// SPDX-License-Identifier: GPL-3.0-or-later /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright (C) 2013 - 2024, nymea GmbH * Copyright (C) 2024 - 2025, chargebyte austria GmbH * * This file is part of nymea-experience-plugin-evdash. * * nymea-experience-plugin-evdash is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * nymea-experience-plugin-evdash is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with nymea-experience-plugin-evdash. If not, see . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "evdashengine.h" #include "chargingsessionsdbusinterfaceclient.h" #include "energymanagerdbusclient.h" #include "evdashsettings.h" #include "evdashwebserverresource.h" #include #include #include #include #include #include #include #include #include #include #include #include #include Q_DECLARE_LOGGING_CATEGORY(dcEvDashExperience) EvDashEngine::EvDashEngine(ThingManager *thingManager, LogEngine *logEngine, EvDashWebServerResource *webServerResource, QObject *parent) : QObject{parent} , m_thingManager{thingManager} , m_logEngine{logEngine} , m_webServerResource{webServerResource} { Things configuredThings = m_thingManager->configuredThings(); foreach (Thing *thing, configuredThings) { if (isChargerThing(thing)) { m_chargers.append(thing); monitorChargerThing(thing); } if (isCarThing(thing)) { m_cars.append(thing); monitorCarThing(thing); } } connect(m_thingManager, &ThingManager::thingAdded, this, &EvDashEngine::onThingAdded); connect(m_thingManager, &ThingManager::thingRemoved, this, &EvDashEngine::onThingRemoved); connect(m_thingManager, &ThingManager::thingChanged, this, &EvDashEngine::onThingChanged); // Setup websocket server m_webSocketServer = new QWebSocketServer(QStringLiteral("EvDashEngine"), QWebSocketServer::NonSecureMode, this); connect(m_webSocketServer, &QWebSocketServer::newConnection, this, [this]() { QWebSocket *socket = m_webSocketServer->nextPendingConnection(); if (!socket) { qCWarning(dcEvDashExperience()) << "Interface: Received new connection but socket was null"; return; } connect(socket, &QWebSocket::textMessageReceived, this, [this, socket](const QString &message) { processTextMessage(socket, message); }); connect(socket, &QWebSocket::disconnected, this, [this, socket]() { m_clients.removeAll(socket); m_authenticatedClients.remove(socket); qCDebug(dcEvDashExperience()) << "WebSocket client disconnected" << socket->peerAddress().toString() << "Remaining clients:" << m_clients.count(); socket->deleteLater(); }); m_clients.append(socket); m_authenticatedClients.insert(socket, QString()); qCDebug(dcEvDashExperience()) << "WebSocket client connected" << socket->peerAddress().toString() << "Total clients:" << m_clients.count(); }); connect(m_webSocketServer, &QWebSocketServer::acceptError, this, [this](QAbstractSocket::SocketError error) { qCWarning(dcEvDashExperience()) << "WebSocket accept error" << error << m_webSocketServer->errorString(); }); EvDashSettings settings; settings.beginGroup("General"); m_webSocketPort = settings.value("webSocketServerPort", 4449).toUInt(); bool enabled = settings.value("enabled", false).toBool(); settings.endGroup(); // ChargingSessions client for fetching charging sessions m_chargingSessionsClient = new ChargingSessionsDBusInterfaceClient(this); connect(m_chargingSessionsClient, &ChargingSessionsDBusInterfaceClient::sessionsReceived, this, &EvDashEngine::onSessionsReceived); connect(m_chargingSessionsClient, &ChargingSessionsDBusInterfaceClient::errorOccurred, this, &EvDashEngine::onSessionsError); // Energy manager client for associated cars and current mode m_energyManagerClient = new EnergyManagerDbusClient(this); connect(m_energyManagerClient, &EnergyManagerDbusClient::chargingInfosUpdated, this, [](const QVariantList &chargingInfos) { qCDebug(dcEvDashExperience()) << "ChargingInfos:"; foreach (const QVariant &ciVariant, chargingInfos) { qCDebug(dcEvDashExperience()) << "-->" << ciVariant.toMap(); } }); connect(m_energyManagerClient, &EnergyManagerDbusClient::chargingInfoAdded, this, [this](const QVariantMap &chargingInfo) { qCDebug(dcEvDashExperience()) << "ChargingInfo added:" << chargingInfo; Thing *charger = m_thingManager->findConfiguredThing(chargingInfo.value("evChargerId").toUuid()); if (charger) { onThingChanged(charger); } }); connect(m_energyManagerClient, &EnergyManagerDbusClient::chargingInfoChanged, this, [this](const QVariantMap &chargingInfo) { qCDebug(dcEvDashExperience()) << "ChargingInfo changed:" << chargingInfo; Thing *charger = m_thingManager->findConfiguredThing(chargingInfo.value("evChargerId").toUuid()); if (charger) { onThingChanged(charger); } }); connect(m_energyManagerClient, &EnergyManagerDbusClient::chargingInfoRemoved, this, [](const QString &evChargerId) { qCDebug(dcEvDashExperience()) << "ChargingInfo removed:" << evChargerId; }); connect(m_energyManagerClient, &EnergyManagerDbusClient::errorOccurred, this, [](const QString &errorMessage) { qCWarning(dcEvDashExperience()) << "Energy manager DBus client error occurred:" << errorMessage; }); qCDebug(dcEvDashExperience()) << "ChargingInfos:" << m_energyManagerClient->chargingInfos(); foreach (const QVariant &ciVariant, m_energyManagerClient->chargingInfos()) { qCDebug(dcEvDashExperience()) << "-->" << ciVariant.toMap(); } // Start the service if enabled setEnabled(enabled); } EvDashEngine::~EvDashEngine() { stopWebSocketServer(); } bool EvDashEngine::enabled() const { return m_enabled; } bool EvDashEngine::setEnabled(bool enabled) { m_enabled = enabled; if (m_enabled) { if (!startWebSocketServer(m_webSocketPort)) { return false; } } else { stopWebSocketServer(); } qCDebug(dcEvDashExperience()) << "The EV Dash service is now" << (enabled ? "enabled" : "disabled"); m_webServerResource->setEnabled(m_enabled); EvDashSettings settings; settings.beginGroup("General"); settings.setValue("enabled", enabled); settings.endGroup(); emit enabledChanged(m_enabled); return true; } void EvDashEngine::onThingAdded(Thing *thing) { if (isChargerThing(thing)) { m_chargers.append(thing); monitorChargerThing(thing); sendNotification("ChargerAdded", packCharger(thing)); verifyChargerStatusChanged(thing); } if (isCarThing(thing)) { m_cars.append(thing); monitorCarThing(thing); sendNotification("CarAdded", packCar(thing)); } } void EvDashEngine::onThingRemoved(const ThingId &thingId) { foreach (Thing *thing, m_chargers) { if (thing->id() == thingId) { qCDebug(dcEvDashExperience()) << "Charger has been removed."; sendNotification("ChargerRemoved", packCharger(thing)); m_chargers.removeAll(thing); m_chargersStatusChangedCache.remove(thing); break; } } foreach (Thing *thing, m_cars) { if (thing->id() == thingId) { qCDebug(dcEvDashExperience()) << "Car has been removed."; m_cars.removeAll(thing); sendNotification("CarRemoved", packCar(thing)); break; } } } void EvDashEngine::onThingChanged(Thing *thing) { if (isChargerThing(thing)) { sendNotification("ChargerChanged", packCharger(thing)); verifyChargerStatusChanged(thing); } if (isCarThing(thing)) sendNotification("CarChanged", packCar(thing)); } void EvDashEngine::monitorChargerThing(Thing *thing) { connect(thing, &Thing::stateValueChanged, this, [this, thing](const StateTypeId &stateTypeId, const QVariant &value, const QVariant &minValue, const QVariant &maxValue, const QVariantList &possibleValues) { Q_UNUSED(stateTypeId) Q_UNUSED(value) Q_UNUSED(minValue) Q_UNUSED(maxValue) Q_UNUSED(possibleValues) onThingChanged(thing); }); } void EvDashEngine::monitorCarThing(Thing *thing) { connect(thing, &Thing::stateValueChanged, this, [this, thing](const StateTypeId &stateTypeId, const QVariant &value, const QVariant &minValue, const QVariant &maxValue, const QVariantList &possibleValues) { Q_UNUSED(stateTypeId) Q_UNUSED(value) Q_UNUSED(minValue) Q_UNUSED(maxValue) Q_UNUSED(possibleValues) onThingChanged(thing); }); } void EvDashEngine::verifyChargerStatusChanged(Thing *charger) { QString stateName = "status"; QString source = QString("state-%1-%2").arg(charger->id().toString(QUuid::WithBraces), stateName); LogFetchJob *job = m_logEngine->fetchLogEntries({source}, {stateName}, {}, {}, {}, Types::SampleRateAny, Qt::DescendingOrder, 0, 1); connect(job, &LogFetchJob::finished, charger, [this, charger, stateName](const LogEntries &entries) { if (entries.isEmpty()) { qCDebug(dcEvDashExperience()) << "Last state change of" << charger->name() << stateName << "unknown"; // Forget any cached values, the database did not return any information... m_chargersStatusChangedCache.remove(charger); return; } qint64 lastChangeTimestamp = entries.first().timestamp().toSecsSinceEpoch(); qCDebug(dcEvDashExperience()) << "Last state change" << charger->name() << stateName << entries.first().timestamp().toString() << entries.first().values().first(); if (!m_chargersStatusChangedCache.contains(charger)) { m_chargersStatusChangedCache.insert(charger, lastChangeTimestamp); sendNotification("ChargerChanged", packCharger(charger)); return; } if (m_chargersStatusChangedCache.value(charger) != lastChangeTimestamp) { m_chargersStatusChangedCache[charger] = lastChangeTimestamp; sendNotification("ChargerChanged", packCharger(charger)); return; } }); } bool EvDashEngine::startWebSocketServer(quint16 port) { if (m_webSocketServer->isListening()) { if (m_webSocketServer->serverPort() == port && port != 0) return true; m_webSocketServer->close(); } const bool listening = m_webSocketServer->listen(QHostAddress::AnyIPv4, port); if (listening) { qCDebug(dcEvDashExperience()) << "WebSocket server listening on" << m_webSocketServer->serverAddress() << m_webSocketServer->serverPort(); } else { qCWarning(dcEvDashExperience()) << "Failed to start WebSocket server" << m_webSocketServer->errorString(); } emit webSocketListeningChanged(listening); return listening; } void EvDashEngine::stopWebSocketServer() { if (m_webSocketServer->isListening()) m_webSocketServer->close(); for (QWebSocket *client : qAsConst(m_clients)) { if (client->state() == QAbstractSocket::ConnectedState) client->close(QWebSocketProtocol::CloseCodeGoingAway, QStringLiteral("Server shutting down")); client->deleteLater(); } m_clients.clear(); m_authenticatedClients.clear(); } void EvDashEngine::processTextMessage(QWebSocket *socket, const QString &message) { if (!socket) return; QJsonParseError parseError; const QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8(), &parseError); if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { qCWarning(dcEvDashExperience()) << "Invalid WebSocket payload" << parseError.errorString(); QJsonObject errorReply = createErrorResponse(QString(), QStringLiteral("invalidPayload")); sendReply(socket, errorReply); return; } qCDebug(dcEvDashExperience()) << "-->" << qUtf8Printable(doc.toJson(QJsonDocument::Compact)); 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); if (!response.isEmpty()) { sendReply(socket, response); if (isAuthenticateAction && !response.value(QStringLiteral("success")).toBool()) { socket->close(QWebSocketProtocol::CloseCodePolicyViolated, QStringLiteral("Authentication failed")); m_authenticatedClients.remove(socket); } } } QJsonObject EvDashEngine::handleApiRequest(QWebSocket *socket, const QJsonObject &request) { qCDebug(dcEvDashExperience()) << "Handle API request" << request; const QString requestId = request.value(QStringLiteral("requestId")).toString(); 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) { QJsonObject payload; 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); return createSuccessResponse(requestId, payload); } if (action.compare(QStringLiteral("GetChargers"), Qt::CaseInsensitive) == 0) { QJsonObject payload; QJsonArray chargerList; foreach (Thing *thing, m_thingManager->configuredThings()) { if (isChargerThing(thing)) chargerList.append(packCharger(thing)); } payload.insert(QStringLiteral("chargers"), chargerList); return createSuccessResponse(requestId, payload); } if (action.compare(QStringLiteral("GetCars"), Qt::CaseInsensitive) == 0) { QJsonObject payload; QJsonArray carList; foreach (Thing *thing, m_thingManager->configuredThings()) { if (isCarThing(thing)) carList.append(packCar(thing)); } payload.insert(QStringLiteral("cars"), carList); 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(); QStringList carThingIds; const QString carId = payload.value(QStringLiteral("carId")).toString(); if (!carId.isEmpty()) { const QUuid carUuid = QUuid::fromString(carId); if (carUuid.isNull()) return createErrorResponse(requestId, QStringLiteral("invalidCarId")); carThingIds.append(carUuid.toString(QUuid::WithoutBraces)); } else { const QString chargerId = payload.value(QStringLiteral("chargerId")).toString(); if (!chargerId.isEmpty()) carThingIds = carThingIdsForCharger(chargerId); } m_pendingChargingSessionsRequests.insert(requestId, QPointer(socket)); m_chargingSessionsClient->getSessions(carThingIds); return {}; } return createErrorResponse(requestId, QStringLiteral("unknownAction")); } void EvDashEngine::sendReply(QWebSocket *socket, QJsonObject response) const { if (!socket) return; const QJsonDocument replyDoc(response); qCDebug(dcEvDashExperience()) << "<--" << qUtf8Printable(replyDoc.toJson(QJsonDocument::Compact)); socket->sendTextMessage(QString::fromUtf8(replyDoc.toJson(QJsonDocument::Compact))); } void EvDashEngine::sendNotification(const QString ¬ification, QJsonObject payload) const { // 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); notificationObject.insert("payload", payload); const QJsonDocument notificationDoc(notificationObject); qCDebug(dcEvDashExperience()) << "<--" << qUtf8Printable(notificationDoc.toJson(QJsonDocument::Compact)); client->sendTextMessage(QString::fromUtf8(notificationDoc.toJson(QJsonDocument::Compact))); } } QJsonObject EvDashEngine::createSuccessResponse(const QString &requestId, const QJsonObject &payload) const { QJsonObject response; 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; } QJsonObject EvDashEngine::packCharger(Thing *charger) const { QJsonObject chargerObject; chargerObject.insert("id", charger->id().toString(QUuid::WithoutBraces)); chargerObject.insert("name", charger->name()); foreach (const QVariant &chargingInfoVariant, m_energyManagerClient->chargingInfos()) { QVariantMap chargingInfo = chargingInfoVariant.toMap(); if (chargingInfo.value("evChargerId").toUuid() == charger->id()) { // Set assigned car name if (chargingInfo.value("assignedCarId").toString().isEmpty()) { chargerObject.insert("assignedCar", ""); } else { Thing *car = m_thingManager->findConfiguredThing(chargingInfo.value("assignedCarId").toUuid()); if (car) { chargerObject.insert("assignedCar", car->name()); } else { chargerObject.insert("assignedCar", ""); } } // Set energyManagerMode chargerObject.insert("energyManagerMode", chargingInfo.value("chargingMode").toInt()); } } chargerObject.insert("connected", charger->stateValue("connected").toBool()); chargerObject.insert("chargingCurrent", charger->stateValue("maxChargingCurrent").toDouble()); chargerObject.insert("currentPower", charger->stateValue("currentPower").toDouble()); chargerObject.insert("pluggedIn", charger->stateValue("pluggedIn").toBool()); chargerObject.insert("chargingAllowed", charger->stateValue("power").toBool()); if (charger->hasState("currentVersion")) chargerObject.insert("version", charger->stateValue("currentVersion").toDouble()); if (charger->hasState("sessionEnergy")) chargerObject.insert("sessionEnergy", charger->stateValue("sessionEnergy").toDouble()); if (charger->hasState("desiredPhaseCount")) chargerObject.insert("chargingPhases", charger->stateValue("desiredPhaseCount").toInt()); // PCE specific if (charger->hasState("temperature")) chargerObject.insert("temperature", charger->stateValue("temperature").toDouble()); if (charger->hasState("error")) chargerObject.insert("error", charger->stateValue("error").toString()); if (charger->hasState("status")) chargerObject.insert("status", charger->stateValue("status").toString()); if (m_chargersStatusChangedCache.contains(charger)) chargerObject.insert("lastStatusUpdate", m_chargersStatusChangedCache.value(charger)); if (charger->hasState("digitalInputMode")) chargerObject.insert("digitalInputMode", charger->stateValue("digitalInputMode").toInt()); return chargerObject; } QJsonObject EvDashEngine::packCar(Thing *car) const { QJsonObject carObject; if (!car) return carObject; carObject.insert("id", car->id().toString(QUuid::WithoutBraces)); carObject.insert("name", car->name()); return carObject; } bool EvDashEngine::isChargerThing(Thing *thing) const { if (!thing) return false; const QStringList interfaces = thing->thingClass().interfaces(); return interfaces.contains(QStringLiteral("evcharger")); } bool EvDashEngine::isCarThing(Thing *thing) const { if (!thing) return false; const QStringList interfaces = thing->thingClass().interfaces(); return interfaces.contains(QStringLiteral("electricvehicle")); } 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 &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 pendingRequestIds = m_pendingChargingSessionsRequests.keys(); for (const QString &requestId : pendingRequestIds) { QPointer 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 pendingRequestIds = m_pendingChargingSessionsRequests.keys(); for (const QString &requestId : pendingRequestIds) { QPointer socket = m_pendingChargingSessionsRequests.take(requestId); if (!socket) continue; sendReply(socket, createErrorResponse(requestId, errorMessage)); } }