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.
+
+
+
+ Version 0.1.0 · © 2013–2025 nymea GmbH. All rights reserved.
+
+
+
+
+
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 \