diff --git a/dashboard.qrc b/dashboard.qrc index 3188564..8bbbd4d 100644 --- a/dashboard.qrc +++ b/dashboard.qrc @@ -3,5 +3,6 @@ dashboard/app.js dashboard/index.html + dashboard/help.html diff --git a/dashboard/app.js b/dashboard/app.js index dabd427..2ef97f3 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -333,6 +333,8 @@ class DashboardApp { return; } + console.log('<--', data); + if (this.elements.incomingMessage) this.elements.incomingMessage.textContent = JSON.stringify(data, null, 2); @@ -380,7 +382,13 @@ class DashboardApp { } handleUnsolicitedMessage(data) { - if (!data || !data.payload) + if (!data) + return false; + + if (data.event && this.handleNotificationEvent(data.event, data.payload)) + return true; + + if (!data.payload) return false; const payload = data.payload; @@ -390,7 +398,7 @@ class DashboardApp { return true; } - if (payload.charger && payload.charger.id) { + if (payload.charger) { this.upsertCharger(payload.charger); return true; } @@ -398,6 +406,24 @@ class DashboardApp { return false; } + handleNotificationEvent(eventName, payload) { + if (!eventName) + return false; + + const normalizedEvent = typeof eventName === 'string' ? eventName.toLowerCase() : ''; + switch (normalizedEvent) { + case 'chargeradded': + case 'chargerchanged': + this.upsertCharger(payload); + return true; + case 'chargerremoved': + this.removeCharger(payload); + return true; + default: + return false; + } + } + onAuthenticationSucceeded() { this.updateConnectionStatus('Connected', 'connected'); this.updateSessionSummary(); @@ -481,9 +507,10 @@ class DashboardApp { const seen = new Set(); chargers.forEach(charger => { - if (!charger || !charger.id) + const key = this.getChargerKey(charger); + if (!key) return; - seen.add(charger.id); + seen.add(key); this.upsertCharger(charger); }); @@ -494,21 +521,24 @@ class DashboardApp { } upsertCharger(charger) { - if (!charger || !charger.id) + const key = this.getChargerKey(charger); + if (!key) return; - const hasExisting = this.chargers.has(charger.id); - const previous = hasExisting ? this.chargers.get(charger.id) : {}; + const hasExisting = this.chargers.has(key); + const previous = hasExisting ? this.chargers.get(key) : {}; const merged = { ...previous, ...charger }; - this.chargers.set(charger.id, merged); + merged.thingId = key; + this.chargers.set(key, merged); this.syncChargerRow(merged, !hasExisting); } syncChargerRow(charger, forceCreate = false) { - if (!charger || !charger.id || !this.elements.chargerTableBody) + const key = this.getChargerKey(charger); + if (!charger || !key || !this.elements.chargerTableBody) return; - let row = this.findChargerRow(charger.id); + let row = this.findChargerRow(key); if (!row || forceCreate) { if (row && row.parentElement) row.parentElement.removeChild(row); @@ -528,7 +558,7 @@ class DashboardApp { buildChargerRow(charger) { const row = document.createElement('tr'); - row.dataset.chargerId = charger.id; + row.dataset.chargerId = this.getChargerKey(charger) || ''; this.chargerColumns.forEach(column => { const cell = document.createElement('td'); cell.dataset.column = column.key; @@ -538,22 +568,13 @@ class DashboardApp { return row; } - findChargerRow(chargerId) { - if (!this.elements.chargerTableBody || !chargerId) - return null; - - const normalizedId = typeof CSS !== 'undefined' && CSS.escape - ? CSS.escape(String(chargerId)) - : String(chargerId).replace(/"/g, '\\"'); - return this.elements.chargerTableBody.querySelector(`tr[data-charger-id="${normalizedId}"]`); - } - - removeCharger(chargerId) { - if (!chargerId) + removeCharger(identifier) { + const key = this.getChargerKey(identifier); + if (!key) return; - this.chargers.delete(chargerId); - const row = this.findChargerRow(chargerId); + this.chargers.delete(key); + const row = this.findChargerRow(key); if (row && row.parentElement) row.parentElement.removeChild(row); @@ -573,6 +594,32 @@ class DashboardApp { this.toggleChargerEmptyState(); } + findChargerRow(chargerId) { + if (!this.elements.chargerTableBody || !chargerId) + return null; + + const normalizedId = typeof CSS !== 'undefined' && CSS.escape + ? CSS.escape(String(chargerId)) + : String(chargerId).replace(/"/g, '\\"'); + return this.elements.chargerTableBody.querySelector(`tr[data-charger-id="${normalizedId}"]`); + } + + getChargerKey(source) { + if (!source) + return null; + + if (typeof source === 'string') + return source; + + if (source.thingId) + return source.thingId; + + if (source.id) + return source.id; + + return null; + } + toggleChargerEmptyState() { if (!this.elements.chargerEmptyRow) return; @@ -680,7 +727,7 @@ class DashboardApp { setAuthLayout(requireAuth) { const body = document.body; - if (!body) + if (!body || body.dataset.mode === 'help') return; body.classList.toggle('needs-auth', requireAuth); } diff --git a/dashboard/help.html b/dashboard/help.html new file mode 100644 index 0000000..cf49cc2 --- /dev/null +++ b/dashboard/help.html @@ -0,0 +1,257 @@ + + + + + + nymea EV Dash · Help + + + +
+
+
+

EV Dash

+

Reference & diagnostics

+
+
+

+ + Awaiting login… +

+
+ Load the dashboard to authenticate. + +
+ +
+
+
+ +
+
+

API Contract

+

All requests follow the structure below. Use app.sendAction(action, payload) from the browser console after authenticating on the dashboard.

+
+
+

Request template

+

+                
+
+

Responses

+

+                
+
+
+ +
+

Last WebSocket message

+

Inspect the raw payload received from the nymea backend. Sign in on the dashboard first so this page can reuse the stored session.

+
No messages received yet.
+
+ +
+

Charger table basics

+ +
+
+ + + + + + diff --git a/dashboard/index.html b/dashboard/index.html index e06b276..a53ae55 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -62,6 +62,27 @@ gap: 1rem 1.5rem; } + .tool-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.65); + color: #ffffff; + background: transparent; + text-decoration: none; + font-weight: 600; + font-size: 1.1rem; + transition: background 0.15s ease, border-color 0.15s ease; + } + + .tool-button:hover { + background: rgba(255, 255, 255, 0.15); + border-color: #ffffff; + } + main { flex: 1; width: min(960px, 92vw); @@ -341,13 +362,13 @@ Please sign in. + ?

Chargers

-

Live data from your configured nymea EV chargers.

@@ -373,25 +394,6 @@
-
-

API Contract

-

All requests follow the structure below. Use app.sendAction(action, payload) from the browser console after authentication.

-
-
-

Request template

-

-                
-
-

Responses

-

-                
-
-
- -
-

Last message

-
No messages received yet.
-
diff --git a/plugin/evdashengine.cpp b/plugin/evdashengine.cpp index 1ab0024..d45c6c4 100644 --- a/plugin/evdashengine.cpp +++ b/plugin/evdashengine.cpp @@ -52,6 +52,19 @@ EvDashEngine::EvDashEngine(ThingManager *thingManager, EvDashWebServerResource * m_thingManager{thingManager}, m_webServerResource{webServerResource} { + Things evChargers = m_thingManager->configuredThings().filterByInterface("evcharger"); + + // Init charger list + foreach (Thing *chargerThing, evChargers) { + m_chargers.append(chargerThing); + monitorChargerThing(chargerThing); + } + + connect(m_thingManager, &ThingManager::thingAdded, this, &EvDashEngine::onThingAdded); + connect(m_thingManager, &ThingManager::thingRemoved, this, &EvDashEngine::onThingRemoved); + connect(m_thingManager, &ThingManager::thingChanged, this, &EvDashEngine::onThingChanged); + + // Setup websocket server m_webSocketServer = new QWebSocketServer(QStringLiteral("EvDashEngine"), QWebSocketServer::NonSecureMode, this); connect(m_webSocketServer, &QWebSocketServer::newConnection, this, [this](){ @@ -100,6 +113,44 @@ EvDashEngine::~EvDashEngine() m_authenticatedClients.clear(); } +void EvDashEngine::onThingAdded(Thing *thing) +{ + if (thing->thingClass().interfaces().contains("evcharger")) { + m_chargers.append(thing); + monitorChargerThing(thing); + sendNotification("ChargerAdded", packCharger(thing)); + } +} + +void EvDashEngine::onThingRemoved(const ThingId &thingId) +{ + foreach (Thing *thing, m_chargers) { + if (thing->id() == thingId) { + qCDebug(dcEvDashExperience()) << "Charger has been removed."; + m_chargers.removeAll(thing); + sendNotification("ChargerRemoved", packCharger(thing)); + } + } +} + +void EvDashEngine::onThingChanged(Thing *thing) +{ + sendNotification("ChargerChanged", packCharger(thing)); +} + +void EvDashEngine::monitorChargerThing(Thing *thing) +{ + connect(thing, &Thing::stateValueChanged, this, [this, thing](const StateTypeId &stateTypeId, const QVariant &value, const QVariant &minValue, const QVariant &maxValue, const QVariantList &possibleValues){ + Q_UNUSED(stateTypeId) + Q_UNUSED(value) + Q_UNUSED(minValue) + Q_UNUSED(maxValue) + Q_UNUSED(possibleValues) + + onThingChanged(thing); + }); +} + bool EvDashEngine::startWebSocket(quint16 port) { if (m_webSocketServer->isListening()) { @@ -232,6 +283,21 @@ void EvDashEngine::sendReply(QWebSocket *socket, QJsonObject response) const socket->sendTextMessage(QString::fromUtf8(replyDoc.toJson(QJsonDocument::Compact))); } +void EvDashEngine::sendNotification(const QString ¬ification, QJsonObject payload) const +{ + // Send to all active clients + + for (QWebSocket *client : qAsConst(m_clients)) { + QJsonObject notificationObject; + notificationObject.insert(QStringLiteral("requestId"), QUuid::createUuid().toString(QUuid::WithoutBraces)); + notificationObject.insert("event", notification); + notificationObject.insert("payload", payload); + const QJsonDocument notificationDoc(notificationObject); + qCDebug(dcEvDashExperience()) << "<--" << qUtf8Printable(notificationDoc.toJson(QJsonDocument::Compact)); + client->sendTextMessage(QString::fromUtf8(notificationDoc.toJson(QJsonDocument::Compact))); + } +} + QJsonObject EvDashEngine::createSuccessResponse(const QString &requestId, const QJsonObject &payload) const { QJsonObject response; diff --git a/plugin/evdashengine.h b/plugin/evdashengine.h index 6b6e7d2..f61c2ab 100644 --- a/plugin/evdashengine.h +++ b/plugin/evdashengine.h @@ -35,6 +35,8 @@ #include #include +#include + class QWebSocket; class QWebSocketServer; @@ -52,6 +54,11 @@ public: signals: void webSocketListeningChanged(bool listening); +private slots: + void onThingAdded(Thing *thing); + void onThingRemoved(const ThingId &thingId); + void onThingChanged(Thing *thing); + private: ThingManager *m_thingManager = nullptr; EvDashWebServerResource *m_webServerResource = nullptr; @@ -60,14 +67,23 @@ private: QList m_clients; QHash m_authenticatedClients; + QList m_chargers; + void monitorChargerThing(Thing *thing); + + // Websocket server bool startWebSocket(quint16 port = 0); void processTextMessage(QWebSocket *socket, const QString &message); + + // Websocket API QJsonObject handleApiRequest(QWebSocket *socket, const QJsonObject &request); void sendReply(QWebSocket *socket, QJsonObject response) const; + void sendNotification(const QString ¬ification, QJsonObject payload) const; + QJsonObject createSuccessResponse(const QString &requestId, const QJsonObject &payload = {}) const; QJsonObject createErrorResponse(const QString &requestId, const QString &errorMessage) const; QJsonObject packCharger(Thing *charger) const; + }; #endif // EVDASHENGINE_H