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.
+
+
+
+
Sign in
+
Authenticate with your nymea credentials to obtain a session token for the dashboard.