Basic dashboard providing and working websocket connection
parent
f362344009
commit
4efed08aac
|
|
@ -0,0 +1,7 @@
|
|||
<RCC>
|
||||
<qresource prefix="/dashboard"/>
|
||||
<qresource prefix="/">
|
||||
<file>dashboard/app.js</file>
|
||||
<file>dashboard/index.html</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
|
@ -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();
|
||||
|
|
@ -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> · © 2013–2025 nymea GmbH. All rights reserved.
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
Loading…
Reference in New Issue