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
+
+
+
+
+
+
+
+ API Contract
+ All requests follow the structure below. Use app.sendAction(action, payload) from the browser console after authenticating on the dashboard.
+
+
+
+
+ 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
+
+ - The main dashboard creates a row for each charger ID and keeps it in sync with backend notifications.
+ - Columns follow the order defined by
EvDashEngine::packCharger, so new properties appear automatically.
+ - Branding (colours, fonts) is driven by CSS variables near the top of each HTML file for easy theming.
+
+
+
+
+
+
+
+
+
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.
-
- API Contract
- All requests follow the structure below. Use app.sendAction(action, payload) from the browser console after authentication.
-
-
-
-
- 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