diff --git a/dashboard.qrc b/dashboard.qrc new file mode 100644 index 0000000..3188564 --- /dev/null +++ b/dashboard.qrc @@ -0,0 +1,7 @@ + + + + dashboard/app.js + dashboard/index.html + + diff --git a/dashboard/app.js b/dashboard/app.js new file mode 100644 index 0000000..249e6e2 --- /dev/null +++ b/dashboard/app.js @@ -0,0 +1,102 @@ +class EvDashApp { + constructor() { + this.statusDot = document.getElementById('statusDot'); + this.connectionStatus = document.getElementById('connectionStatus'); + this.outgoingStructure = document.getElementById('outgoingStructure'); + this.incomingMessage = document.getElementById('incomingMessage'); + + this.requestTemplate = { + version: '1.0', + requestId: 'uuid-v4', + action: 'ping', + payload: {} + }; + + this.responseTemplate = { + version: '1.0', + requestId: 'uuid-v4', + event: 'statusUpdate', + payload: { + status: 'ok', + timestamp: new Date().toISOString() + } + }; + + this.renderOutgoingTemplate(); + this.connect(); + } + + get websocketUrl() { + const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + const hostname = window.location.hostname || 'localhost'; + const port = 4449; + const normalizedHost = hostname.includes(':') ? `[${hostname}]` : hostname; + return `${protocol}${normalizedHost}:${port}`; + } + + connect() { + this.updateStatus('Connecting…', 'connecting'); + this.socket = new WebSocket(this.websocketUrl); + + this.socket.addEventListener('open', () => { + this.updateStatus('Connected', 'connected'); + }); + + this.socket.addEventListener('message', event => { + try { + const data = JSON.parse(event.data); + this.incomingMessage.textContent = JSON.stringify(data, null, 2); + } catch (error) { + this.incomingMessage.textContent = `Failed to parse message: ${error.message}`; + } + }); + + this.socket.addEventListener('error', () => { + this.updateStatus('Connection error', 'error'); + }); + + this.socket.addEventListener('close', () => { + this.updateStatus('Disconnected. Reconnecting…', 'error'); + setTimeout(() => this.connect(), 3000); + }); + } + + updateStatus(text, state) { + this.connectionStatus.textContent = text; + this.statusDot.classList.remove('connected', 'connecting', 'error'); + this.statusDot.classList.add(state); + } + + renderOutgoingTemplate() { + const example = { + ...this.requestTemplate, + payload: { + example: true + } + }; + this.outgoingStructure.textContent = JSON.stringify({ + request: this.requestTemplate, + response: this.responseTemplate, + exampleRequest: example + }, null, 2); + } + + sendExampleMessage() { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + const message = { + ...this.requestTemplate, + requestId: crypto.randomUUID ? crypto.randomUUID() : `req-${Date.now()}`, + payload: { + command: 'ping', + timestamp: new Date().toISOString() + } + }; + this.socket.send(JSON.stringify(message)); + return message; + } + console.warn('WebSocket is not connected.'); + return null; + } +} + +window.app = new EvDashApp(); diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..52bc504 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,126 @@ + + + + + + nymea EV Dash + + + +
+

Hello from nymea EV Dash

+

+ + Connecting… +

+
+
+
+

WebSocket Messages

+

Outgoing JSON structure:

+

+            

Last incoming JSON message:

+
No messages received yet.
+
+ +
+

Quick Start

+

This page establishes a bidirectional WebSocket connection to the nymea EV Dash backend. Use app.sendExampleMessage() in the browser console to send a sample payload.

