Add login and basic api workflow
This commit is contained in:
parent
0920b7de49
commit
f251743793
54
AGENTS.md
54
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!
|
||||
|
||||
505
dashboard/app.js
505
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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Hello from nymea EV Dash</h1>
|
||||
<h1>nymea EV Dash</h1>
|
||||
<p class="status-indicator">
|
||||
<span id="statusDot" class="status-dot connecting" aria-hidden="true"></span>
|
||||
<span id="connectionStatus">Connecting…</span>
|
||||
<span id="connectionStatus">Awaiting login…</span>
|
||||
</p>
|
||||
</header>
|
||||
<main>
|
||||
<section class="card">
|
||||
<h2>WebSocket Messages</h2>
|
||||
<p>Outgoing JSON structure:</p>
|
||||
<pre id="outgoingStructure"></pre>
|
||||
<p>Last incoming JSON message:</p>
|
||||
<pre id="incomingMessage">No messages received yet.</pre>
|
||||
<section class="card" aria-live="polite">
|
||||
<h2>Connection</h2>
|
||||
<p id="sessionSummary" class="helper-text">Please sign in to start the WebSocket session.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Quick Start</h2>
|
||||
<p>This page establishes a bidirectional WebSocket connection to the nymea EV Dash backend. Use <code>app.sendExampleMessage()</code> in the browser console to send a sample payload.</p>
|
||||
<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>
|
||||
|
||||
<div id="loginOverlay" class="overlay" role="dialog" aria-modal="true" aria-labelledby="loginTitle">
|
||||
<div class="login-panel">
|
||||
<h2 id="loginTitle">Sign in</h2>
|
||||
<p>Authenticate with your nymea credentials to obtain a session token for the dashboard.</p>
|
||||
<div id="loginError" class="error-message hidden" role="alert"></div>
|
||||
<form id="loginForm" autocomplete="on">
|
||||
<label for="username">Username
|
||||
<input type="text" id="username" name="username" autocomplete="username" required placeholder="nymea user">
|
||||
</label>
|
||||
<label for="password">Password
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required placeholder="••••••••">
|
||||
</label>
|
||||
<p class="helper-text">Tokens remain valid for one hour. Reloading the dashboard reuses an active session automatically.</p>
|
||||
<button type="submit" id="loginButton" class="primary">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<span id="appVersion">Version 0.1.0</span> · © 2013–2025 nymea GmbH. All rights reserved.
|
||||
<span id="appVersion">nymea EV Dash</span> · © 2013–2025 nymea GmbH. All rights reserved.
|
||||
</footer>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
@ -29,16 +29,19 @@
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#include "evdashengine.h"
|
||||
#include "evdashwebserverresource.h"
|
||||
|
||||
#include <integrations/thingmanager.h>
|
||||
|
||||
#include <QWebSocket>
|
||||
#include <QWebSocketServer>
|
||||
#include <QHostAddress>
|
||||
#include <QWebSocketProtocol>
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include <QLoggingCategory>
|
||||
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;
|
||||
}
|
||||
|
||||
@ -32,6 +32,8 @@
|
||||
#define EVDASHENGINE_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QHash>
|
||||
#include <QJsonObject>
|
||||
|
||||
class QWebSocket;
|
||||
class QWebSocketServer;
|
||||
@ -55,11 +57,14 @@ private:
|
||||
QWebSocketServer *m_webSocketServer = nullptr;
|
||||
|
||||
QList<QWebSocket *> m_clients;
|
||||
QHash<QWebSocket *, QString> 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
|
||||
|
||||
@ -38,128 +38,7 @@ EvDashJsonHandler::EvDashJsonHandler(EvDashEngine *engine, QObject *parent):
|
||||
JsonHandler{parent},
|
||||
m_engine{engine}
|
||||
{
|
||||
// registerEnum<EnergyManager::EnergyError>();
|
||||
// registerEnum<EnergyLogs::SampleRate>();
|
||||
|
||||
// registerObject<PowerBalanceLogEntry, PowerBalanceLogEntries>();
|
||||
// registerObject<ThingPowerLogEntry, ThingPowerLogEntries>();
|
||||
|
||||
// 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<EnergyManager::EnergyError>());
|
||||
// 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<EnergyLogs::SampleRate>());
|
||||
// params.insert("o:from", enumValueName(Uint));
|
||||
// params.insert("o:to", enumValueName(Uint));
|
||||
// returns.insert("powerBalanceLogEntries", objectRef<PowerBalanceLogEntries>());
|
||||
// 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<EnergyLogs::SampleRate>());
|
||||
// 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<ThingPowerLogEntries>());
|
||||
// returns.insert("thingPowerLogEntries", objectRef<ThingPowerLogEntries>());
|
||||
// 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<EnergyLogs::SampleRate>());
|
||||
// params.insert("powerBalanceLogEntry", objectRef<PowerBalanceLogEntry>());
|
||||
// registerNotification("PowerBalanceLogEntryAdded", description, params);
|
||||
|
||||
// params.clear();
|
||||
// description = "Emitted whenever an entry is added to the thing power log.";
|
||||
// params.insert("sampleRate", enumRef<EnergyLogs::SampleRate>());
|
||||
// params.insert("thingPowerLogEntry", objectRef<ThingPowerLogEntry>());
|
||||
// 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<EnergyLogs::SampleRate>(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<EnergyLogs::SampleRate>(params.value("sampleRate").toString());
|
||||
// QList<ThingId> 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);
|
||||
// }
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
#include "evdashwebserverresource.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QUuid>
|
||||
|
||||
#include <QLoggingCategory>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,14 @@
|
||||
#define EVDASHWEBSERVERRESOURCE_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QDateTime>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
|
||||
#include <webserver/webserverresource.h>
|
||||
|
||||
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<QString, TokenInfo> m_activeTokens;
|
||||
};
|
||||
|
||||
#endif // EVDASHWEBSERVERRESOURCE_H
|
||||
|
||||
Reference in New Issue
Block a user