8
\$\begingroup\$

So I built a house recently and didn't want to rely on "privacy-questionnable" systems like Google Home or Amazon whatever so I decided to build a doorbell system myself.

Hardware

I use a RPI4B+ as doorbell hardware and MQTT as communication platform between my home server, the doorbell hardware and my Android app, which makes three components.

  1. RPI4B+ - my outdoor doorbell including RPI webcam, a simple mic and a simple speaker
  2. Ubuntu server - my MQTT server
  3. Android client - my doorbell app (which is the one to be reviewed here)

Software - Android Client

For development, I use Qt 5.15 with QtMQTT. For webcam video transfer, I use a simple RTSP server.

I am having heavy architectural issues in my code, which you're about to see.

The Problem

My MQTT client is built upon the QMqttClient class which I use both in QML and C++. When triggering the open door MQTT message, I want to use the same client I'm using declared in my QML file but I just don't feel like what I am doing there is right because I just can't seem to use the QML-declared MQTT client in my C++ code reasonably.

Please give me some feedback on my code architecture and tell me a better way to have a clean architecture here, thanks a lot in advance.

Code

DoorOpener.h

#ifndef DOOROPENER_H
#define DOOROPENER_H

#include <QObject>
#include <QByteArray>
#include <QMqttClient>

class DoorOpener : public QObject
{
    Q_OBJECT
public:
    DoorOpener(QObject *parent = nullptr);
    ~DoorOpener() = default;

public slots:
    bool open();

private:
    QMqttClient* _client;
    std::string _access_token {};

    void initializeWithMqtt();
};

#endif // DOOROPENER_H

DoorOpener.cpp

#include "DoorOpener.h"
#include "log.h"
#include "variables.h"

#include <QFile>
#include <QDebug>

DoorOpener::DoorOpener(QObject* parent)
{   
    Q_UNUSED(parent);

    initializeWithMqtt();
}

void DoorOpener::initializeWithMqtt()
{
    _client = new QMqttClient();
    _client->setHostname(hdvars::MQTT_HOSTNAME.c_str());
    _client->setPort(hdvars::MQTT_PORT);

    LOG_DBG("Initializing DoorOpener:");
    LOG_DBG("\t - Host: " + _client->hostname().toStdString());
    LOG_DBG("\t - Port: " + std::to_string(_client->port()));

    // TODO this is bad architecture. it should be safe to know when the connection is established. (see main.qml)
    if (_client->state() != QMqttClient::Connected) {
        _client->connectToHost();
    }

    connect(_client, &QMqttClient::stateChanged, [](QMqttClient::ClientState state) {
        LOG_INF("MQTT client state is now " + [&]() -> std::string {
            switch (state) {
            case QMqttClient::Disconnected:
                return "\"Disconnected\"";
            case QMqttClient::Connecting:
                return "\"Connecting\"";
            case QMqttClient::Connected:
                return "\"Connected\"";
            default:
                return "";
            }
        }());
    });

    if (QFile atFile { "access_token" }; atFile.open(QIODevice::ReadOnly)) {
        LOG_DBG("Reading access token.");
        QString token { atFile.readAll() };

        // we only care about the first line of the "access_token" file
        _access_token = token.split("\n").first().toStdString();

        if (_access_token.empty()) {
            LOG_ERR("Unable to read access token.");
        }
        else {
            LOG_INF("Access token read.");
        }
    }
}

bool DoorOpener::open()
{
    if (_client->state() != QMqttClient::Connected) {
        LOG_ERR("Connect to MQTT client before calling open() function.");
        return false;
    }

    LOG_DBG("Trying to send MQTT message to server with topic " + hdvars::MQTT_TOPIC + ".");

    if (_access_token.empty()) {
        LOG_WRN("No access token provided.");
    }

    qint32 retval { _client->publish(QMqttTopicName(hdvars::MQTT_TOPIC.c_str()), QByteArray(_access_token.c_str())) };

    LOG_DBG("publish() returned " + std::to_string(retval) + ".");

    return 0 == retval;
}

QmlMqttClient.h

#ifndef QMLMQTTCLIENT_H
#define QMLMQTTCLIENT_H

#include <QtCore/QMap>
#include <QtMqtt/QMqttClient>
#include <QtMqtt/QMqttSubscription>

class QmlMqttClient;

class QmlMqttSubscription : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QMqttTopicFilter topic MEMBER m_topic NOTIFY topicChanged)
public:
    QmlMqttSubscription(QMqttSubscription *s, QmlMqttClient *c);
    ~QmlMqttSubscription();

Q_SIGNALS:
    void topicChanged(QString);
    void messageReceived(const QString &msg);

