Add notifications

initial-version
Simon Stürz 2025-11-11 10:49:02 +01:00
parent 842a4510f0
commit cc476812fb
6 changed files with 435 additions and 46 deletions

View File

@ -3,5 +3,6 @@
<qresource prefix="/">
<file>dashboard/app.js</file>
<file>dashboard/index.html</file>
<file>dashboard/help.html</file>
</qresource>
</RCC>

View File

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

257
dashboard/help.html Normal file
View File

@ -0,0 +1,257 @@
<!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 · Help</title>
<style>
:root {
--primary-color: #0050a0;
--secondary-color: #00a0e0;
--accent-color: #f4b400;
--surface-color: #ffffff;
--background-color: #f5f7fa;
--text-color: #1f2d3d;
--muted-text-color: #566b84;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", Roboto, sans-serif;
background: var(--background-color);
color: var(--text-color);
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: #ffffff;
padding: 2.5rem 1.5rem 2rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.header-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.brand h1 {
margin: 0 0 0.4rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.brand p {
margin: 0;
opacity: 0.9;
}
.session-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem 1.5rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font-weight: 600;
margin: 0;
}
.status-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background-color: #d3dce6;
transition: background-color 0.3s ease;
}
.status-dot.connecting {
background-color: #f4b400;
}
.status-dot.connected {
background-color: #2ecc71;
}
.status-dot.authenticating {
background-color: #8e44ad;
}
.status-dot.error {
background-color: #e74c3c;
}
.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;
}
.session-details {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.95rem;
}
button.ghost {
border: 1px solid rgba(255, 255, 255, 0.55);
color: #ffffff;
background: transparent;
padding: 0.35rem 0.85rem;
border-radius: 999px;
font-size: 0.9rem;
}
button.ghost:hover {
border-color: #ffffff;
background: rgba(255, 255, 255, 0.12);
}
main {
flex: 1;
width: min(960px, 92vw);
margin: 2rem auto 3rem;
display: grid;
gap: 1.5rem;
}
.card {
background: var(--surface-color);
border-radius: 14px;
padding: 1.5rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06);
}
.card h2 {
margin-top: 0;
font-size: 1.25rem;
}
.grid-two-column {
display: grid;
gap: 1.5rem;
}
@media (min-width: 900px) {
.grid-two-column {
grid-template-columns: 1fr 1fr;
}
}
pre {
margin: 0;
background: #f8fafc;
border-radius: 10px;
padding: 1rem;
overflow-x: auto;
font-size: 0.95rem;
}
code {
font-family: "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
.helper-text {
font-size: 0.9rem;
color: var(--muted-text-color);
}
footer {
text-align: center;
padding: 1.25rem 1rem;
background: #ffffff;
border-top: 1px solid #d3dce6;
font-size: 0.9rem;
color: var(--muted-text-color);
}
</style>
</head>
<body data-mode="help">
<header>
<div class="header-bar">
<div class="brand">
<h1>EV Dash</h1>
<p>Reference & diagnostics</p>
</div>
<div class="session-panel">
<p class="status-indicator">
<span id="statusDot" class="status-dot connecting" aria-hidden="true"></span>
<span id="connectionStatus">Awaiting login…</span>
</p>
<div class="session-details">
<span id="sessionSummary">Load the dashboard to authenticate.</span>
<button type="button" id="logoutButton" class="ghost hidden">Logout</button>
</div>
<a class="tool-button" href="index.html" aria-label="Back to dashboard"></a>
</div>
</div>
</header>
<main>
<section class="card">
<h2>API Contract</h2>
<p class="helper-text">All requests follow the structure below. Use <code>app.sendAction(action, payload)</code> from the browser console after authenticating on the dashboard.</p>
<div class="grid-two-column">
<div>
<h3>Request template</h3>
<pre id="requestTemplate"></pre>
</div>
<div>
<h3>Responses</h3>
<pre id="responseTemplate"></pre>
</div>
</div>
</section>
<section class="card" aria-live="polite">
<h2>Last WebSocket message</h2>
<p class="helper-text">Inspect the raw payload received from the nymea backend. Sign in on the dashboard first so this page can reuse the stored session.</p>
<pre id="incomingMessage">No messages received yet.</pre>
</section>
<section class="card">
<h2>Charger table basics</h2>
<ul>
<li>The main dashboard creates a row for each charger ID and keeps it in sync with backend notifications.</li>
<li>Columns follow the order defined by <code>EvDashEngine::packCharger</code>, so new properties appear automatically.</li>
<li>Branding (colours, fonts) is driven by CSS variables near the top of each HTML file for easy theming.</li>
</ul>
</section>
</main>
<footer>
<span id="appVersion">nymea EV Dash</span> · &copy; 20132025 chargebyte GmbH. All rights reserved.
</footer>
<script src="app.js"></script>
</body>
</html>

View File

@ -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 @@
<span id="sessionSummary">Please sign in.</span>
<button type="button" id="logoutButton" class="ghost hidden">Logout</button>
</div>
<a class="tool-button" href="help.html" aria-label="Open help">?</a>
</div>
</div>
</header>
<main>
<section class="card">
<h2>Chargers</h2>
<p class="helper-text">Live data from your configured nymea EV chargers.</p>
<div class="table-wrapper">
<table class="chargers-table">
<thead>
@ -373,25 +394,6 @@
</table>
</div>
</section>
<section class="card">
<h2>API Contract</h2>
<p class="helper-text">All requests follow the structure below. Use <code>app.sendAction(action, payload)</code> from the browser console after authentication.</p>
<div class="grid-two-column">
<div>
<h3>Request template</h3>
<pre id="requestTemplate"></pre>
</div>
<div>
<h3>Responses</h3>
<pre id="responseTemplate"></pre>
</div>
</div>
</section>
<section class="card" aria-live="polite">
<h2>Last message</h2>
<pre id="incomingMessage">No messages received yet.</pre>
</section>
</main>
<section id="loginOverlay" class="login-view" aria-labelledby="loginTitle">

View File

@ -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 &notification, 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;

View File

@ -35,6 +35,8 @@
#include <QHash>
#include <QJsonObject>
#include <integrations/thing.h>
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<QWebSocket *> m_clients;
QHash<QWebSocket *, QString> m_authenticatedClients;
QList<Thing *> 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 &notification, 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