Add basic functionality of fetching sessions

This commit is contained in:
Simon Stürz 2025-12-11 12:43:40 +01:00
parent b36323c2ed
commit a9f51296b3
6 changed files with 276 additions and 21 deletions

View File

@ -16,6 +16,9 @@ class DashboardApp {
incomingMessage: document.getElementById('incomingMessage'),
chargerTableBody: document.getElementById('chargerTableBody'),
chargerEmptyRow: document.getElementById('chargerEmptyRow'),
fetchSessionsButton: document.getElementById('fetchSessionsButton'),
chargerFilter: document.getElementById('chargerFilter'),
chargingSessionsOutput: document.getElementById('chargingSessionsOutput'),
panelButtons: Array.from(document.querySelectorAll('[data-panel-target]')),
contentPanels: Array.from(document.querySelectorAll('[data-panel]'))
};
@ -30,6 +33,7 @@ class DashboardApp {
this.tokenRefreshTimer = null;
this.refreshInFlight = false;
this.chargers = new Map();
this.sessions = [];
this.activePanel = null;
this.chargerColumns = [
{ key: 'id', label: 'ID', hidden: true },
@ -51,6 +55,7 @@ class DashboardApp {
this.initializePanelNavigation();
this.restoreSession();
this.toggleChargerEmptyState();
this.updateChargerSelector();
}
attachEventListeners() {
@ -66,6 +71,12 @@ class DashboardApp {
this.logout();
});
}
if (this.elements.fetchSessionsButton) {
this.elements.fetchSessionsButton.addEventListener('click', () => {
this.fetchChargingSessions();
});
}
}
initializePanelNavigation() {
@ -341,6 +352,7 @@ class DashboardApp {
this.refreshInFlight = false;
this.chargers.clear();
this.resetChargerTable();
this.renderChargingSessions([], 'No charging sessions fetched yet.');
try {
window.localStorage.removeItem(this.sessionKey);
@ -460,6 +472,20 @@ class DashboardApp {
return true;
}
if (type === 'getchargingsessions') {
if (data.success) {
const payload = data && data.payload ? data.payload : {};
const sessions = Array.isArray(payload.sessions) ? payload.sessions : [];
this.renderChargingSessions(sessions, 'No charging sessions found.');
} else if (data.error === 'unauthenticated') {
this.onAuthenticationFailed('unauthenticated');
} else {
console.warn('GetChargingSessions request failed', data.error || 'unknownError');
this.renderChargingSessions([], 'Failed to fetch charging sessions.');
}
return true;
}
return false;
}
@ -480,6 +506,11 @@ class DashboardApp {
return true;
}
if (Array.isArray(payload.sessions)) {
this.renderChargingSessions(payload.sessions);
return true;
}
if (payload.charger) {
this.upsertCharger(payload.charger);
return true;
@ -501,6 +532,10 @@ class DashboardApp {
case 'chargerremoved':
this.removeCharger(payload);
return true;
case 'chargingsessionsupdated':
if (payload && Array.isArray(payload.sessions))
this.renderChargingSessions(payload.sessions);
return true;
default:
return false;
}
@ -581,6 +616,19 @@ class DashboardApp {
return this.sendAction('GetChargers', { });
}
fetchChargingSessions() {
const payload = {};
const chargerId = this.elements.chargerFilter ? this.elements.chargerFilter.value : '';
if (chargerId)
payload.chargerId = chargerId;
const requestId = this.sendAction('GetChargingSessions', payload);
if (!requestId)
this.renderChargingSessions([], 'Unable to request charging sessions. Check the connection status.');
return requestId;
}
processChargerList(chargers = []) {
if (!Array.isArray(chargers)) {
console.warn('Expected chargers array in payload.');
@ -600,6 +648,8 @@ class DashboardApp {
if (!seen.has(existingId))
this.removeCharger(existingId);
}
this.updateChargerSelector();
}
upsertCharger(charger) {
@ -613,6 +663,7 @@ class DashboardApp {
merged.thingId = key;
this.chargers.set(key, merged);
this.syncChargerRow(merged, !hasExisting);
this.updateChargerSelector();
}
syncChargerRow(charger, forceCreate = false) {
@ -687,6 +738,7 @@ class DashboardApp {
row.parentElement.removeChild(row);
this.toggleChargerEmptyState();
this.updateChargerSelector();
}
resetChargerTable() {
@ -700,6 +752,7 @@ class DashboardApp {
});
this.toggleChargerEmptyState();
this.updateChargerSelector();
}
findChargerRow(chargerId) {
@ -736,6 +789,37 @@ class DashboardApp {
this.elements.chargerEmptyRow.classList.toggle('hidden', hasChargers);
}
updateChargerSelector() {
const select = this.elements.chargerFilter;
if (!select)
return;
const currentValue = select.value;
while (select.options.length > 0)
select.remove(0);
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = 'All chargers';
select.appendChild(defaultOption);
const chargers = Array.from(this.chargers.values())
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
chargers.forEach(charger => {
const option = document.createElement('option');
option.value = this.getChargerKey(charger) || '';
option.textContent = charger.name || option.value;
select.appendChild(option);
});
const hasValue = currentValue && select.querySelector
&& typeof CSS !== 'undefined' && CSS.escape
&& select.querySelector(`option[value="${CSS.escape(currentValue)}"]`);
select.value = hasValue ? currentValue : '';
}
formatNumber(value, unit) {
if (!Number.isFinite(value))
return '—';
@ -784,6 +868,25 @@ class DashboardApp {
}
}
renderChargingSessions(sessions, fallbackMessage) {
if (!this.elements.chargingSessionsOutput)
return;
if (!Array.isArray(sessions) || !sessions.length) {
this.sessions = [];
this.elements.chargingSessionsOutput.textContent = fallbackMessage || 'No charging sessions found.';
return;
}
this.sessions = sessions;
try {
this.elements.chargingSessionsOutput.textContent = JSON.stringify(sessions, null, 2);
} catch (error) {
console.warn('Failed to render charging sessions', error);
this.elements.chargingSessionsOutput.textContent = 'Unable to display charging sessions.';
}
}
updateConnectionStatus(text, state) {
if (this.elements.connectionStatus)
this.elements.connectionStatus.textContent = text;

View File

@ -283,6 +283,13 @@
margin-bottom: 1rem;
}
.action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.eyebrow {
margin: 0;
font-size: 0.8rem;
@ -475,6 +482,22 @@
box-shadow: 0 0 0 3px rgba(162, 13, 23, 0.2);
}
select {
border-radius: 10px;
border: 1px solid #d3dce6;
padding: 0.65rem 1rem;
font-size: 1rem;
background: #f9fbfd;
color: var(--text-color);
min-width: 200px;
}
select:focus {
outline: none;
border-color: var(--secondary-color);
box-shadow: 0 0 0 3px rgba(162, 13, 23, 0.2);
}
button {
border: none;
border-radius: 999px;
@ -600,6 +623,10 @@
<span>Chargers</span>
<span class="side-nav-subtitle">Live table & telemetry</span>
</button>
<button type="button" class="side-nav-button" data-panel-target="chargingSessions" aria-pressed="false">
<span>Charging sessions</span>
<span class="side-nav-subtitle">History fetched via nymea</span>
</button>
<button type="button" class="side-nav-button" data-panel-target="help" aria-pressed="false">
<span>Help</span>
<span class="side-nav-subtitle">API contract & logs</span>
@ -647,6 +674,26 @@
</article>
</section>
<section class="content-panel" data-panel="chargingSessions" aria-labelledby="chargingSessionsTitle" aria-hidden="true">
<article class="card">
<div class="card-header">
<div>
<p class="eyebrow">History</p>
<h2 id="chargingSessionsTitle">Charging sessions</h2>
</div>
<div class="action-row">
<label class="sr-only" for="chargerFilter">Charger</label>
<select id="chargerFilter" name="chargerFilter">
<option value="">All chargers</option>
</select>
<button type="button" id="fetchSessionsButton" class="primary">Fetch sessions</button>
</div>
</div>
<p class="helper-text">Select a charger to filter by its assigned car and fetch sessions from nymea.</p>
<pre id="chargingSessionsOutput">No charging sessions fetched yet.</pre>
</article>
</section>
<section class="content-panel" data-panel="help" aria-labelledby="helpTitle" aria-hidden="true">
<article class="card">
<div class="card-header">

View File

@ -26,14 +26,13 @@
#include <QDBusArgument>
#include <QDBusInterface>
#include <QDBusConnectionInterface>
#include <QDBusPendingCall>
#include <QDBusPendingCallWatcher>
#include <QDBusPendingReply>
#include <QDBusReply>
#include <QDBusError>
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcChargingSessions)
#include <QDBusServiceWatcher>
static const QString kDbusService = QStringLiteral("io.nymea.energy.chargingsessions");
static const QString kDbusPath = QStringLiteral("/io/nymea/energy/chargingsessions");
@ -43,8 +42,16 @@ ChargingSessionsDBusInterfaceClient::ChargingSessionsDBusInterfaceClient(QObject
QObject(parent),
m_connection(QDBusConnection::systemBus())
{
if (!m_connection.isConnected()) {
qCWarning(dcChargingSessions()) << "DBus system bus not connected";
m_serviceWatcher = new QDBusServiceWatcher(kDbusService,
m_connection,
QDBusServiceWatcher::WatchForRegistration | QDBusServiceWatcher::WatchForUnregistration,
this);
connect(m_serviceWatcher, &QDBusServiceWatcher::serviceRegistered, this, &ChargingSessionsDBusInterfaceClient::onServiceRegistered);
connect(m_serviceWatcher, &QDBusServiceWatcher::serviceUnregistered, this, &ChargingSessionsDBusInterfaceClient::onServiceUnregistered);
QDBusConnectionInterface *bus = m_connection.interface();
if (bus && bus->isServiceRegistered(kDbusService)) {
onServiceRegistered(kDbusService);
}
}
@ -76,7 +83,6 @@ void ChargingSessionsDBusInterfaceClient::onCallFinished(QDBusPendingCallWatcher
watcher->deleteLater();
if (reply.isError()) {
qCWarning(dcChargingSessions()) << "GetSessions DBus call failed:" << reply.error().message();
emit errorOccurred(reply.error().message());
return;
}
@ -107,13 +113,11 @@ bool ChargingSessionsDBusInterfaceClient::ensureInterface()
m_interface = nullptr;
if (!m_connection.isConnected()) {
qCWarning(dcChargingSessions()) << "DBus system bus not connected";
return false;
}
m_interface = new QDBusInterface(kDbusService, kDbusPath, kDbusInterface, m_connection, this);
if (!m_interface->isValid()) {
qCWarning(dcChargingSessions()) << "Charging sessions DBus interface is not available:" << m_connection.lastError().message();
delete m_interface;
m_interface = nullptr;
return false;
@ -121,3 +125,18 @@ bool ChargingSessionsDBusInterfaceClient::ensureInterface()
return true;
}
void ChargingSessionsDBusInterfaceClient::onServiceRegistered(const QString &service)
{
Q_UNUSED(service)
ensureInterface();
}
void ChargingSessionsDBusInterfaceClient::onServiceUnregistered(const QString &service)
{
Q_UNUSED(service)
if (m_interface) {
m_interface->deleteLater();
}
m_interface = nullptr;
}

View File

@ -29,9 +29,11 @@
#include <QList>
#include <QVariantMap>
#include <QStringList>
#include <QDBusServiceWatcher>
class QDBusInterface;
class QDBusPendingCallWatcher;
class QDBusServiceWatcher;
class ChargingSessionsDBusInterfaceClient : public QObject
{
@ -51,11 +53,14 @@ signals:
private slots:
void onCallFinished(QDBusPendingCallWatcher *watcher);
void onServiceRegistered(const QString &service);
void onServiceUnregistered(const QString &service);
private:
bool ensureInterface();
QDBusConnection m_connection;
QDBusInterface *m_interface = nullptr;
QDBusServiceWatcher *m_serviceWatcher = nullptr;
QList<QVariantMap> m_sessions;
};

View File

@ -46,6 +46,7 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QUuid>
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcEvDashExperience)
@ -105,16 +106,11 @@ EvDashEngine::EvDashEngine(ThingManager *thingManager, EvDashWebServerResource *
// ChargingSessions client for fetching charging sessions
m_chargingSessionsClient = new ChargingSessionsDBusInterfaceClient(this);
connect(m_chargingSessionsClient, &ChargingSessionsDBusInterfaceClient::sessionsReceived, this, [](const QList<QVariantMap> &chargingSessions){
qCDebug(dcEvDashExperience()) << "ChargingSessions :";
foreach (const QVariant &ciVariant, chargingSessions) {
qCDebug(dcEvDashExperience()) << "-->" << ciVariant.toMap();
}
});
connect(m_chargingSessionsClient, &ChargingSessionsDBusInterfaceClient::sessionsReceived,
this, &EvDashEngine::onSessionsReceived);
connect(m_chargingSessionsClient, &ChargingSessionsDBusInterfaceClient::errorOccurred, this, [](const QString &errorMessage){
qCWarning(dcEvDashExperience()) << "Charging sessions DBus client error occurred:" << errorMessage;
});
connect(m_chargingSessionsClient, &ChargingSessionsDBusInterfaceClient::errorOccurred,
this, &EvDashEngine::onSessionsError);
// Energy manager client for associated cars and current mode
@ -307,11 +303,13 @@ void EvDashEngine::processTextMessage(QWebSocket *socket, const QString &message
}
QJsonObject response = handleApiRequest(socket, requestObject);
sendReply(socket, response);
if (!response.isEmpty()) {
sendReply(socket, response);
if (isAuthenticateAction && !response.value(QStringLiteral("success")).toBool()) {
socket->close(QWebSocketProtocol::CloseCodePolicyViolated, QStringLiteral("Authentication failed"));
m_authenticatedClients.remove(socket);
if (isAuthenticateAction && !response.value(QStringLiteral("success")).toBool()) {
socket->close(QWebSocketProtocol::CloseCodePolicyViolated, QStringLiteral("Authentication failed"));
m_authenticatedClients.remove(socket);
}
}
}
@ -366,6 +364,19 @@ QJsonObject EvDashEngine::handleApiRequest(QWebSocket *socket, const QJsonObject
return createSuccessResponse(requestId, payload);
}
if (action.compare(QStringLiteral("GetChargingSessions"), Qt::CaseInsensitive) == 0) {
if (!m_chargingSessionsClient)
return createErrorResponse(requestId, QStringLiteral("chargingSessionsUnavailable"));
const QJsonObject payload = request.value(QStringLiteral("payload")).toObject();
const QString chargerId = payload.value(QStringLiteral("chargerId")).toString();
const QStringList carThingIds = carThingIdsForCharger(chargerId);
m_pendingChargingSessionsRequests.insert(requestId, QPointer<QWebSocket>(socket));
m_chargingSessionsClient->getSessions(carThingIds);
return {};
}
return createErrorResponse(requestId, QStringLiteral("unknownAction"));
}
@ -384,6 +395,9 @@ void EvDashEngine::sendNotification(const QString &notification, QJsonObject pay
// Send to all active clients
for (QWebSocket *client : qAsConst(m_clients)) {
if (m_authenticatedClients.value(client).isEmpty())
continue;
QJsonObject notificationObject;
notificationObject.insert(QStringLiteral("requestId"), QUuid::createUuid().toString(QUuid::WithoutBraces));
notificationObject.insert("event", notification);
@ -465,3 +479,62 @@ QJsonObject EvDashEngine::packCharger(Thing *charger) const
return chargerObject;
}
QStringList EvDashEngine::carThingIdsForCharger(const QString &chargerId) const
{
QStringList carThingIds;
if (!m_energyManagerClient || chargerId.isEmpty())
return carThingIds;
const QUuid chargerUuid = QUuid::fromString(chargerId);
if (chargerUuid.isNull())
return carThingIds;
for (const QVariant &ciVariant : m_energyManagerClient->chargingInfos()) {
const QVariantMap chargingInfo = ciVariant.toMap();
if (chargingInfo.value(QStringLiteral("evChargerId")).toUuid() != chargerUuid)
continue;
const QString assignedCarId = chargingInfo.value(QStringLiteral("assignedCarId")).toString();
if (!assignedCarId.isEmpty())
carThingIds.append(assignedCarId);
break;
}
return carThingIds;
}
void EvDashEngine::onSessionsReceived(const QList<QVariantMap> &sessions)
{
qCDebug(dcEvDashExperience()) << "ChargingSessions received:" << sessions.count();
QJsonArray sessionArray;
for (const QVariantMap &session : sessions)
sessionArray.append(QJsonObject::fromVariantMap(session));
QJsonObject payload;
payload.insert(QStringLiteral("sessions"), sessionArray);
const QList<QString> pendingRequestIds = m_pendingChargingSessionsRequests.keys();
for (const QString &requestId : pendingRequestIds) {
QPointer<QWebSocket> socket = m_pendingChargingSessionsRequests.take(requestId);
if (!socket)
continue;
sendReply(socket, createSuccessResponse(requestId, payload));
}
sendNotification(QStringLiteral("chargingSessionsUpdated"), payload);
}
void EvDashEngine::onSessionsError(const QString &errorMessage)
{
qCWarning(dcEvDashExperience()) << "Charging sessions DBus client error occurred:" << errorMessage;
const QList<QString> pendingRequestIds = m_pendingChargingSessionsRequests.keys();
for (const QString &requestId : pendingRequestIds) {
QPointer<QWebSocket> socket = m_pendingChargingSessionsRequests.take(requestId);
if (!socket)
continue;
sendReply(socket, createErrorResponse(requestId, errorMessage));
}
}

View File

@ -34,6 +34,8 @@
#include <QObject>
#include <QHash>
#include <QJsonObject>
#include <QPointer>
#include <QStringList>
#include <integrations/thing.h>
@ -91,6 +93,10 @@ private:
QList<Thing *> m_chargers;
void monitorChargerThing(Thing *thing);
// Pending requests waiting for charging sessions data to return
QHash<QString, QPointer<QWebSocket>> m_pendingChargingSessionsRequests;
QStringList carThingIdsForCharger(const QString &chargerId) const;
// Websocket server
bool startWebSocketServer(quint16 port = 0);
void stopWebSocketServer();
@ -105,6 +111,8 @@ private:
QJsonObject createErrorResponse(const QString &requestId, const QString &errorMessage) const;
QJsonObject packCharger(Thing *charger) const;
void onSessionsReceived(const QList<QVariantMap> &sessions);
void onSessionsError(const QString &errorMessage);
};