public slots:
    void handleMessage(const QMqttMessage &qmsg);

private:
    Q_DISABLE_COPY(QmlMqttSubscription)
    QMqttSubscription *sub;
    QmlMqttClient *client;
    QMqttTopicFilter m_topic;
};

class QmlMqttClient : public QMqttClient
{
    Q_OBJECT
public:
    QmlMqttClient(QObject *parent = nullptr);

    Q_INVOKABLE QmlMqttSubscription* subscribe();
private:
    Q_DISABLE_COPY(QmlMqttClient)
};

#endif // QMLMQTTCLIENT_H

QmlMqttClient.cpp

#include "QmlMqttClient.h"
#include "log.h"
#include "variables.h"

QmlMqttClient::QmlMqttClient(QObject *parent)
    : QMqttClient(parent)
{
    setHostname(hdvars::MQTT_HOSTNAME.c_str());
    setPort(hdvars::MQTT_PORT);
}

QmlMqttSubscription* QmlMqttClient::subscribe()
{
    LOG_INF("Initializing subscription with topic " + hdvars::MQTT_TOPIC + ".");

    QMqttSubscription* sub { QMqttClient::subscribe(QMqttTopicFilter(hdvars::MQTT_TOPIC.c_str()), 0) };
    QmlMqttSubscription* result { new QmlMqttSubscription(sub, this) };

    return result;
}

QmlMqttSubscription::QmlMqttSubscription(QMqttSubscription *s, QmlMqttClient *c)
    : sub(s)
    , client(c)
{
    connect(sub, &QMqttSubscription::messageReceived, this, &QmlMqttSubscription::handleMessage);
    m_topic = sub->topic();
}

QmlMqttSubscription::~QmlMqttSubscription()
{
}

void QmlMqttSubscription::handleMessage(const QMqttMessage &qmsg)
{
    emit messageReceived(qmsg.payload());
}

variables.h

#ifndef VARIABLES_H
#define VARIABLES_H

#include <string>

namespace hdvars
{
static const std::string MQTT_HOSTNAME { "localhost" };
static const std::string MQTT_TOPIC { "my_topic/heimdall" };

static const std::uint32_t MQTT_PORT { 1883 };
}

#endif // VARIABLES_H

main.cpp

#include <QGuiApplication>
#include <QQmlApplicationEngine>

#include "log.h"
#include "QmlMqttClient.h"
#include "DoorOpener.h"

int main(int argc, char *argv[])
{
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
    QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
#endif

    // TODO put all these log lines in a static function in log.h: initialize(std::string filename)
    // Logging configuration
    std::string const& logfile { "heimdall-client.log" };

    if (!freopen(logfile.c_str(), "a", stderr)) {
        // we can still write to stderr.
        LOG_ERR("Unable to create log file! Continuing without log.");
    }

    LOG_INF("------------------\n");
    LOG_INF("Hello from heimdall client.");

    QGuiApplication app(argc, argv);

    QQmlApplicationEngine engine;
    const QUrl url(QStringLiteral("qrc:/main.qml"));
    QObject::connect(&engine, &QQmlApplicationEngine::objectCreated,
                     &app, [url](QObject *obj, const QUrl &objUrl) {
        if (!obj && url == objUrl) {
            LOG_FTL("Unable to load " + url.toString().toStdString() + ". Exiting.");
            QCoreApplication::exit(-1);
        }
    }, Qt::QueuedConnection);

    qmlRegisterType<QmlMqttClient>("MqttClient", 1, 0, "MqttClient");
    qmlRegisterUncreatableType<QmlMqttSubscription>("MqttClient", 1, 0, "MqttSubscription", QLatin1String("Subscriptions are read-only"));

    qmlRegisterType<DoorOpener>("DoorOpener", 1, 0, "DoorOpener");

    engine.load(url);

    return app.exec();
}

main.qml

import QtQuick 2.15
import QtQuick.Window 2.15
import QtQuick.Controls 2.1
import QtQuick.Layouts 1.1
import QtMultimedia 5.12
import MqttClient 1.0
import DoorOpener 1.0

