/*! * * Copyright (c) 2024-2026 Diality Inc. - All Rights Reserved. * \copyright * THIS CODE MAY NOT BE COPIED OR REPRODUCED IN ANY FORM, IN PART OR IN * WHOLE, WITHOUT THE EXPLICIT PERMISSION OF THE COPYRIGHT OWNER. * * \file CloudConnectController.cpp * \author (original) Stephen Quong * \date (original) 24-May-2026 * */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "CloudConnectController.h" #include "LeahiMsgDefs.pb.h" #include "LeahiMsgProtoUtils.h" Q_LOGGING_CATEGORY(logConfig, "config") Q_LOGGING_CATEGORY(logProto, "proto") Q_LOGGING_CATEGORY(logCanRouting, "can.routing") Q_LOGGING_CATEGORY(logMqtt, "mqtt") Q_LOGGING_CATEGORY(logMqttStats, "mqtt.stats") Q_LOGGING_CATEGORY(logCanStats, "can.stats") /*! * \brief CloudConnectController::CloudConnectController * \details Constructor. Wires the frame→dispatcher→completed-message pipeline. * The CAN device itself is started by startCan() once this object is * on its worker thread. * \param[in] parent - optional QObject parent * \note Must be constructed on the main thread, but later moved to a worker thread. */ CloudConnectController::CloudConnectController(QObject *parent) : QObject(parent), _canInterface(this), _dispatcher(this), _mqttClient(this) { connect(&_canInterface, &Can::CanInterface::didFrameReceive, this, &CloudConnectController::onCanFrameReceive); connect(&_dispatcher, &Can::MessageDispatcher::didActionReceive, this, &CloudConnectController::onCanMessageReceive); connect(&_mqttClient, &MqttClient::didStateChanged, this, &CloudConnectController::onCloudStateChanged); connect(&_mqttClient, &MqttClient::didMessageStatusChanged, this, &CloudConnectController::onCloudMessageStatusChanged); connect(&_mqttClient, &MqttClient::didMessageReceived, this, &CloudConnectController::onCloudMessageReceived); } /*! * \brief CloudConnectController::~CloudConnectController */ CloudConnectController::~CloudConnectController() = default; /*! * \brief CloudConnectController::loadConfig * \details Loads the CloudConnect configuration INI file and validates the content. * \param[in] configPath - file path of CloudConnect configuration INI * \return True if config is loaded successfully, otherwise false. */ bool CloudConnectController::loadConfig(const QString &configPath) { static auto resolvePath = [](const QString &path) -> QString { const QFileInfo info(path); return info.isAbsolute() ? path : QString("%1/%2").arg(QCoreApplication::applicationDirPath(), path); }; if (!QFileInfo::exists(configPath)) { qCCritical(logConfig).noquote() << QString("config file %1 does not exist").arg(configPath); return false; } QSettings config = QSettings(configPath, QSettings::IniFormat); if (config.status() != QSettings::NoError) { qCCritical(logConfig).noquote() << QString("could not read config file %1").arg(configPath); return false; } // NOTE: do not try to prefix keys in the General (i.e. "General/") or beginGroup("General") or // QSettings will look for a group in the INI file called "%General", "General" is a special group. if (!loadCanRouting(resolvePath(config.value(QStringLiteral("CanRoutingConfig")).toString()))) { return false; } _appServerSocketPath = config.value("App/SocketName", "/tmp/cloudconnect.sock").toString(); config.beginGroup(QStringLiteral("Cloud")); MqttClient::Config mqttConfig; mqttConfig.endpoint = config.value(QStringLiteral("ServerAddress"), QStringLiteral("localhost")).toString(); mqttConfig.port = config.value(QStringLiteral("Port"), 8883).toInt(); mqttConfig.clientId = config.value(QStringLiteral("ClientId")).toString(); mqttConfig.caPath = resolvePath(config.value(QStringLiteral("CaFile")).toString()); mqttConfig.certPath = resolvePath(config.value(QStringLiteral("CertFile")).toString()); mqttConfig.keyPath = resolvePath(config.value(QStringLiteral("KeyFile")).toString()); _deviceId = config.value(QStringLiteral("SerialNumber")).toString(); _topicPrefix = config.value(QStringLiteral("TopicPrefix")).toString(); config.endGroup(); if (!_mqttClient.init(mqttConfig)) { return false; } return true; } /*! * \brief CloudConnectController::initThread * \details Moves this object and its children onto a worker thread and starts it. * \param[in] thread - thread to move to, owned by the caller. * \note Must be called from the main thread, before the event loop runs. */ void CloudConnectController::initThread(QThread &thread) { Q_ASSERT_X(QThread::currentThread() == qApp->thread(), __func__, "CloudConnectController initialization must be done in Main Thread"); thread.setObjectName(QString("%1_Thread").arg(metaObject()->className())); moveToThread(&thread); thread.start(); } /*! * \brief CloudConnectController::startCan * \details Creates and connects the CAN device. * \return true if the device was created and connected. * \note Must run on the controller thread, after initThread(). */ bool CloudConnectController::startCan() { Q_ASSERT_X(QThread::currentThread() == thread(), __func__, "startCan() must run on the controller thread"); return _canInterface.init(); } /*! * \brief CloudConnectController::listenForApp * \details Creates the app socket server, then starts listening. * \return true if the server bound successfully, false otherwise. * \note Must run on the controller thread, after initThread(). * QLocalServer ties its socket engine to the thread that calls * listen(), and accepted client sockets are parented to that engine. */ bool CloudConnectController::listenForApp() { // use Q_ASSERT only during object creation or thread move Q_ASSERT_X(QThread::currentThread() == thread(), __func__, "listenForApp() must run on the controller thread"); if (_appServer == nullptr) { _appServer = QSharedPointer::create(this); } return _appServer->listen(_appServerSocketPath); } /*! * \brief CloudConnectController::connectToCloud * \details Starts connecting to the cloud server with the configuration loaded in loadConfig. * \return true if the attempt was started, or false if MQTT is disabled. * \note Must run on the controller thread, after initThread(). The interface's * QMqttClient socket belongs to the thread that constructs it. */ bool CloudConnectController::connectToCloud() { Q_ASSERT_X(QThread::currentThread() == thread(), __func__, "connectToCloud() must run on the controller thread"); return _mqttClient.open(); } /*! * \brief CloudConnectController::loadCanRouting * \details Parses CAN message routing INI and populates _canRouting. * \param[in] canRoutingPath - path to the CAN message routing INI file * \return true if CanRouting config successfully loads, otherwise false */ bool CloudConnectController::loadCanRouting(const QString &canRoutingPath) { static const QHash actionMap = { { QStringLiteral("SendAlways"), CanAction::SendAlways }, { QStringLiteral("SendDelta"), CanAction::SendDelta }, { QStringLiteral("Drop"), CanAction::Drop }, }; static const QHash topicMap = { { QStringLiteral("HighPriority"), CloudConnectFrame::Topic::HighPriority }, { QStringLiteral("NormalPriority"), CloudConnectFrame::Topic::NormalPriority }, { QStringLiteral("DeviceLogFile"), CloudConnectFrame::Topic::DeviceLogFile }, { QStringLiteral("TreatmentLogFile"), CloudConnectFrame::Topic::TreatmentLogFile }, { QStringLiteral("CloudSyncLogFile"), CloudConnectFrame::Topic::CloudSyncLogFile }, }; if (!QFile::exists(canRoutingPath)) { qCWarning(logCanRouting).noquote() << QString("CAN mesg routing config file %1 does not exist").arg(canRoutingPath); return false; } QSettings canRoutingConfig(canRoutingPath, QSettings::IniFormat); if (canRoutingConfig.status() != QSettings::NoError) { qCWarning(logCanRouting).noquote() << QString("could not read CAN message routing config file %1").arg(canRoutingPath); return false; } int msgCount = 0; for (const QString &group : canRoutingConfig.childGroups()) { bool ok = false; const Can::MsgId msgId = static_cast(group.toUInt(&ok, 16)); if (!ok) { qCWarning(logCanRouting).noquote() << QString("could not convert group \"%1\" to MsgId in CAN mesg routing config file %2") .arg(group, canRoutingPath); continue; } canRoutingConfig.beginGroup(group); const QString msgIdStr = canRoutingConfig.value(QStringLiteral("msg_id")).toString(); const QString actionStr = canRoutingConfig.value(QStringLiteral("action")).toString().trimmed(); const QString topicStr = canRoutingConfig.value(QStringLiteral("topic")).toString().trimmed(); // qCInfo(logCanRouting).noquote() << QString("%1 (0x%2): action=%3, topic=%4") // .arg(msgIdStr, QString("%1").arg(quint16(msgId), 4, 16, QChar('0')).toUpper(), actionStr, topicStr); canRoutingConfig.endGroup(); CanRouting canRouting; // TODO: uncomment the following line after testing // canRouting.action = actionMap.value(actionStr, CanAction::Drop); canRouting.action = actionMap.value(actionStr, CanAction::SendAlways); // TODO: remove after testing canRouting.topic = topicMap.value(topicStr, CloudConnectFrame::Topic::NormalPriority); if (actionStr.length() > 0 && !actionMap.contains(actionStr)) { qCWarning(logCanRouting).noquote() << QString("unknown message action \"%1\" for msgId=0x%2, defaulting to Drop") .arg(actionStr, QString("%1").arg(quint16(msgId), 4, 16, QChar('0').toUpper())); } if (topicStr.length() > 0 && !topicMap.contains(topicStr)) { qCWarning(logCanRouting).noquote() << QString("unknown message topic \"%1\" for msgId=0x%2, defaulting to NormalPriority") .arg(topicStr, QString("%1").arg(quint16(msgId), 4, 16, QChar('0').toUpper())); } _canRouting.insert(msgId, canRouting); msgCount++; } qCInfo(logCanRouting).noquote() << QString("loaded CAN message routing %1 (%2 entries)").arg(canRoutingPath).arg(msgCount); return true; } /*! * \brief CloudConnectController::mqttTopic * \details Builds the MQTT topic for a message class. * \param topic - message class from the CAN message routing INI * \return Topic of the form {prefix}/{deviceId}/{suffix}. */ QString CloudConnectController::mqttTopic(CloudConnectFrame::Topic topic) const { static const QMap suffixes = { { CloudConnectFrame::Topic::HighPriority, QStringLiteral("high") }, { CloudConnectFrame::Topic::NormalPriority, QStringLiteral("normal") }, { CloudConnectFrame::Topic::DeviceLogFile, QStringLiteral("log") }, { CloudConnectFrame::Topic::TreatmentLogFile, QStringLiteral("tx_log") }, { CloudConnectFrame::Topic::CloudSyncLogFile, QStringLiteral("cs_log") }, }; // TODO: uncomment the following line after testing // return suffixes.value(topic, QStringLiteral("normal")); Q_UNUSED(topic) return QString("%1/%2/%3").arg(_topicPrefix, _deviceId, "clinical"); // TODO: remove after test } /*! * \brief CloudConnectController::onCanFrameReceive * \details Unpacks a CAN frame and sends it to the dispatcher to reassemble the entire CAN message. * \param frame - the received CAN frame */ void CloudConnectController::onCanFrameReceive(const QCanBusFrame &frame) { _dispatcher.onFrameReceive(Can::CanId(frame.frameId()), frame.payload()); } /*! * \brief CloudConnectController::onCanMessageReceive * \details Applies the CAN mesg routing policy from CanRouting.ini. * \param msg - the reassembled message */ void CloudConnectController::onCanMessageReceive(const Can::Message &msg) { // BEGIN: CAN Mesg Stats if (!_canTimer.isValid()) { _canTimer.start(); } const auto now = _canTimer.nsecsElapsed() / 1000; const auto last = _canLastRecv; _canLastRecv = now; _canCount++; if (_canCount > 1) { _canTotalTime += (now - last); _canAvgTime = _canTotalTime / qint64(_canCount - 1); } qCInfo(logCanStats).noquote() << canStats(msg.msgId, (_canCount > 1) ? (now - last) : 0); // END: CAN Mesg Stats const auto it = _canRouting.constFind(msg.msgId); if (it == _canRouting.constEnd()) { qCInfo(logCanRouting).noquote() << QString("no action defined for %1 (0x%2), dropping") .arg(leahi::msgIdString(leahi::MsgId(msg.msgId))).arg(msg.msgId, 4, 16, QChar('0')); return; } if (it->action == CanAction::Drop) { qCInfo(logCanRouting).noquote() << QString("%1 (0x%2) dropped") .arg(leahi::msgIdString(leahi::MsgId(msg.msgId))).arg(msg.msgId, 4, 16, QChar('0')); return; } auto &[received, cachedMsg] = _canCache[msg.msgId]; if (it->action == CanAction::SendDelta && received && cachedMsg.data.chopped(1) == msg.data.chopped(1)) { // move chops to payload sending qCInfo(logCanRouting).noquote() << QString("%1 (0x%2) did not changed from previous, dropping") .arg(leahi::msgIdString(leahi::MsgId(msg.msgId))).arg(msg.msgId, 4, 16, QChar('0')); return; } QByteArray payload; if (leahi::canMessageToProtobufByteArray(QDateTime::currentDateTime(), msg, payload)) { qint32 pubId; if (_mqttClient.publish(mqttTopic(it->topic), payload, pubId)) { qCWarning(logMqtt).noquote() << QString("message[ msgId=0x%1 (%2), seq=%3 ], published (pubId=%4)") .arg(QString("%1").arg(msg.msgId, 4, 16, QChar('0')).toUpper()) .arg(leahi::msgIdString(leahi::MsgId(msg.msgId))) .arg(msg.sequence) .arg(pubId); // BEGIN: CAN Mesg Stats if (_inflight.contains(pubId)) { // The client reuses packet ids once they retire, so a collision means the // previous publish never reached a terminal state. Report the loss rather // than let the old entry disappear silently. const Inflight &stale = _inflight.value(pubId); qCWarning(logMqttStats).noquote() << QString("publish id %1 still in flight for 0x%2 seq=%3, replacing") .arg(pubId).arg(stale.msgId, 4, 16, QChar('0')).arg(stale.sequence); } _msgSentCount++; _inflight.insert(pubId, Inflight { Inflight::Sent, msg.msgId, msg.sequence, msgElapsedUs() }); // END: CAN Mesg Stats } else { // TODO: spool the message if publish fails qCWarning(logMqtt).noquote() << QString("could not publish %1 (0x%2), message lost") .arg(leahi::msgIdString(leahi::MsgId(msg.msgId))).arg(msg.msgId, 4, 16, QChar('0')); } } else { qCWarning(logProto).noquote() << QString("could not serialize %1 (0x%2) message to protobuf, message dropped") .arg(leahi::msgIdString(leahi::MsgId(msg.msgId))).arg(msg.msgId, 4, 16, QChar('0')); } // Cache the message only after it has been successfully sent // TODO: this may need to be moved so the message is cached on ACK cachedMsg = msg; } /*! * \brief CloudConnectController::onCloudStateChanged * \details Re-establishes the echo subscriptions on every new session, and * retires anything still awaiting an echo when one ends. * \param connected - true once the broker has accepted the session * \note Subscribing here rather than in connectToCloud() is required, not * stylistic: open() only starts the attempt, and a SUBSCRIBE sent * before the CONNACK is rejected by the client. */ void CloudConnectController::onCloudStateChanged(QMqttClient::ClientState state) { switch (state) { case QMqttClient::Disconnected: // clear any pending messages _inflight.clear(); // TODO: Mesg Stats break; case QMqttClient::Connecting: break; case QMqttClient::Connected: // BEGIN: Mesg Stats _canCount = 0; _canAvgTime = 0; _canTotalTime = 0; _canLastRecv = 0; _canTimer.invalidate(); _inflight.clear(); _msgTimer.invalidate(); _msgSentCount = 0; _msgAckCount = 0; _msgRecvCount = 0; _msgRecvMatchCount = 0; _msgAckTotalTime = 0; _msgRecvTotalTime = 0; _msgAckAvgTime = 0; _msgRecvAvgTime = 0; // END: Mesg Stats (void)_mqttClient.subscribe(mqttTopic(CloudConnectFrame::Topic::NormalPriority)); break; default: break; } } /*! * \brief CloudConnectController::onCloudMessageStatusChanged * \details Correlates one message delivered by the broker back to the publish * that produced it and credits that msgId. * \param[in] msgId - message identifier * \param[in] status - new message status * \param[in] properties - additional properties specified by the server/broker */ void CloudConnectController::onCloudMessageStatusChanged(qint32 id, QMqtt::MessageStatus status, const QMqttMessageStatusProperties &properties) { Q_UNUSED(properties) // SQ switch (status) { case QMqtt::MessageStatus::Acknowledged: { // PUBACK (QoS 1&2) // BEGIN: Mesg Stats const auto it = _inflight.find(id); if (it == _inflight.end()) { qCWarning(logMqttStats).noquote() << QString("PUBACK for unknown publish id %1, ignoring").arg(id); break; } it->status = Inflight::ACK; const qint64 ackUs = msgElapsedUs() - it->sentUs; _msgAckCount++; _msgAckTotalTime += ackUs; _msgAckAvgTime = _msgAckTotalTime / qint64(_msgAckCount); qCInfo(logMqttStats).noquote() << msgStats(QStringLiteral("PUBACK"), it->msgId, it->sequence, QStringLiteral("publish to ack"), ackUs, _msgAckAvgTime); // END: Mesg Stats break; } case QMqtt::MessageStatus::Unknown: case QMqtt::MessageStatus::Published: // PUBLISH (QoS 1) case QMqtt::MessageStatus::Received: // PUBREC (QoS 2) case QMqtt::MessageStatus::Released: // PUBREL (QoS 2) case QMqtt::MessageStatus::Completed: // PUBCOMP (QoS 2) default: break; } } /*! * \brief CloudConnectController::onCloudMessageReceived * \details Handle received MQTT messages * \param[in] message - MQTT message data * \param[in] topic - MQTT topic */ void CloudConnectController::onCloudMessageReceived(const QByteArray &message, const QMqttTopicName &topic) { qCInfo(logMqtt).noquote() << QString("received message with topic=%1").arg(topic.name()); // SQ // TODO: filter by topic leahi::messages::Envelope envelope; if (!envelope.ParseFromArray(message.constData(), message.size()) || !envelope.has_header()) { qCWarning(logMqtt).noquote() << QString("received mesg (size=%1) for topic %2 do not contain a valid protobuf message, ignoring") .arg(message.size()).arg(topic.name()); return; } const leahi::messages::Header &header = envelope.header(); const Can::MsgId msgId = Can::MsgId(header.msgid()); const Can::Sequence sequence = static_cast(header.sequence()); const qint64 headerMsecs = header.timestamp().seconds() * 1000 + header.timestamp().nanos() / 1000000; qCInfo(logProto).noquote() << QString("header: msgId=0x%1 (%2), seq=%3, timestamp=%4") .arg(QString("%1").arg(msgId, 4, 16, QChar('0')).toUpper(), leahi::msgIdString(static_cast(msgId))) .arg(header.sequence()) .arg(QDateTime::fromMSecsSinceEpoch(headerMsecs).toString(Qt::ISODateWithMs)); // BEGIN: Mesg Stats const qint64 now = msgElapsedUs(); _msgRecvCount++; qint64 recvUs = -1; for (auto it = _inflight.begin(); it != _inflight.end(); ++it) { if (it->msgId == msgId && it->sequence == sequence) { recvUs = now - it->sentUs; _inflight.erase(it); break; } } if (recvUs >= 0) { _msgRecvMatchCount++; _msgRecvTotalTime += recvUs; _msgRecvAvgTime = _msgRecvTotalTime / qint64(_msgRecvMatchCount); } qCInfo(logMqttStats).noquote() << msgStats(QStringLiteral("SUB"), msgId, sequence, QStringLiteral("publish to receive"), recvUs, _msgRecvAvgTime); // END: Mesg Stats const std::string &typeName = leahi::msgIdToProtoName(msgId); if (typeName.empty()) { qCWarning(logProto).noquote() << QString("no protobuf mapping for message with msgId=0x%1, ignoring").arg(msgId, 4, 16, QChar('0')); return; } const google::protobuf::Descriptor *descriptor = google::protobuf::DescriptorPool::generated_pool()->FindMessageTypeByName(typeName); if (descriptor == nullptr) { qCWarning(logProto).noquote() << QString("no descriptor for protobuf message %1, ignoring").arg(QString::fromStdString(typeName)); return; } google::protobuf::DynamicMessageFactory factory; std::unique_ptr body(factory.GetPrototype(descriptor)->New()); if (!body->ParseFromArray(message.constData(), static_cast(message.size()))) { qCWarning(logProto).noquote() << QString("could not parse protobuf message %1, ignoring").arg(QString::fromStdString(typeName)); return; } std::string json; google::protobuf::util::JsonPrintOptions options; options.add_whitespace = true; options.always_print_primitive_fields = true; google::protobuf::util::MessageToJsonString(*body, &json, options); qCDebug(logProto).noquote() << QString::fromStdString(typeName) << ":" << Qt::endl << QString::fromStdString(json); } // BEGIN: Mesg Stats QString CloudConnectController::canStats(Can::MsgId msgId, qint64 sinceLastUs) const { return QString("0x%1: count=%2, time since last=%3ms, avg time=%4ms") .arg(QString("%1").arg(msgId, 4, 16, QChar('0')).toUpper()) .arg(_canCount) .arg(sinceLastUs / 1000.0, 6, 'f', 3, QChar(' ')) .arg(_canAvgTime / 1000.0, 6, 'f', 3, QChar(' ')); } qint64 CloudConnectController::msgElapsedUs() { if (!_msgTimer.isValid()) { _msgTimer.start(); } return _msgTimer.nsecsElapsed() / 1000; } QString CloudConnectController::msgStats(const QString &label, Can::MsgId msgId, qint16 sequence, const QString &latencyLabel, qint64 latencyUs, qint64 avgUs) const { return QString("%1: msgId=0x%2 (%3), seq=%4, %5=%6, avg %7=%8ms") .arg(label) .arg(QString("%1").arg(msgId, 4, 16, QChar('0')).toUpper(), leahi::msgIdString(leahi::MsgId(msgId))) .arg(sequence) .arg(latencyLabel, (latencyUs >= 0) ? QString("%1ms").arg(latencyUs / 1000.0, 6, 'f', 3, QChar(' ')) : QStringLiteral("unknown")) .arg(latencyLabel) .arg(avgUs / 1000.0, 6, 'f', 3, QChar(' ')); } // END: Mesg Stats