From f2517437936bd8505d4543d340bbed06a535e851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Mon, 10 Nov 2025 15:07:11 +0100 Subject: [PATCH] Add login and basic api workflow --- AGENTS.md | 54 +-- dashboard/app.js | 505 +++++++++++++++++++++++++---- dashboard/index.html | 256 ++++++++++++--- plugin/evdashengine.cpp | 113 +++++-- plugin/evdashengine.h | 7 +- plugin/evdashjsonhandler.cpp | 188 ----------- plugin/evdashwebserverresource.cpp | 105 +++++- plugin/evdashwebserverresource.h | 20 ++ 8 files changed, 897 insertions(+), 351 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 25b037b..da0a394 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,45 @@ # Agent Instructions -Welcome to the nymea EV-Dash experience plugin repository. Please keep the following guidelines in mind when working on this codebase: +Welcome to the nymea EV-Dash experience plugin repository. This project ships a Qt-based experience plugin together with a small +HTML dashboard that visualises EV charger information exposed by nymea. The following notes summarise the most important pieces +you will touch while working on tasks in this repository. ## Purpose -The dashboard should should give an overview of conofugred ev chargers in the system, show the status of each ev charger, information and provide +- Deliver a secure, brandable dashboard that shows the status of configured EV chargers, their live data and actions. +- All browser interactions flow through a nymea web server resource that authenticates users and distributes data via WebSocket + push events. -## Structure -- The `plugin` directory contains the Qt c++ implementation of the experience plugin - - The `EvDashJsonHandler` class provides the JSON RPC API definition and declaration of the experience - - The `EvDashWebServerResource` represents the webserver HTTP backend and handles REST API requests and file requests starting with the path /evdash. This class provides file access to the static resources and provides secure generated data access -- The `dashboard` folder contains the webinterface, a html + js file based website representing the frontend. The interface will be compiled into the plugin using the dashboard.qrc file. -- The dashboard should be brandable, providing 3 colors and icons should allow to change the style of the webinterface -- The dashboard uses a websocket to communicate with the API interface in `EvDashEngine`. +## Repository Structure +- `plugin/` + - Contains the Qt/C++ implementation of the experience plugin. + - `EvDashWebServerResource` is responsible for HTTP handling under `/evdash`, including REST endpoints such as the login flow and + serving the compiled dashboard assets from `dashboard.qrc`. + - `EvDashEngine` runs the WebSocket server, receives JSON requests with `{ requestId, action, payload }`, authenticates clients via + session tokens and pushes live updates. + - `EvDashJsonHandler` defines the JSON-RPC schema for nymea integration (keep new actions consistent with this handler). +- `dashboard/` + - Browser-based UI written in HTML and vanilla JavaScript (`index.html`, `app.js`). The files are bundled into the plugin through + `dashboard.qrc`. + - Use CSS variables for branding (primary/secondary/accent colours, icons) so the dashboard can be themed easily. -## General workflow -- Keep pull request descriptions concise but informative, mentioning both user-visible changes and internal refactors. -- Prefer focused commits that touch related files together. +## Implementation Guidelines +- Maintain the request/response contract documented in the repository README and API discussions: + ```json + { "requestId": "uuid", "action": "ActionName", "payload": { } } + ``` + Every response must echo the `requestId`, set `success` and either contain `payload` or an `error` string. +- The WebSocket connection stays closed for unauthenticated clients. They must send an `authenticate` action with a valid login + token obtained via `POST /evdash/api/login`. Reject subsequent requests until the token is validated. +- When adding UI behaviour, ensure the login overlay is shown before establishing a WebSocket connection and that connection status + is clearly visible for operators. -## C++/Qt coding style -- Follow the Qt coding conventions: camelCase for method names and member variables beginning with `m_`. -- Keep `#include` directives alphabetised within their groups (project headers before Qt and system headers). -- Use Qt container classes where the rest of the code already does so. -- Use Qt logging helpers (`qCDebug(dcEvDashExperience())`, `qCWarning(dcEvDashExperience())`, etc.) for new diagnostics. +## Coding Style +- C++/Qt: follow Qt conventions (camelCase, `m_` member prefixes, grouped/alphabetised includes, Qt containers, Qt logging macros). +- JavaScript: prefer small helper functions, avoid external dependencies, keep DOM selectors at the top of the file, and document + any public methods intended for use from the developer console. -## Tests and verification -- When possible, run the relevant unit or integration tests and mention them in the final summary. +## Workflow & Testing +- Keep commits focused and group related changes across frontend/backend in the same patch where it improves traceability. +- Mention any manual or automated verification steps in commit messages and PR summaries. If tests are unavailable, explain why. Thank you for contributing! diff --git a/dashboard/app.js b/dashboard/app.js index c2f8af7..3e12043 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -1,103 +1,470 @@ -class EvDashApp { +class DashboardApp { 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.elements = { + loginOverlay: document.getElementById('loginOverlay'), + loginForm: document.getElementById('loginForm'), + loginButton: document.getElementById('loginButton'), + loginError: document.getElementById('loginError'), + username: document.getElementById('username'), + password: document.getElementById('password'), + statusDot: document.getElementById('statusDot'), + connectionStatus: document.getElementById('connectionStatus'), + sessionSummary: document.getElementById('sessionSummary'), + requestTemplate: document.getElementById('requestTemplate'), + responseTemplate: document.getElementById('responseTemplate'), + incomingMessage: document.getElementById('incomingMessage') }; - this.responseTemplate = { - version: '1.0', - requestId: 'uuid-v4', - event: 'statusUpdate', - payload: { - status: 'ok', - timestamp: new Date().toISOString() + this.sessionKey = 'evdash.session'; + this.socket = null; + this.token = null; + this.tokenExpiry = null; + this.username = null; + this.pendingRequests = new Map(); + this.reconnectTimer = null; + + this.renderStaticTemplates(); + this.attachEventListeners(); + this.restoreSession(); + } + + attachEventListeners() { + if (this.elements.loginForm) { + this.elements.loginForm.addEventListener('submit', event => { + event.preventDefault(); + this.submitLogin(); + }); + } + } + + renderStaticTemplates() { + const contract = { + login: { + method: 'POST /evdash/api/login', + payload: { + username: 'user', + password: 'secret' + } + }, + websocket: { + request: { + requestId: 'uuid', + action: 'ActionName', + payload: {} + }, + authenticate: { + requestId: 'uuid', + action: 'authenticate', + payload: { + token: 'issued-token' + } + } } }; - this.renderOutgoingTemplate(); - this.connect(); + const responses = { + success: { + requestId: 'uuid', + success: true, + payload: {} + }, + failure: { + requestId: 'uuid', + success: false, + error: 'Error message' + }, + examplePing: { + requestId: 'uuid', + success: true, + payload: { + timestamp: '2025-01-12T09:30:00Z' + } + } + }; + + if (this.elements.requestTemplate) + this.elements.requestTemplate.textContent = JSON.stringify(contract, null, 2); + if (this.elements.responseTemplate) + this.elements.responseTemplate.textContent = JSON.stringify(responses, null, 2); } - get websocketUrl() { + restoreSession() { + const stored = window.localStorage.getItem(this.sessionKey); + if (!stored) { + this.showLoginOverlay(); + return; + } + + try { + const parsed = JSON.parse(stored); + if (!parsed || !parsed.token || !parsed.expiresAt) { + this.clearSession(); + this.showLoginOverlay(); + return; + } + + const expiresAt = new Date(parsed.expiresAt); + if (Number.isNaN(expiresAt.getTime()) || expiresAt <= new Date()) { + this.clearSession(); + this.showLoginOverlay('Your session has expired. Please sign in again.'); + return; + } + + this.token = parsed.token; + this.tokenExpiry = expiresAt; + this.username = parsed.username || null; + this.updateSessionSummary(); + this.hideLoginOverlay(); + this.connectWebSocket(); + } catch (error) { + console.warn('Failed to restore session', error); + this.clearSession(); + this.showLoginOverlay('We could not restore your previous session. Please sign in again.'); + } + } + + submitLogin() { + if (!this.elements.username || !this.elements.password || !this.elements.loginButton) + return; + + const username = this.elements.username.value.trim(); + const password = this.elements.password.value; + + if (!username || !password) { + this.showLoginError('Username and password are required.'); + return; + } + + this.setLoginLoading(true); + this.performLoginRequest(username, password) + .then(session => { + this.persistSession({ ...session, username }); + this.hideLoginOverlay(); + this.updateSessionSummary(); + this.connectWebSocket(true); + }) + .catch(error => { + const message = error && error.message ? error.message : 'Login failed. Please try again.'; + this.showLoginError(message); + }) + .finally(() => { + this.setLoginLoading(false); + if (this.elements.password) + this.elements.password.value = ''; + }); + } + + async performLoginRequest(username, password) { + let response; + try { + response = await fetch('/evdash/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ username, password }) + }); + } catch (networkError) { + console.warn('Login request failed', networkError); + throw new Error('Unable to reach the login endpoint. Please check your connection.'); + } + + let data; + try { + data = await response.json(); + } catch (parseError) { + console.warn('Failed to parse login response', parseError); + throw new Error('Received an unexpected response from the server.'); + } + + if (!response.ok || !data.success) { + const errorCode = data && data.error ? data.error : 'unauthorized'; + throw new Error(this.describeLoginError(errorCode)); + } + + if (!data.token || !data.expiresAt) + throw new Error('Invalid response from server.'); + + return { + token: data.token, + expiresAt: data.expiresAt + }; + } + + describeLoginError(code) { + switch (code) { + case 'invalidRequest': + return 'The login request was malformed. Please reload the page and try again.'; + case 'unauthorized': + return 'The provided credentials were not accepted.'; + default: + return 'Login failed. Please try again.'; + } + } + + persistSession(session) { + this.token = session.token; + this.tokenExpiry = new Date(session.expiresAt); + this.username = session.username || null; + + try { + window.localStorage.setItem(this.sessionKey, JSON.stringify({ + token: this.token, + expiresAt: this.tokenExpiry.toISOString(), + username: this.username + })); + } catch (error) { + console.warn('Failed to persist session', error); + } + } + + clearSession() { + this.token = null; + this.tokenExpiry = null; + this.username = null; + this.pendingRequests.clear(); + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + + try { + window.localStorage.removeItem(this.sessionKey); + } catch (error) { + console.warn('Failed to clear session', error); + } + } + + connectWebSocket(resetPending = false) { + if (!this.token) { + this.updateConnectionStatus('Awaiting login…', 'connecting'); + return; + } + + if (this.tokenExpiry && this.tokenExpiry <= new Date()) { + this.clearSession(); + this.showLoginOverlay('Your session has expired. Please sign in again.'); + return; + } + + if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) + return; + + if (resetPending) + this.pendingRequests.clear(); + + clearTimeout(this.reconnectTimer); + this.updateConnectionStatus('Connecting…', 'connecting'); const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; - const hostname = window.location.hostname || 'localhost'; + const host = 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); + const normalizedHost = host.includes(':') ? `[${host}]` : host; + const url = `${protocol}${normalizedHost}:${port}`; + this.socket = new WebSocket(url); this.socket.addEventListener('open', () => { - this.updateStatus('Connected', 'connected'); - this.sendExampleMessage() + this.updateConnectionStatus('Authenticating…', 'authenticating'); + this.sendAuthenticate(); }); 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.onSocketMessage(event); }); this.socket.addEventListener('error', () => { - this.updateStatus('Connection error', 'error'); + this.updateConnectionStatus('Connection error', 'error'); }); this.socket.addEventListener('close', () => { - this.updateStatus('Disconnected. Reconnecting…', 'error'); - setTimeout(() => this.connect(), 3000); + this.onSocketClosed(); }); } - updateStatus(text, state) { - this.connectionStatus.textContent = text; - this.statusDot.classList.remove('connected', 'connecting', 'error'); - this.statusDot.classList.add(state); - } + sendAuthenticate() { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) + return; - renderOutgoingTemplate() { - const example = { - ...this.requestTemplate, + const requestId = this.generateRequestId(); + const message = { + requestId, + action: 'authenticate', payload: { - example: true + token: this.token } }; - this.outgoingStructure.textContent = JSON.stringify({ - request: this.requestTemplate, - response: this.responseTemplate, - exampleRequest: example - }, null, 2); + + this.pendingRequests.set(requestId, { type: 'authenticate' }); + this.socket.send(JSON.stringify(message)); } - 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; + onSocketMessage(event) { + let data; + try { + data = JSON.parse(event.data); + } catch (error) { + console.warn('Failed to parse WebSocket message', error); + this.elements.incomingMessage.textContent = `Failed to parse message: ${error.message}`; + return; } - console.warn('WebSocket is not connected.'); - return null; + + if (this.elements.incomingMessage) + this.elements.incomingMessage.textContent = JSON.stringify(data, null, 2); + + if (data.requestId && this.pendingRequests.has(data.requestId)) { + const pending = this.pendingRequests.get(data.requestId); + this.pendingRequests.delete(data.requestId); + + if (pending.type === 'authenticate') { + if (data.success) { + this.onAuthenticationSucceeded(); + } else { + this.onAuthenticationFailed(data.error || 'unauthorized'); + } + return; + } + } + + if (data.success === false && data.error === 'unauthenticated') { + this.onAuthenticationFailed('unauthenticated'); + } + } + + onAuthenticationSucceeded() { + this.updateConnectionStatus('Connected', 'connected'); + this.updateSessionSummary(); + } + + onAuthenticationFailed(reason) { + const message = reason === 'unauthenticated' + ? 'Your session expired. Please sign in again.' + : 'Authentication failed. Please try again.'; + + console.warn('Authentication failed', reason); + this.clearSession(); + this.showLoginOverlay(message); + this.updateConnectionStatus('Authentication required', 'error'); + + if (this.socket && this.socket.readyState === WebSocket.OPEN) + this.socket.close(); + } + + onSocketClosed() { + this.pendingRequests.clear(); + this.updateConnectionStatus('Disconnected', 'error'); + if (!this.token) { + this.showLoginOverlay(); + return; + } + + clearTimeout(this.reconnectTimer); + this.reconnectTimer = setTimeout(() => { + this.connectWebSocket(); + }, 3000); + } + + sendAction(action, payload = {}) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + console.warn('Cannot send action. WebSocket not connected.'); + return null; + } + + if (action !== 'authenticate' && this.pendingRequests.size && !this.isAuthenticated()) { + console.warn('Cannot send action before authentication succeeded.'); + return null; + } + + const requestId = this.generateRequestId(); + const message = { + requestId, + action, + payload + }; + + this.socket.send(JSON.stringify(message)); + return requestId; + } + + isAuthenticated() { + for (const pending of this.pendingRequests.values()) { + if (pending.type === 'authenticate') + return false; + } + return !!this.token && !!this.socket && this.socket.readyState === WebSocket.OPEN; + } + + sendPing() { + return this.sendAction('ping', { timestamp: new Date().toISOString() }); + } + + updateConnectionStatus(text, state) { + if (this.elements.connectionStatus) + this.elements.connectionStatus.textContent = text; + + if (!this.elements.statusDot) + return; + + const dot = this.elements.statusDot; + dot.classList.remove('connecting', 'connected', 'authenticating', 'error'); + dot.classList.add(state); + } + + updateSessionSummary() { + if (!this.elements.sessionSummary) + return; + + if (!this.token) { + this.elements.sessionSummary.textContent = 'Please sign in to start the WebSocket session.'; + return; + } + + const expires = this.tokenExpiry ? this.tokenExpiry.toISOString() : 'unknown'; + const username = this.username ? this.username : 'user'; + this.elements.sessionSummary.textContent = `Signed in as ${username}. Token valid until ${expires}.`; + } + + showLoginOverlay(message) { + if (this.elements.loginOverlay) + this.elements.loginOverlay.classList.remove('hidden'); + if (typeof message === 'string' && message.length > 0) + this.showLoginError(message); + else + this.hideLoginError(); + + if (this.elements.username) + setTimeout(() => this.elements.username.focus(), 50); + } + + hideLoginOverlay() { + if (this.elements.loginOverlay) + this.elements.loginOverlay.classList.add('hidden'); + this.hideLoginError(); + } + + showLoginError(message) { + if (!this.elements.loginError) + return; + this.elements.loginError.textContent = message; + this.elements.loginError.classList.remove('hidden'); + } + + hideLoginError() { + if (!this.elements.loginError) + return; + this.elements.loginError.textContent = ''; + this.elements.loginError.classList.add('hidden'); + } + + setLoginLoading(loading) { + if (!this.elements.loginButton) + return; + this.elements.loginButton.disabled = loading; + this.elements.loginButton.textContent = loading ? 'Signing in…' : 'Sign in'; + } + + generateRequestId() { + if (window.crypto && window.crypto.randomUUID) + return window.crypto.randomUUID(); + + return `req-${Date.now()}-${Math.random().toString(16).slice(2)}`; } } -window.app = new EvDashApp(); +window.app = new DashboardApp(); diff --git a/dashboard/index.html b/dashboard/index.html index 52bc504..3b818bf 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -9,116 +9,294 @@ --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 { - font-family: "Segoe UI", Roboto, sans-serif; margin: 0; - padding: 0; - background-color: var(--background-color); + font-family: "Segoe UI", Roboto, sans-serif; + background: var(--background-color); color: var(--text-color); + min-height: 100vh; 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; + padding: 2.5rem 1.5rem 2rem; text-align: center; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); } + header h1 { + margin: 0 0 0.5rem; + font-weight: 600; + letter-spacing: 0.02em; + } + + header p { + margin: 0; + } + main { flex: 1; - padding: 2rem 1.5rem; - max-width: 960px; - margin: 0 auto; + width: min(960px, 92vw); + margin: 2rem auto 3rem; + display: grid; + gap: 1.5rem; } .card { - background: #ffffff; - border-radius: 12px; + background: var(--surface-color); + border-radius: 14px; padding: 1.5rem; - box-shadow: 0 4px 12px rgba(31, 45, 61, 0.08); - margin-bottom: 1.5rem; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06); + } + + .card h2 { + margin-top: 0; + font-size: 1.25rem; } .status-indicator { display: inline-flex; align-items: center; - gap: 0.5rem; + gap: 0.75rem; font-weight: 600; } .status-dot { - width: 12px; - height: 12px; + width: 14px; + height: 14px; 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.connected { + background-color: #2ecc71; + } + + .status-dot.authenticating { + background-color: #8e44ad; + } + .status-dot.error { background-color: #e74c3c; } + .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: 8px; + border-radius: 10px; 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; + code { + font-family: "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; } - a { - color: inherit; + footer { + text-align: center; + padding: 1.25rem 1rem; + background: #ffffff; + border-top: 1px solid #d3dce6; + font-size: 0.9rem; + color: var(--muted-text-color); + } + + /* Login overlay */ + .overlay { + position: fixed; + inset: 0; + background: rgba(31, 45, 61, 0.65); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + transition: opacity 0.3s ease; + } + + .overlay.hidden { + visibility: hidden; + opacity: 0; + pointer-events: none; + } + + .login-panel { + background: var(--surface-color); + width: min(420px, 100%); + border-radius: 16px; + padding: 2rem; + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18); + } + + .login-panel h2 { + margin: 0 0 0.5rem; + font-size: 1.5rem; + } + + .login-panel p { + margin: 0; + color: var(--muted-text-color); + } + + form { + margin-top: 1.5rem; + display: grid; + gap: 1rem; + } + + label { + display: grid; + gap: 0.35rem; + font-weight: 600; + } + + input[type="text"], + input[type="password"] { + border-radius: 10px; + border: 1px solid #d3dce6; + padding: 0.75rem 1rem; + font-size: 1rem; + background: #f9fbfd; + color: var(--text-color); + } + + input[type="text"]:focus, + input[type="password"]:focus { + outline: none; + border-color: var(--secondary-color); + box-shadow: 0 0 0 3px rgba(0, 160, 224, 0.2); + } + + button { + border: none; + border-radius: 999px; + padding: 0.75rem 1.5rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + justify-self: start; + transition: transform 0.12s ease, box-shadow 0.12s ease; + } + + button.primary { + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + color: #ffffff; + box-shadow: 0 10px 24px rgba(0, 80, 160, 0.26); + } + + button.primary:disabled { + opacity: 0.65; + cursor: progress; + box-shadow: none; + } + + button.primary:not(:disabled):hover { + transform: translateY(-1px); + box-shadow: 0 16px 32px rgba(0, 80, 160, 0.28); + } + + .error-message { + background: rgba(231, 76, 60, 0.08); + border: 1px solid rgba(231, 76, 60, 0.18); + color: #c0392b; + border-radius: 10px; + padding: 0.75rem 1rem; + } + + .helper-text { + font-size: 0.9rem; + color: var(--muted-text-color); + } + + .hidden { + display: none !important; }
-