Window {
    width: 360
    height: 640
    visible: true
    title: qsTr("heimdall")

    // background image
    Image {
        source: "qrc:/bg.jpg"
        anchors.fill: parent
        fillMode: Image.Pad
        opacity: .25
    }

    // mqtt client
    MqttClient {
        id: client

        Component.onCompleted: {
            // TODO this is bad architecture. it should be safe to know when the connection is established. (see DoorOpener.cpp)
            connectToHost()
        }

        onConnected: {
            subscribe()
        }

        onDisconnected: {
            video.stop()
        }

        onStateChanged: {
            stateIndicator.color = getState()
            stateIndicatorText.text = color == "green" ? "✔" : "𐄂"
        }

        onMessageReceived: {
            video.play()
        }

        Component.onDestruction: {
            disconnectFromHost()
        }

        function getState() {
            switch (state) {
            case MqttClient.Disconnected:
                return "red"
            case MqttClient.Connecting:
                return "yellow"
            case MqttClient.Connected:
                return "green"
            default:
                return "gray"
            }
        }
    }

    DoorOpener {
        id: doorOpener
    }

    // caption
    Label {
        id: caption
        // text: "Heimdall - Welcome Home"
        text: "Here's Heimdall"

        font {
            family: "Open Sans MS"
            bold: true
            capitalization: Font.SmallCaps
            pointSize: 14
        }

        anchors {
            top: parent.top
            topMargin: 10
            horizontalCenter: parent.horizontalCenter
        }
    }

    // webcam stream
    Video {
        id: video
        objectName: "vplayer"
        width: parent.width - 20
        height: 200
        autoPlay: client.connected
        source: "rtsp://localhost:8554/mystream" // TODO

        onPlaying: vplaceholder.visible = false

        Image {
            id: vplaceholder
            width: parent.width
            height: parent.height
            source: "qrc:/test_picture.png"
        }

        anchors {
            top: caption.bottom
            topMargin: 10
            horizontalCenter: parent.horizontalCenter
        }

        onErrorChanged: {
            console.log("error: " + video.errorString)
        }

        MouseArea {
            anchors.fill: parent
            onClicked: {
                video.muted = !video.muted
            }
        }

        focus: true

        Image {
            id: muteIndicator
            source: "mute_white.png"

            width: 64
            height: width

            visible: video.muted

            anchors.centerIn: parent
        }
    } // Video

    // Buttons
    Item {
        width: parent.width
        height: 50

        anchors {
            top: video.bottom
            topMargin: 10
            centerIn: parent
        }

        Button {
            id: btnTalk
            objectName: "btnTalk"

            text: "Sprechen"

            anchors.left: parent.left
            anchors.leftMargin: 10

            onClicked: {
                bgrTalk.start()
            }

            Rectangle {
                id: bgrect_talk
                width: parent.width
                height: parent.height
                color: "white"
            }

            SequentialAnimation {
                id: bgrTalk

                PropertyAnimation {
                    target: bgrect_talk
                    property: "color"
                    to: "darkseagreen"

                    duration: 500
                }
                PropertyAnimation {
                    target: bgrect_talk
                    property: "color"
                    to: "white"

                    duration: 500
                    easing.type: Easing.InCirc
                }
            }
        }

        Button {
            id: btnOpen
            objectName: "btnOpen"

            text: "Öffnen"

            property var bgColor: "darkred"

            anchors.right: parent.right
            anchors.rightMargin: 10

            onClicked: {
                var opened = doorOpener.open()

                if (opened) {
                    bgColor = "forestgreen"
                }

                bgrOpened.start()
            }

            Rectangle {
                id: bgrect_open
                width: parent.width
                height: parent.height
                color: "white"
            }

            SequentialAnimation {
                id: bgrOpened

                PropertyAnimation {
                    target: bgrect_open
                    property: "color"
                    to: btnOpen.bgColor

                    duration: 500
                }
                PropertyAnimation {
                    target: bgrect_open
                    property: "color"
                    to: "white"

                    duration: 500
                    easing.type: Easing.InCirc
                }
            }
        }
    }

    Item {
        anchors {
            left: parent.left
            leftMargin: 10
            bottom: parent.bottom
            bottomMargin: 10
        }

        width: parent.width - 20
        height: 25

        Rectangle {
            id: stateIndicator
            width: 75
            height: 20

            color: "gray"

            Text {
                id: stateIndicatorText

                text: "?"
                color: "white"

                anchors.centerIn: parent
            }
        }

        Button {
            anchors.right: parent.right

            text: "Reconnect"

            onPressed: {
                client.disconnectFromHost()
                vplaceholder.visible = true
                client.connectToHost()
            }

            width: 75
            height: 20
        }
    }
}
\$\endgroup\$
2
  • 1
    \$\begingroup\$ "So I built a house recently..." One of the best openings to a SE post ever! :) \$\endgroup\$ Jan 19, 2022 at 10:23
  • 1
    \$\begingroup\$ @CodeGorilla thanks! :-D \$\endgroup\$
    – tai
    Jan 25, 2022 at 22:30

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.