+
+
+ + + + + diff --git a/plugin/evdashengine.cpp b/plugin/evdashengine.cpp index 2d126a3..407ed90 100644 --- a/plugin/evdashengine.cpp +++ b/plugin/evdashengine.cpp @@ -30,11 +30,177 @@ #include "evdashengine.h" +#include + +#include +#include +#include + +#include +#include +#include + #include Q_DECLARE_LOGGING_CATEGORY(dcEvDashExperience) -EvDashEngine::EvDashEngine(QObject *parent) - : QObject{parent} +EvDashEngine::EvDashEngine(ThingManager *thingManager, EvDashWebServerResource *webServerResource, QObject *parent) + : QObject{parent}, + m_thingManager{thingManager}, + m_webServerResource{webServerResource} { + m_webSocketServer = new QWebSocketServer(QStringLiteral("EvDashEngine"), QWebSocketServer::NonSecureMode, this); + connect(m_webSocketServer, &QWebSocketServer::newConnection, this, &EvDashEngine::handleNewConnection); + connect(m_webSocketServer, &QWebSocketServer::acceptError, this, [this](QAbstractSocket::SocketError error) { + qCWarning(dcEvDashExperience()) << "WebSocket accept error" << error << m_webSocketServer->errorString(); + }); + startWebSocket(4449); +} + +EvDashEngine::~EvDashEngine() +{ + if (m_webSocketServer->isListening()) { + m_webSocketServer->close(); + } + + for (QWebSocket *client : qAsConst(m_clients)) { + if (client->state() == QAbstractSocket::ConnectedState) { + client->close(QWebSocketProtocol::CloseCodeGoingAway, QStringLiteral("Server shutting down")); + } + client->deleteLater(); + } + m_clients.clear(); +} + +bool EvDashEngine::startWebSocket(quint16 port) +{ + if (m_webSocketServer->isListening()) { + if (m_webSocketServer->serverPort() == port && port != 0) { + return true; + } + m_webSocketServer->close(); + } + + const bool listening = m_webSocketServer->listen(QHostAddress::Any, port); + if (listening) { + qCDebug(dcEvDashExperience()) << "WebSocket server listening on" << m_webSocketServer->serverAddress() << m_webSocketServer->serverPort(); + } else { + qCWarning(dcEvDashExperience()) << "Failed to start WebSocket server" << m_webSocketServer->errorString(); + } + + emit webSocketListeningChanged(listening); + return listening; +} + +quint16 EvDashEngine::webSocketPort() const +{ + if (!m_webSocketServer->isListening()) { + return 0; + } + return m_webSocketServer->serverPort(); +} + +void EvDashEngine::handleNewConnection() +{ + QWebSocket *socket = m_webSocketServer->nextPendingConnection(); + if (!socket) { + qCWarning(dcEvDashExperience()) << "Received new connection but socket was null"; + return; + } + + connect(socket, &QWebSocket::textMessageReceived, this, [this, socket](const QString &message) { + processTextMessage(socket, message); + }); + connect(socket, &QWebSocket::disconnected, this, &EvDashEngine::handleSocketDisconnected); + + m_clients.append(socket); + qCDebug(dcEvDashExperience()) << "WebSocket client connected" << socket->peerAddress() << "Total clients:" << m_clients.count(); +} + +void EvDashEngine::handleSocketDisconnected() +{ + QWebSocket *socket = qobject_cast(sender()); + if (!socket) { + return; + } + + m_clients.removeAll(socket); + qCDebug(dcEvDashExperience()) << "WebSocket client disconnected" << socket->peerAddress() << "Remaining clients:" << m_clients.count(); + socket->deleteLater(); +} + +void EvDashEngine::processTextMessage(QWebSocket *socket, const QString &message) +{ + if (!socket) { + return; + } + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + qCWarning(dcEvDashExperience()) << "Invalid WebSocket payload" << parseError.errorString(); + + QJsonObject errorReply{ + {QStringLiteral("version"), QStringLiteral("1.0")}, + {QStringLiteral("event"), QStringLiteral("error")}, + {QStringLiteral("payload"), QJsonObject{ + {QStringLiteral("message"), QStringLiteral("Invalid JSON payload")}, + {QStringLiteral("details"), parseError.errorString()} + }} + }; + sendReply(socket, errorReply); + return; + } + + const QJsonObject response = handleApiRequest(doc.object()); + sendReply(socket, response); +} + +QJsonObject EvDashEngine::handleApiRequest(const QJsonObject &request) const +{ + QJsonObject response; + response.insert(QStringLiteral("version"), request.value(QStringLiteral("version")).toString(QStringLiteral("1.0"))); + + const QString requestId = request.value(QStringLiteral("requestId")).toString(); + if (!requestId.isEmpty()) { + response.insert(QStringLiteral("requestId"), requestId); + } + + const QString action = request.value(QStringLiteral("action")).toString(); + + if (action.compare(QStringLiteral("ping"), Qt::CaseInsensitive) == 0) { + response.insert(QStringLiteral("event"), QStringLiteral("statusUpdate")); + QJsonObject payload; + payload.insert(QStringLiteral("status"), QStringLiteral("ok")); + payload.insert(QStringLiteral("timestamp"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + + const QJsonObject requestPayload = request.value(QStringLiteral("payload")).toObject(); + if (!requestPayload.isEmpty()) { + payload.insert(QStringLiteral("echo"), requestPayload); + } + + response.insert(QStringLiteral("payload"), payload); + } else { + response.insert(QStringLiteral("event"), QStringLiteral("error")); + QJsonObject payload; + payload.insert(QStringLiteral("message"), QStringLiteral("Unknown action")); + payload.insert(QStringLiteral("action"), action); + response.insert(QStringLiteral("payload"), payload); + } + + return response; +} + +void EvDashEngine::sendReply(QWebSocket *socket, QJsonObject response) const +{ + if (!socket) { + return; + } + + if (!response.contains(QStringLiteral("version"))) { + response.insert(QStringLiteral("version"), QStringLiteral("1.0")); + } + + const QJsonDocument replyDoc(response); + socket->sendTextMessage(QString::fromUtf8(replyDoc.toJson(QJsonDocument::Compact))); } diff --git a/plugin/evdashengine.h b/plugin/evdashengine.h index 6a3b255..ae2a008 100644 --- a/plugin/evdashengine.h +++ b/plugin/evdashengine.h @@ -33,14 +33,41 @@ #include +class QWebSocket; +class QWebSocketServer; + +class ThingManager; +class EvDashWebServerResource; + class EvDashEngine : public QObject { Q_OBJECT public: - explicit EvDashEngine(QObject *parent = nullptr); + explicit EvDashEngine(ThingManager *thingManager, EvDashWebServerResource *webServerResource, QObject *parent = nullptr); + + ~EvDashEngine() override; + + bool startWebSocket(quint16 port = 0); + quint16 webSocketPort() const; + QString webSocketPath() const; signals: + void webSocketListeningChanged(bool listening); +private slots: + void handleNewConnection(); + void handleSocketDisconnected(); + +private: + ThingManager *m_thingManager = nullptr; + EvDashWebServerResource *m_webServerResource = nullptr; + QWebSocketServer *m_webSocketServer = nullptr; + + QList m_clients; + + void processTextMessage(QWebSocket *socket, const QString &message); + QJsonObject handleApiRequest(const QJsonObject &request) const; + void sendReply(QWebSocket *socket, QJsonObject response) const; }; #endif // EVDASHENGINE_H diff --git a/plugin/evdashjsonhandler.h b/plugin/evdashjsonhandler.h index 9b51cad..7531621 100644 --- a/plugin/evdashjsonhandler.h +++ b/plugin/evdashjsonhandler.h @@ -45,7 +45,7 @@ public: QString name() const override; - Q_INVOKABLE JsonReply *SetEnabled(const QVariantMap ¶ms); + // Q_INVOKABLE JsonReply *SetEnabled(const QVariantMap ¶ms); signals: void EnabledChanged(const QVariantMap ¶ms); diff --git a/plugin/evdashwebserverresource.cpp b/plugin/evdashwebserverresource.cpp index 3b536d4..9e9af46 100644 --- a/plugin/evdashwebserverresource.cpp +++ b/plugin/evdashwebserverresource.cpp @@ -1,5 +1,7 @@ #include "evdashwebserverresource.h" +#include + #include Q_DECLARE_LOGGING_CATEGORY(dcEvDashExperience) @@ -17,5 +19,44 @@ bool EvDashWebServerResource::authenticationRequired() const HttpReply *EvDashWebServerResource::processRequest(const HttpRequest &request) { qCDebug(dcEvDashExperience()) << "Process request" << request.url().toString(); - return HttpReply::createSuccessReply(); + + // Verify methods + if (request.method() != HttpRequest::Get) { + HttpReply *reply = HttpReply::createErrorReply(HttpReply::MethodNotAllowed); + reply->setHeader(HttpReply::AllowHeader, "GET"); + return reply; + } + + // Redirect base url to index + if (request.url().path() == basePath() || request.url().path() == basePath() + "/") { + qCDebug(dcEvDashExperience()) << "Base URL called, redirect to main page..."; + return redirectToIndex(); + } + + // Check if this is a static file we can provide + QString fileName = request.url().path().remove(basePath()); + qCDebug(dcEvDashExperience()) << "Check filename" << fileName; + if (verifyStaticFile(fileName)) + return WebServerResource::createFileReply(":/dashboard" + fileName); + + // If nothing matches, redirect to main page + qCWarning(dcEvDashExperience()) << "Resource for debug interface not found. Redirecting to main page..."; + return redirectToIndex(); } + +HttpReply *EvDashWebServerResource::redirectToIndex() +{ + HttpReply *reply = HttpReply::createErrorReply(HttpReply::PermanentRedirect); + reply->setHeader(HttpReply::LocationHeader, QString(basePath() + "/index.html").toLocal8Bit()); + return reply; +} + +bool EvDashWebServerResource::verifyStaticFile(const QString &fileName) +{ + if (QFileInfo::exists(":/dashboard" + fileName)) + return true; + + qCWarning(dcEvDashExperience()) << "Could not find" << fileName << "in resource files"; + return false; +} + diff --git a/plugin/evdashwebserverresource.h b/plugin/evdashwebserverresource.h index b35e17a..a8ef572 100644 --- a/plugin/evdashwebserverresource.h +++ b/plugin/evdashwebserverresource.h @@ -14,6 +14,11 @@ public: bool authenticationRequired() const override; HttpReply *processRequest(const HttpRequest &request) override; + +private: + HttpReply *redirectToIndex(); + + bool verifyStaticFile(const QString &fileName); }; #endif // EVDASHWEBSERVERRESOURCE_H diff --git a/plugin/experiencepluginevdash.cpp b/plugin/experiencepluginevdash.cpp index dfb6a53..234e61e 100644 --- a/plugin/experiencepluginevdash.cpp +++ b/plugin/experiencepluginevdash.cpp @@ -50,10 +50,9 @@ void ExperiencePluginEvDash::init() qCDebug(dcEvDashExperience()) << "Initializing experience..."; m_webServerResource = new EvDashWebServerResource(this); + m_engine = new EvDashEngine(thingManager(), m_webServerResource, this); - EvDashEngine *engine = new EvDashEngine(this); - jsonRpcServer()->registerExperienceHandler(new EvDashJsonHandler(engine, this), 1, 0); - + jsonRpcServer()->registerExperienceHandler(new EvDashJsonHandler(m_engine, this), 1, 0); } WebServerResource *ExperiencePluginEvDash::webServerResource() const diff --git a/plugin/experiencepluginevdash.h b/plugin/experiencepluginevdash.h index f6f9d54..8f48c2b 100644 --- a/plugin/experiencepluginevdash.h +++ b/plugin/experiencepluginevdash.h @@ -37,6 +37,7 @@ Q_DECLARE_LOGGING_CATEGORY(dcEvDashExperience) +class EvDashEngine; class EvDashWebServerResource; class ExperiencePluginEvDash: public ExperiencePlugin @@ -53,6 +54,7 @@ public: WebServerResource *webServerResource() const override; private: + EvDashEngine *m_engine = nullptr; EvDashWebServerResource *m_webServerResource = nullptr; }; diff --git a/plugin/plugin.pro b/plugin/plugin.pro index a07ca37..1cae799 100644 --- a/plugin/plugin.pro +++ b/plugin/plugin.pro @@ -6,8 +6,10 @@ include(../config.pri) CONFIG += plugin link_pkgconfig PKGCONFIG += nymea +RESOURCES += ../dashboard.qrc + QT -= gui -QT += network sql +QT += network sql websockets HEADERS += experiencepluginevdash.h \ evdashengine.h \