Basic dashboard providing and working websocket connection

initial-version
Simon Stürz 2025-11-07 15:44:44 +01:00
parent f362344009
commit 4efed08aac
11 changed files with 486 additions and 9 deletions

7
dashboard.qrc Normal file
View File

@ -0,0 +1,7 @@
<RCC>
<qresource prefix="/dashboard"/>
<qresource prefix="/">
<file>dashboard/app.js</file>
<file>dashboard/index.html</file>
</qresource>
</RCC>

102
dashboard/app.js Normal file
View File

@ -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();

126
dashboard/index.html Normal file
View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nymea EV Dash</title>
<style>
:root {
--primary-color: #0050a0;
--secondary-color: #00a0e0;
--accent-color: #f4b400;
--background-color: #f5f7fa;
--text-color: #1f2d3d;
}
body {
font-family: "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
background-color: var(--background-color);
color: var(--text-color);
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: #ffffff;
padding: 2rem 1.5rem;
text-align: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
main {
flex: 1;
padding: 2rem 1.5rem;
max-width: 960px;
margin: 0 auto;
}
.card {
background: #ffffff;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 12px rgba(31, 45, 61, 0.08);
margin-bottom: 1.5rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #d3dce6;
transition: background-color 0.3s ease;
}
.status-dot.connected {
background-color: #2ecc71;
}
.status-dot.connecting {
background-color: #f4b400;
}
.status-dot.error {
background-color: #e74c3c;
}
pre {
background: #f8fafc;
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
font-size: 0.95rem;
}
footer {
text-align: center;
padding: 1rem;
background-color: #ffffff;
border-top: 1px solid #d3dce6;
font-size: 0.9rem;
}
a {
color: inherit;
}
</style>
</head>
<body>
<header>
<h1>Hello from nymea EV Dash</h1>
<p class="status-indicator">
<span id="statusDot" class="status-dot connecting" aria-hidden="true"></span>
<span id="connectionStatus">Connecting…</span>
</p>
</header>
<main>
<section class="card">
<h2>WebSocket Messages</h2>
<p>Outgoing JSON structure:</p>
<pre id="outgoingStructure"></pre>
<p>Last incoming JSON message:</p>
<pre id="incomingMessage">No messages received yet.</pre>
</section>
<section class="card">
<h2>Quick Start</h2>
<p>This page establishes a bidirectional WebSocket connection to the nymea EV Dash backend. Use <code>app.sendExampleMessage()</code> in the browser console to send a sample payload.</p>
</section>
</main>
<footer>
<span id="appVersion">Version 0.1.0</span> · &copy; 20132025 nymea GmbH. All rights reserved.
</footer>
<script src="app.js"></script>
</body>
</html>

View File

@ -30,11 +30,177 @@
#include "evdashengine.h"
#include <integrations/thingmanager.h>
#include <QWebSocket>
#include <QWebSocketServer>
#include <QHostAddress>
#include <QDateTime>
#include <QJsonParseError>
#include <QJsonDocument>
#include <QLoggingCategory>
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<QWebSocket *>(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)));
}

View File

@ -33,14 +33,41 @@
#include <QObject>
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<QWebSocket *> 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

View File

@ -45,7 +45,7 @@ public:
QString name() const override;
Q_INVOKABLE JsonReply *SetEnabled(const QVariantMap &params);
// Q_INVOKABLE JsonReply *SetEnabled(const QVariantMap &params);
signals:
void EnabledChanged(const QVariantMap &params);

View File

@ -1,5 +1,7 @@
#include "evdashwebserverresource.h"
#include <QFileInfo>
#include <QLoggingCategory>
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;
}

View File

@ -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

View File

@ -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

View File

@ -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;
};

View File

@ -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 \