Hello from nymea EV Dash

+

nymea EV Dash

- Connecting… + Awaiting login…

-
-

WebSocket Messages

-

Outgoing JSON structure:

-

-            

Last incoming JSON message:

-
No messages received yet.
+
+

Connection

+

Please sign in to start the WebSocket session.

-

Quick Start

-

This page establishes a bidirectional WebSocket connection to the nymea EV Dash backend. Use app.sendExampleMessage() in the browser console to send a sample payload.

+

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 a7e4249..eb8b541 100644 --- a/plugin/evdashengine.cpp +++ b/plugin/evdashengine.cpp @@ -29,16 +29,19 @@ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "evdashengine.h" +#include "evdashwebserverresource.h" #include #include #include #include +#include #include #include #include +#include #include Q_DECLARE_LOGGING_CATEGORY(dcEvDashExperience) @@ -63,11 +66,13 @@ EvDashEngine::EvDashEngine(ThingManager *thingManager, EvDashWebServerResource * connect(socket, &QWebSocket::disconnected, this, [this, socket](){ m_clients.removeAll(socket); + m_authenticatedClients.remove(socket); qCDebug(dcEvDashExperience()) << "WebSocket client disconnected" << socket->peerAddress() << "Remaining clients:" << m_clients.count(); socket->deleteLater(); }); m_clients.append(socket); + m_authenticatedClients.insert(socket, QString()); qCDebug(dcEvDashExperience()) << "WebSocket client connected" << socket->peerAddress() << "Total clients:" << m_clients.count(); }); @@ -91,6 +96,7 @@ EvDashEngine::~EvDashEngine() } m_clients.clear(); + m_authenticatedClients.clear(); } bool EvDashEngine::startWebSocket(quint16 port) @@ -123,58 +129,82 @@ void EvDashEngine::processTextMessage(QWebSocket *socket, const QString &message 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()} - }} - }; - + QJsonObject errorReply = createErrorResponse(QString(), QStringLiteral("invalidPayload")); sendReply(socket, errorReply); return; } - const QJsonObject response = handleApiRequest(doc.object()); + const QJsonObject requestObject = doc.object(); + const QString requestId = requestObject.value(QStringLiteral("requestId")).toString(); + const QString action = requestObject.value(QStringLiteral("action")).toString(); + + if (action.isEmpty()) { + QJsonObject response = createErrorResponse(requestId, QStringLiteral("invalidAction")); + sendReply(socket, response); + return; + } + + const bool isAuthenticateAction = action.compare(QStringLiteral("authenticate"), Qt::CaseInsensitive) == 0; + if (!isAuthenticateAction) { + const QString token = m_authenticatedClients.value(socket); + if (token.isEmpty()) { + QJsonObject response = createErrorResponse(requestId, QStringLiteral("unauthenticated")); + sendReply(socket, response); + socket->close(QWebSocketProtocol::CloseCodePolicyViolated, QStringLiteral("Authentication required")); + m_authenticatedClients.remove(socket); + return; + } + } + + QJsonObject response = handleApiRequest(socket, requestObject); sendReply(socket, response); + + if (isAuthenticateAction && !response.value(QStringLiteral("success")).toBool()) { + socket->close(QWebSocketProtocol::CloseCodePolicyViolated, QStringLiteral("Authentication failed")); + m_authenticatedClients.remove(socket); + } } -QJsonObject EvDashEngine::handleApiRequest(const QJsonObject &request) const +QJsonObject EvDashEngine::handleApiRequest(QWebSocket *socket, const QJsonObject &request) { qCDebug(dcEvDashExperience()) << "Handle API request" << request; - 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("authenticate"), Qt::CaseInsensitive) == 0) { + const QJsonObject payload = request.value(QStringLiteral("payload")).toObject(); + const QString token = payload.value(QStringLiteral("token")).toString(); + + if (token.isEmpty()) + return createErrorResponse(requestId, QStringLiteral("missingToken")); + + if (!m_webServerResource || !m_webServerResource->validateToken(token)) { + m_authenticatedClients.remove(socket); + return createErrorResponse(requestId, QStringLiteral("unauthorized")); + } + + m_authenticatedClients.insert(socket, token); + + QJsonObject responsePayload { + {QStringLiteral("authenticated"), true}, + {QStringLiteral("timestamp"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)} + }; + return createSuccessResponse(requestId, responsePayload); + } + 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 createSuccessResponse(requestId, payload); } - return response; + return createErrorResponse(requestId, QStringLiteral("unknownAction")); } void EvDashEngine::sendReply(QWebSocket *socket, QJsonObject response) const @@ -182,9 +212,28 @@ 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))); } + +QJsonObject EvDashEngine::createSuccessResponse(const QString &requestId, const QJsonObject &payload) const +{ + QJsonObject response; + if (!requestId.isEmpty()) + response.insert(QStringLiteral("requestId"), requestId); + + response.insert(QStringLiteral("success"), true); + response.insert(QStringLiteral("payload"), payload.isEmpty() ? QJsonObject{} : payload); + return response; +} + +QJsonObject EvDashEngine::createErrorResponse(const QString &requestId, const QString &errorMessage) const +{ + QJsonObject response; + if (!requestId.isEmpty()) + response.insert(QStringLiteral("requestId"), requestId); + + response.insert(QStringLiteral("success"), false); + response.insert(QStringLiteral("error"), errorMessage); + return response; +} diff --git a/plugin/evdashengine.h b/plugin/evdashengine.h index bb98c17..16ad106 100644 --- a/plugin/evdashengine.h +++ b/plugin/evdashengine.h @@ -32,6 +32,8 @@ #define EVDASHENGINE_H #include +#include +#include class QWebSocket; class QWebSocketServer; @@ -55,11 +57,14 @@ private: QWebSocketServer *m_webSocketServer = nullptr; QList m_clients; + QHash m_authenticatedClients; bool startWebSocket(quint16 port = 0); void processTextMessage(QWebSocket *socket, const QString &message); - QJsonObject handleApiRequest(const QJsonObject &request) const; + QJsonObject handleApiRequest(QWebSocket *socket, const QJsonObject &request); void sendReply(QWebSocket *socket, QJsonObject response) const; + QJsonObject createSuccessResponse(const QString &requestId, const QJsonObject &payload = {}) const; + QJsonObject createErrorResponse(const QString &requestId, const QString &errorMessage) const; }; #endif // EVDASHENGINE_H diff --git a/plugin/evdashjsonhandler.cpp b/plugin/evdashjsonhandler.cpp index 860926b..ed20768 100644 --- a/plugin/evdashjsonhandler.cpp +++ b/plugin/evdashjsonhandler.cpp @@ -38,128 +38,7 @@ EvDashJsonHandler::EvDashJsonHandler(EvDashEngine *engine, QObject *parent): JsonHandler{parent}, m_engine{engine} { - // registerEnum(); - // registerEnum(); - // registerObject(); - // registerObject(); - - // QVariantMap params, returns; - // QString description; - - // params.clear(); returns.clear(); - // description = "Get the root meter ID. If there is no root meter set, the params will be empty."; - // returns.insert("o:rootMeterThingId", enumValueName(Uuid)); - // registerMethod("GetRootMeter", description, params, returns, Types::PermissionScopeNone); - - // params.clear(); returns.clear(); - // description = "Set the root meter."; - // params.insert("rootMeterThingId", enumValueName(Uuid)); - // returns.insert("energyError", enumRef()); - // registerMethod("SetRootMeter", description, params, returns, Types::PermissionScopeAdmin); - - // params.clear(); returns.clear(); - // description = "Get the current power balance. That is, production, consumption and acquisition."; - // returns.insert("currentPowerConsumption", enumValueName(Double)); - // returns.insert("currentPowerProduction", enumValueName(Double)); - // returns.insert("currentPowerAcquisition", enumValueName(Double)); - // returns.insert("currentPowerStorage", enumValueName(Double)); - // returns.insert("totalConsumption", enumValueName(Double)); - // returns.insert("totalProduction", enumValueName(Double)); - // returns.insert("totalAcquisition", enumValueName(Double)); - // returns.insert("totalReturn", enumValueName(Double)); - // registerMethod("GetPowerBalance", description, params, returns, Types::PermissionScopeNone); - - // params.clear(); returns.clear(); - // description = "Get logs for the power balance. If from is not give, the log will start at the beginning of " - // "recording. If to is not given, the logs will and at the last sample for this sample rate before now."; - // params.insert("sampleRate", enumRef()); - // params.insert("o:from", enumValueName(Uint)); - // params.insert("o:to", enumValueName(Uint)); - // returns.insert("powerBalanceLogEntries", objectRef()); - // registerMethod("GetPowerBalanceLogs", description, params, returns, Types::PermissionScopeNone); - - // params.clear(); returns.clear(); - // description = "Get logs for one or more things power values. If thingIds is not given, logs for all energy related " - // "things will be returned. If from is not given, the log will start at the beginning of recording. If " - // "to is not given, the logs will and at the last sample for this sample rate before now. If the parameter " - // "\"includeCurrent\" is set to true, the result will contain the newest log entries available, regardless " - // "of the sample rate (that is, 1 minute). This may be useful to calculate the difference to the newest " - // "entry of the fetched sample rate and the current values to display the live value until the current sample " - // "is completed."; - // params.insert("sampleRate", enumRef()); - // params.insert("o:thingIds", QVariantList() << enumValueName(Uuid)); - // params.insert("o:from", enumValueName(Uint)); - // params.insert("o:to", enumValueName(Uint)); - // params.insert("o:includeCurrent", enumValueName(Bool)); - // returns.insert("o:currentEntries", objectRef()); - // returns.insert("thingPowerLogEntries", objectRef()); - // registerMethod("GetThingPowerLogs", description, params, returns, Types::PermissionScopeNone); - - // params.clear(); - // description = "Emitted whenever the root meter id changes. If the root meter has been unset, the params will be empty."; - // params.insert("o:rootMeterThingId", enumValueName(Uuid)); - // registerNotification("RootMeterChanged", description, params); - - // params.clear(); - // description = "Emitted whenever the energy balance changes. That is, when the current consumption, production or " - // "acquisition changes. Typically they will all change at the same time."; - // params.insert("currentPowerConsumption", enumValueName(Double)); - // params.insert("currentPowerProduction", enumValueName(Double)); - // params.insert("currentPowerAcquisition", enumValueName(Double)); - // params.insert("currentPowerStorage", enumValueName(Double)); - // params.insert("totalConsumption", enumValueName(Double)); - // params.insert("totalProduction", enumValueName(Double)); - // params.insert("totalAcquisition", enumValueName(Double)); - // params.insert("totalReturn", enumValueName(Double)); - // registerNotification("PowerBalanceChanged", description, params); - - // params.clear(); - // description = "Emitted whenever an entry is added to the power balance log."; - // params.insert("sampleRate", enumRef()); - // params.insert("powerBalanceLogEntry", objectRef()); - // registerNotification("PowerBalanceLogEntryAdded", description, params); - - // params.clear(); - // description = "Emitted whenever an entry is added to the thing power log."; - // params.insert("sampleRate", enumRef()); - // params.insert("thingPowerLogEntry", objectRef()); - // registerNotification("ThingPowerLogEntryAdded", description, params); - - // connect(m_energyManager, &EnergyManager::rootMeterChanged, this, [=](){ - // QVariantMap params; - // if (m_energyManager->rootMeter()) { - // params.insert("rootMeterThingId", m_energyManager->rootMeter()->id()); - // } - // emit RootMeterChanged(params); - // }); - - // connect(m_energyManager, &EnergyManager::powerBalanceChanged, this, [=](){ - // QVariantMap params; - // params.insert("currentPowerConsumption", m_energyManager->currentPowerConsumption()); - // params.insert("currentPowerProduction", m_energyManager->currentPowerProduction()); - // params.insert("currentPowerAcquisition", m_energyManager->currentPowerAcquisition()); - // params.insert("currentPowerStorage", m_energyManager->currentPowerStorage()); - // params.insert("totalConsumption", m_energyManager->totalConsumption()); - // params.insert("totalProduction", m_energyManager->totalProduction()); - // params.insert("totalAcquisition", m_energyManager->totalAcquisition()); - // params.insert("totalReturn", m_energyManager->totalReturn()); - // emit PowerBalanceChanged(params); - // }); - - // connect(m_energyManager->logs(), &EnergyLogs::powerBalanceEntryAdded, this, [=](EnergyLogs::SampleRate sampleRate, const PowerBalanceLogEntry &entry){ - // QVariantMap params; - // params.insert("sampleRate", enumValueName(sampleRate)); - // params.insert("powerBalanceLogEntry", pack(entry)); - // emit PowerBalanceLogEntryAdded(params); - // }); - - // connect(m_energyManager->logs(), &EnergyLogs::thingPowerEntryAdded, this, [=](EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry){ - // QVariantMap params; - // params.insert("sampleRate", enumValueName(sampleRate)); - // params.insert("thingPowerLogEntry", pack(entry)); - // emit ThingPowerLogEntryAdded(params); - // }); } QString EvDashJsonHandler::name() const @@ -167,70 +46,3 @@ QString EvDashJsonHandler::name() const return "EvDash"; } - -// JsonReply *EnergyJsonHandler::GetRootMeter(const QVariantMap ¶ms) -// { -// Q_UNUSED(params) -// QVariantMap ret; -// if (m_energyManager->rootMeter()) { -// ret.insert("rootMeterThingId", m_energyManager->rootMeter()->id()); -// } -// return createReply(ret); -// } - -// JsonReply *EnergyJsonHandler::SetRootMeter(const QVariantMap ¶ms) -// { -// QVariantMap returns; - -// if (!params.contains("rootMeterThingId")) { -// returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorMissingParameter)); -// return createReply(returns); -// } -// EnergyManager::EnergyError status = m_energyManager->setRootMeter(params.value("rootMeterThingId").toUuid()); -// returns.insert("energyError", enumValueName(status)); -// return createReply(returns); -// } - -// JsonReply *EnergyJsonHandler::GetPowerBalance(const QVariantMap ¶ms) -// { -// Q_UNUSED(params) -// QVariantMap ret; -// ret.insert("currentPowerConsumption", m_energyManager->currentPowerConsumption()); -// ret.insert("currentPowerProduction", m_energyManager->currentPowerProduction()); -// ret.insert("currentPowerAcquisition", m_energyManager->currentPowerAcquisition()); -// ret.insert("currentPowerStorage", m_energyManager->currentPowerStorage()); -// ret.insert("totalConsumption", m_energyManager->totalConsumption()); -// ret.insert("totalProduction", m_energyManager->totalProduction()); -// ret.insert("totalAcquisition", m_energyManager->totalAcquisition()); -// ret.insert("totalReturn", m_energyManager->totalReturn()); -// return createReply(ret); -// } - -// JsonReply *EnergyJsonHandler::GetPowerBalanceLogs(const QVariantMap ¶ms) -// { -// EnergyLogs::SampleRate sampleRate = enumNameToValue(params.value("sampleRate").toString()); -// QDateTime from = params.contains("from") ? QDateTime::fromMSecsSinceEpoch(params.value("from").toLongLong() * 1000) : QDateTime(); -// QDateTime to = params.contains("to") ? QDateTime::fromMSecsSinceEpoch(params.value("to").toLongLong() * 1000) : QDateTime(); -// QVariantMap returns; -// returns.insert("powerBalanceLogEntries", pack(m_energyManager->logs()->powerBalanceLogs(sampleRate, from, to))); -// return createReply(returns); -// } - -// JsonReply *EnergyJsonHandler::GetThingPowerLogs(const QVariantMap ¶ms) -// { -// EnergyLogs::SampleRate sampleRate = enumNameToValue(params.value("sampleRate").toString()); -// QList thingIds; -// foreach (const QVariant &thingId, params.value("thingIds").toList()) { -// thingIds.append(thingId.toUuid()); -// } -// QDateTime from = params.contains("from") ? QDateTime::fromMSecsSinceEpoch(params.value("from").toLongLong() * 1000) : QDateTime(); -// QDateTime to = params.contains("to") ? QDateTime::fromMSecsSinceEpoch(params.value("to").toLongLong() * 1000) : QDateTime(); -// QVariantMap returns; -// returns.insert("thingPowerLogEntries", pack(m_energyManager->logs()->thingPowerLogs(sampleRate, thingIds, from, to))); - -// if (params.contains("includeCurrent") && params.value("includeCurrent").toBool()) { -// returns.insert("currentEntries", pack(m_energyManager->logs()->thingPowerLogs(EnergyLogs::SampleRate1Min, thingIds, QDateTime::currentDateTime().addSecs(-60)))); -// } - -// return createReply(returns); -// } diff --git a/plugin/evdashwebserverresource.cpp b/plugin/evdashwebserverresource.cpp index 9e9af46..9676e7f 100644 --- a/plugin/evdashwebserverresource.cpp +++ b/plugin/evdashwebserverresource.cpp @@ -1,6 +1,10 @@ #include "evdashwebserverresource.h" #include +#include +#include +#include +#include #include Q_DECLARE_LOGGING_CATEGORY(dcEvDashExperience) @@ -20,7 +24,12 @@ HttpReply *EvDashWebServerResource::processRequest(const HttpRequest &request) { qCDebug(dcEvDashExperience()) << "Process request" << request.url().toString(); - // Verify methods + const QString path = request.url().path(); + + if (path == basePath() + QStringLiteral("/api/login")) + return handleLoginRequest(request); + + // Verify methods for static content if (request.method() != HttpRequest::Get) { HttpReply *reply = HttpReply::createErrorReply(HttpReply::MethodNotAllowed); reply->setHeader(HttpReply::AllowHeader, "GET"); @@ -28,13 +37,14 @@ HttpReply *EvDashWebServerResource::processRequest(const HttpRequest &request) } // Redirect base url to index - if (request.url().path() == basePath() || request.url().path() == basePath() + "/") { + if (path == basePath() || path == basePath() + QStringLiteral("/")) { 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()); + QString fileName = path; + fileName.remove(basePath()); qCDebug(dcEvDashExperience()) << "Check filename" << fileName; if (verifyStaticFile(fileName)) return WebServerResource::createFileReply(":/dashboard" + fileName); @@ -60,3 +70,92 @@ bool EvDashWebServerResource::verifyStaticFile(const QString &fileName) return false; } +HttpReply *EvDashWebServerResource::handleLoginRequest(const HttpRequest &request) +{ + if (request.method() != HttpRequest::Post) { + HttpReply *reply = HttpReply::createErrorReply(HttpReply::MethodNotAllowed); + reply->setHeader(HttpReply::AllowHeader, "POST"); + return reply; + } + + QJsonParseError parseError; + const QJsonDocument requestDoc = QJsonDocument::fromJson(request.payload(), &parseError); + if (parseError.error != QJsonParseError::NoError || !requestDoc.isObject()) { + qCWarning(dcEvDashExperience()) << "Invalid login payload" << parseError.errorString(); + QJsonObject errorPayload { + {QStringLiteral("success"), false}, + {QStringLiteral("error"), QStringLiteral("invalidRequest")} + }; + return HttpReply::createJsonReply(QJsonDocument(errorPayload), HttpReply::BadRequest); + } + + const QJsonObject requestObject = requestDoc.object(); + const QString username = requestObject.value(QStringLiteral("username")).toString(); + const QString password = requestObject.value(QStringLiteral("password")).toString(); + + if (!verifyCredentials(username, password)) { + QJsonObject response { + {QStringLiteral("success"), false}, + {QStringLiteral("error"), QStringLiteral("unauthorized")} + }; + return HttpReply::createJsonReply(QJsonDocument(response), HttpReply::Unauthorized); + } + + const QString token = issueToken(username); + const TokenInfo tokenInfo = m_activeTokens.value(token); + + QJsonObject payload { + {QStringLiteral("success"), true}, + {QStringLiteral("token"), token}, + {QStringLiteral("expiresAt"), tokenInfo.expiresAt.toString(Qt::ISODateWithMs)} + }; + + return HttpReply::createJsonReply(QJsonDocument(payload)); +} + +bool EvDashWebServerResource::verifyCredentials(const QString &username, const QString &password) const +{ + Q_UNUSED(username) + Q_UNUSED(password) + return true; +} + +QString EvDashWebServerResource::issueToken(const QString &username) +{ + purgeExpiredTokens(); + + const QString token = QUuid::createUuid().toString(QUuid::WithoutBraces); + TokenInfo info; + info.username = username; + info.expiresAt = QDateTime::currentDateTimeUtc().addSecs(s_tokenLifetimeSeconds); + m_activeTokens.insert(token, info); + return token; +} + +bool EvDashWebServerResource::validateToken(const QString &token) +{ + purgeExpiredTokens(); + auto it = m_activeTokens.find(token); + if (it == m_activeTokens.end()) + return false; + + if (it->expiresAt < QDateTime::currentDateTimeUtc()) { + m_activeTokens.erase(it); + return false; + } + + return true; +} + +void EvDashWebServerResource::purgeExpiredTokens() +{ + const QDateTime now = QDateTime::currentDateTimeUtc(); + auto it = m_activeTokens.begin(); + while (it != m_activeTokens.end()) { + if (it->expiresAt < now) + it = m_activeTokens.erase(it); + else + ++it; + } +} + diff --git a/plugin/evdashwebserverresource.h b/plugin/evdashwebserverresource.h index a8ef572..fb3f2a6 100644 --- a/plugin/evdashwebserverresource.h +++ b/plugin/evdashwebserverresource.h @@ -2,9 +2,14 @@ #define EVDASHWEBSERVERRESOURCE_H #include +#include +#include +#include #include +class QJsonObject; + class EvDashWebServerResource : public WebServerResource { Q_OBJECT @@ -15,10 +20,25 @@ public: HttpReply *processRequest(const HttpRequest &request) override; + bool validateToken(const QString &token); + private: + struct TokenInfo { + QString username; + QDateTime expiresAt; + }; + + HttpReply *handleLoginRequest(const HttpRequest &request); HttpReply *redirectToIndex(); bool verifyStaticFile(const QString &fileName); + bool verifyCredentials(const QString &username, const QString &password) const; + QString issueToken(const QString &username); + void purgeExpiredTokens(); + + static constexpr int s_tokenLifetimeSeconds = 3600; + + QHash m_activeTokens; }; #endif // EVDASHWEBSERVERRESOURCE_H