From 073ff88f32ccd52324059128be62fe73080f724a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Tue, 13 Oct 2026 16:38:21 +0200 Subject: [PATCH] EVerest: Add RFID thing class and functionality for the nymea-rfid-provider --- everest/README.md | 54 ++++ everest/everest.pro | 5 +- everest/integrationplugineverest.cpp | 214 +++++++++++----- everest/integrationplugineverest.h | 4 + everest/integrationplugineverest.json | 308 ++++++++++++++++++++++- everest/jsonrpc/everestevse.cpp | 56 ++--- everest/rfid/everestrfidutils.h | 34 +++ everest/rfid/rfidproviderclient.cpp | 176 +++++++++++++ everest/rfid/rfidproviderclient.h | 46 ++++ everest/tests/CMakeLists.txt | 16 ++ everest/tests/tst_rfidproviderclient.cpp | 150 +++++++++++ 11 files changed, 970 insertions(+), 93 deletions(-) create mode 100644 everest/rfid/everestrfidutils.h create mode 100644 everest/rfid/rfidproviderclient.cpp create mode 100644 everest/rfid/rfidproviderclient.h create mode 100644 everest/tests/CMakeLists.txt create mode 100644 everest/tests/tst_rfidproviderclient.cpp diff --git a/everest/README.md b/everest/README.md index f8f7ae75..e413ac63 100644 --- a/everest/README.md +++ b/everest/README.md @@ -6,6 +6,60 @@ This nymea integration plugin allows to connect to any EVerest instance in the n By default the integration requires the use of the RpcApi module, which provides a JSON RPC interface based on a websocket communication to the EVerest core. +## Local AC charger with RFID + +Install and enable `nymea-rfid-provider.service` on the charger before nymead +starts. The provider owns the RFID hardware; EVerest must not run its own reader +module against it. The first provider backend supports PN7160 through +EVerest's `libnfc-nci`, built with libgpiod 2.x support for named GPIO lines. + +When creating a missing RPC charger child, the integration selects **EVerest AC +Charger with RFID** if the provider's compatible system D-Bus interface exists +and the connection is localhost, has exactly one EVSE with one connector, and +supports AC without DC. Provider readiness does not select the class: an +unavailable reader still belongs to an RFID-capable charger. Remote connections, +DC, MQTT and ambiguous connector topologies retain their existing behavior. + +The provider probe completes before new charger descriptors are emitted. +`connected` reports RPC connectivity; `rfidAvailable` reports usable RFID +connectivity. Provider restarts restore subscriptions without recreating things. +Scans during outages are not replayed. More than one available local connection +disables RFID routing rather than assigning a scan to an arbitrary charger. + +Existing charger things are preserved. To change an existing plain AC charger, +explicitly remove it and rediscover it by reconnecting/restarting its EVerest +connection after enabling the provider. The new thing has a new identity: +review thing-linked rules, energy settings and other references; historical data +remains associated with the old identity. There is no automatic migration. + +The standard nymea RFID experience handles enrollment, authorization identity and +charging policy. The integration supplies its required identity states and +enrollment action. It forwards `tagDetected(code)` once per PN7160 presentation; +holding a tag does not repeatedly trigger authorization or stop charging. +Identifiers use uppercase UID hexadecimal in reader-library byte order with +leading zeroes and no separators. Arrival/removal notifications are available +only on the provider's D-Bus interface in this version. + +`tagAccepted` and `tagRejected` currently log readable feedback, clear enrollment +mode and succeed. They do not call EVerest: the corresponding RPC feedback +methods are not yet available. This does not replace the RFID experience's +authorization decision. + +### RFID regression tests + +The standalone Qt 6 tests need Qt Core, DBus, Network, WebSockets, Test, +`dbus-run-session`, and the sibling nymea source checkout for headers. + +```sh +cmake -S everest/tests -B build-everest-rfid-tests -DCMAKE_PREFIX_PATH=/path/to/Qt +cmake --build build-everest-rfid-tests -j +ctest --test-dir build-everest-rfid-tests --output-on-failure +``` + +The tests use a private bus and fake provider; real PN7160 tag formats, physical +removal, charging enrollment/authorization and system boot ordering require +target acceptance testing. + ## Using the MQTT based API module diff --git a/everest/everest.pro b/everest/everest.pro index 970731a0..b9f6d96a 100644 --- a/everest/everest.pro +++ b/everest/everest.pro @@ -1,9 +1,10 @@ include(../plugins.pri) -QT += network websockets +QT += network websockets dbus PKGCONFIG += nymea-mqtt SOURCES += \ + rfid/rfidproviderclient.cpp \ jsonrpc/everestconnection.cpp \ jsonrpc/everestevse.cpp \ jsonrpc/everestjsonrpcclient.cpp \ @@ -16,6 +17,8 @@ SOURCES += \ integrationplugineverest.cpp HEADERS += \ + rfid/rfidproviderclient.h \ + rfid/everestrfidutils.h \ jsonrpc/everestconnection.h \ jsonrpc/everestevse.h \ jsonrpc/everestjsonrpcclient.h \ diff --git a/everest/integrationplugineverest.cpp b/everest/integrationplugineverest.cpp index 9805deb4..eb9331ca 100644 --- a/everest/integrationplugineverest.cpp +++ b/everest/integrationplugineverest.cpp @@ -28,9 +28,12 @@ #include "mqtt/everestmqttdiscovery.h" #include "jsonrpc/everestevse.h" #include "jsonrpc/everestjsonrpcdiscovery.h" +#include "rfid/everestrfidutils.h" #include +#include +#include #include #include @@ -41,6 +44,16 @@ IntegrationPluginEverest::IntegrationPluginEverest() void IntegrationPluginEverest::init() { + m_rfidProvider = new RfidProviderClient(this); + connect(m_rfidProvider, &RfidProviderClient::availabilityChanged, this, &IntegrationPluginEverest::updateRfidAvailability); + connect(m_rfidProvider, &RfidProviderClient::tagDetected, this, [this](const QString &code, const QVariantMap &) { + Thing *target = rfidTarget(); + if (!target) + return; + emit emitEvent(Event(everestChargerAcRfidTagDetectedEventTypeId, target->id(), + ParamList() << Param(everestChargerAcRfidTagDetectedEventCodeParamTypeId, code))); + }); + m_rfidProvider->probe(); QFileInfo enableMqttFileInfo(NymeaSettings::settingsPath() + "/everest-mqtt"); m_useMqtt = enableMqttFileInfo.exists(); if (m_useMqtt) { @@ -399,74 +412,98 @@ void IntegrationPluginEverest::setupThing(ThingSetupInfo *info) m_everstConnections.insert(thing, connection); connect(connection, &EverestConnection::availableChanged, thing, [this, thing, connection](bool available){ + const quint64 generation = connection->property("rfidDiscoveryGeneration").toULongLong() + 1; + connection->setProperty("rfidDiscoveryGeneration", generation); thing->setStateValue(everestConnectionConnectedStateTypeId, available); // Update connected state of child things foreach (Thing *child, myThings().filterByParentId(thing->id())) { child->setStateValue("connected", available); } + updateRfidAvailability(); if (available) { thing->setStateValue(everestConnectionApiVersionStateTypeId, connection->client()->apiVersion()); - // Sync things with availabe EvseInfos from this connection + // Finish the bounded provider probe before choosing a new child's class. + QPointer guardedThing(thing); + QPointer guardedConnection(connection); + m_rfidProvider->probe([this, guardedThing, guardedConnection, generation](bool providerAvailable) { + if (!guardedThing || !guardedConnection || !guardedConnection->available() + || guardedConnection->property("rfidDiscoveryGeneration").toULongLong() != generation + || m_everstConnections.value(guardedThing) != guardedConnection) + return; + Thing *thing = guardedThing; + EverestConnection *connection = guardedConnection; + int localConnections = 0; + for (EverestConnection *candidate : m_everstConnections) { + const QString host = candidate->client()->serverUrl().host(); + if (candidate->available() && (QHostAddress(host).isLoopback() || host.compare("localhost", Qt::CaseInsensitive) == 0)) + ++localConnections; + } + const bool withRfid = providerAvailable && localConnections == 1 + && everestRfidEligible(connection->client()->serverUrl(), connection->client()->evseInfos()); - QList indexList; - ThingDescriptors descriptors; - qCDebug(dcEverest()) << "The client is now available, synching things..."; + // Sync things with availabe EvseInfos from this connection - // First check if we have a thing for each index, if not, create the thing for it - foreach (const EverestJsonRpcClient::EVSEInfo &evseInfo, connection->client()->evseInfos()) { + QList indexList; + ThingDescriptors descriptors; + qCDebug(dcEverest()) << "The client is now available, synching things..."; - // Collect all EVSE index in onder to check later if we have a thing for a dissapeard index. - indexList.append(evseInfo.index); + // First check if we have a thing for each index, if not, create the thing for it + foreach (const EverestJsonRpcClient::EVSEInfo &evseInfo, connection->client()->evseInfos()) { - // Check if we already have a child device for this index - bool alreadyAdded = false; + // Collect all EVSE index in onder to check later if we have a thing for a dissapeard index. + indexList.append(evseInfo.index); + + // Check if we already have a child device for this index + bool alreadyAdded = false; + foreach (Thing *childThing, myThings().filterByParentId(thing->id())) { + if (childThing->paramValue("index").toInt() == evseInfo.index) { + qCDebug(dcEverest()) << "-> Found already added charger with index" << evseInfo.index; + alreadyAdded = true; + break; + } + } + + if (!alreadyAdded) { + if (evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC) + || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_ACDP) + || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_ACDP_BPT) + || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_BPT) + || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_combo_core) + || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_core) + || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_extended) + || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_unique)) { + + qCDebug(dcEverest()) << "-> Adding new discovered DC charger on" << connection->client()->serverUrl(); + ThingDescriptor descriptor(everestChargerDcThingClassId, evseInfo.id, evseInfo.description, thing->id()); + descriptor.setParams(ParamList() << Param(everestChargerDcThingIndexParamTypeId, evseInfo.index)); + descriptors.append(descriptor); + } else { + // If not a DC charger, default to AC + qCDebug(dcEverest()) << "-> Adding new discovered AC charger on" << connection->client()->serverUrl(); + ThingDescriptor descriptor(withRfid ? everestChargerAcRfidThingClassId : everestChargerAcThingClassId, + evseInfo.id, evseInfo.description, thing->id()); + descriptor.setParams(ParamList() << Param(withRfid ? everestChargerAcRfidThingIndexParamTypeId : everestChargerAcThingIndexParamTypeId, evseInfo.index)); + descriptors.append(descriptor); + } + } + } + + // Check if we have things for an index which does not exist for this connection any more + QList thingIdsToRemove; foreach (Thing *childThing, myThings().filterByParentId(thing->id())) { - if (childThing->paramValue("index").toInt() == evseInfo.index) { - qCDebug(dcEverest()) << "-> Found already added charger with index" << evseInfo.index; - alreadyAdded = true; - break; + int thingIndex = childThing->paramValue("index").toInt(); + if (!indexList.contains(thingIndex)) { + qCDebug(dcEverest()) << "Removing thing" << childThing->name() << "because the index" << thingIndex << "does not exist any more on this connection."; + emit autoThingDisappeared(childThing->id()); } } - if (!alreadyAdded) { - if (evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC) - || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_ACDP) - || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_ACDP_BPT) - || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_BPT) - || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_combo_core) - || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_core) - || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_extended) - || evseInfo.supportedEnergyTransferModes.contains(EverestJsonRpcClient::EnergyTransferModeDC_unique)) { - - qCDebug(dcEverest()) << "-> Adding new discovered DC charger on" << connection->client()->serverUrl(); - ThingDescriptor descriptor(everestChargerDcThingClassId, evseInfo.id, evseInfo.description, thing->id()); - descriptor.setParams(ParamList() << Param(everestChargerDcThingIndexParamTypeId, evseInfo.index)); - descriptors.append(descriptor); - } else { - // If not a DC charger, default to AC - qCDebug(dcEverest()) << "-> Adding new discovered AC charger on" << connection->client()->serverUrl(); - ThingDescriptor descriptor(everestChargerAcThingClassId, evseInfo.id, evseInfo.description, thing->id()); - descriptor.setParams(ParamList() << Param(everestChargerAcThingIndexParamTypeId, evseInfo.index)); - descriptors.append(descriptor); - } + if (!descriptors.isEmpty()) { + emit autoThingsAppeared(descriptors); } - } - - // Check if we have things for an index which does not exist for this connection any more - QList thingIdsToRemove; - foreach (Thing *childThing, myThings().filterByParentId(thing->id())) { - int thingIndex = childThing->paramValue("index").toInt(); - if (!indexList.contains(thingIndex)) { - qCDebug(dcEverest()) << "Removing thing" << childThing->name() << "because the index" << thingIndex << "does not exist any more on this connection."; - emit autoThingDisappeared(childThing->id()); - } - } - - if (!descriptors.isEmpty()) { - emit autoThingsAppeared(descriptors); - } + }); } }); @@ -474,7 +511,7 @@ void IntegrationPluginEverest::setupThing(ThingSetupInfo *info) connection->start(); return; - } else if (thing->thingClassId() == everestChargerAcThingClassId || thing->thingClassId() == everestChargerDcThingClassId) { + } else if (thing->thingClassId() == everestChargerAcThingClassId || thing->thingClassId() == everestChargerAcRfidThingClassId || thing->thingClassId() == everestChargerDcThingClassId) { Thing *parentThing = myThings().findById(thing->parentId()); EverestConnection *connection = m_everstConnections.value(parentThing); @@ -486,12 +523,30 @@ void IntegrationPluginEverest::setupThing(ThingSetupInfo *info) info->finish(Thing::ThingErrorNoError); connection->addThing(thing); + QTimer::singleShot(0, this, &IntegrationPluginEverest::updateRfidAvailability); return; } } void IntegrationPluginEverest::executeAction(ThingActionInfo *info) { + if (info->thing()->thingClassId() == everestChargerAcRfidThingClassId) { + const auto actionId = info->action().actionTypeId(); + if (actionId == everestChargerAcRfidRfidEnrollmentActiveActionTypeId) { + info->thing()->setStateValue("rfidEnrollmentActive", + info->action().paramValue(everestChargerAcRfidRfidEnrollmentActiveActionRfidEnrollmentActiveParamTypeId)); + info->finish(Thing::ThingErrorNoError); + return; + } + if (actionId == everestChargerAcRfidTagAcceptedActionTypeId || actionId == everestChargerAcRfidTagRejectedActionTypeId) { + qCInfo(dcEverest()) << "RFID tag" + << (actionId == everestChargerAcRfidTagAcceptedActionTypeId ? "accepted" : "rejected") + << "for" << info->thing()->name() << "- charger feedback RPC is not yet available."; + info->thing()->setStateValue("rfidEnrollmentActive", false); + info->finish(Thing::ThingErrorNoError); + return; + } + } qCDebug(dcEverest()) << "Executing action for thing" << info->thing() << info->action().actionTypeId().toString() << info->action().params(); @@ -543,7 +598,7 @@ void IntegrationPluginEverest::executeAction(ThingActionInfo *info) } return; - } else if (info->thing()->thingClassId() == everestChargerAcThingClassId) { + } else if (info->thing()->thingClassId() == everestChargerAcThingClassId || info->thing()->thingClassId() == everestChargerAcRfidThingClassId) { Thing *thing = info->thing(); Thing *parentThing = myThings().findById(thing->parentId()); EverestConnection *connection = m_everstConnections.value(parentThing); @@ -552,7 +607,7 @@ void IntegrationPluginEverest::executeAction(ThingActionInfo *info) return; } - if (!thing->stateValue(everestChargerAcConnectedStateTypeId).toBool()) { + if (!thing->stateValue(QStringLiteral("connected")).toBool()) { info->finish(Thing::ThingErrorHardwareNotAvailable); return; } @@ -563,8 +618,8 @@ void IntegrationPluginEverest::executeAction(ThingActionInfo *info) return; } - if (info->action().actionTypeId() == everestChargerAcPowerActionTypeId) { - bool power = info->action().paramValue(everestChargerAcPowerActionPowerParamTypeId).toBool(); + if (info->action().actionTypeId() == everestChargerAcPowerActionTypeId || info->action().actionTypeId() == everestChargerAcRfidPowerActionTypeId) { + bool power = info->action().paramValue(thing->thingClassId() == everestChargerAcRfidThingClassId ? everestChargerAcRfidPowerActionPowerParamTypeId : everestChargerAcPowerActionPowerParamTypeId).toBool(); qCDebug(dcEverest()) << "Execute power action" << power; EverestJsonRpcReply *reply = evse->setChargingAllowed(power) ; connect(reply, &EverestJsonRpcReply::finished, reply, &EverestJsonRpcReply::deleteLater); @@ -583,11 +638,11 @@ void IntegrationPluginEverest::executeAction(ThingActionInfo *info) return; } - info->thing()->setStateValue(everestChargerAcCurrentPowerStateTypeId, power); + info->thing()->setStateValue(QStringLiteral("power"), power); info->finish(Thing::ThingErrorNoError); }); - } else if (info->action().actionTypeId() == everestChargerAcMaxChargingCurrentActionTypeId) { - double current = info->action().paramValue(everestChargerAcMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toDouble(); + } else if (info->action().actionTypeId() == everestChargerAcMaxChargingCurrentActionTypeId || info->action().actionTypeId() == everestChargerAcRfidMaxChargingCurrentActionTypeId) { + double current = info->action().paramValue(thing->thingClassId() == everestChargerAcRfidThingClassId ? everestChargerAcRfidMaxChargingCurrentActionMaxChargingCurrentParamTypeId : everestChargerAcMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toDouble(); qCDebug(dcEverest()) << "Execute action set max charging current" << current << "[A]"; EverestJsonRpcReply *reply = evse->setACChargingCurrent(current) ; connect(reply, &EverestJsonRpcReply::finished, reply, &EverestJsonRpcReply::deleteLater); @@ -606,11 +661,11 @@ void IntegrationPluginEverest::executeAction(ThingActionInfo *info) return; } - info->thing()->setStateValue(everestChargerAcMaxChargingCurrentStateTypeId, current); + info->thing()->setStateValue(QStringLiteral("maxChargingCurrent"), current); info->finish(Thing::ThingErrorNoError); }); - } else if (info->action().actionTypeId() == everestChargerAcDesiredPhaseCountActionTypeId) { - int phaseCount = info->action().paramValue(everestChargerAcDesiredPhaseCountActionDesiredPhaseCountParamTypeId).toInt(); + } else if (info->action().actionTypeId() == everestChargerAcDesiredPhaseCountActionTypeId || info->action().actionTypeId() == everestChargerAcRfidDesiredPhaseCountActionTypeId) { + int phaseCount = info->action().paramValue(thing->thingClassId() == everestChargerAcRfidThingClassId ? everestChargerAcRfidDesiredPhaseCountActionDesiredPhaseCountParamTypeId : everestChargerAcDesiredPhaseCountActionDesiredPhaseCountParamTypeId).toInt(); qCDebug(dcEverest()) << "Execute action set phase count" << phaseCount; EverestJsonRpcReply *reply = evse->setACChargingPhaseCount(phaseCount); connect(reply, &EverestJsonRpcReply::finished, reply, &EverestJsonRpcReply::deleteLater); @@ -628,7 +683,7 @@ void IntegrationPluginEverest::executeAction(ThingActionInfo *info) info->finish(Thing::ThingErrorHardwareFailure); return; } - info->thing()->setStateValue(everestChargerAcDesiredPhaseCountStateTypeId, phaseCount); + info->thing()->setStateValue(QStringLiteral("desiredPhaseCount"), phaseCount); info->finish(Thing::ThingErrorNoError); }); } @@ -731,7 +786,7 @@ void IntegrationPluginEverest::thingRemoved(Thing *thing) } } else if (thing->thingClassId() == everestConnectionThingClassId) { m_everstConnections.take(thing)->deleteLater(); - } else if (thing->thingClassId() == everestChargerAcThingClassId || thing->thingClassId() == everestChargerDcThingClassId) { + } else if (thing->thingClassId() == everestChargerAcThingClassId || thing->thingClassId() == everestChargerAcRfidThingClassId || thing->thingClassId() == everestChargerDcThingClassId) { Thing *parentThing = myThings().findById(thing->parentId()); EverestConnection *connection = m_everstConnections.value(parentThing); if (!connection) @@ -739,4 +794,37 @@ void IntegrationPluginEverest::thingRemoved(Thing *thing) connection->removeThing(thing); } + QTimer::singleShot(0, this, &IntegrationPluginEverest::updateRfidAvailability); +} + +Thing *IntegrationPluginEverest::rfidTarget() const +{ + Thing *target = nullptr; + int localConnections = 0; + for (auto it = m_everstConnections.constBegin(); it != m_everstConnections.constEnd(); ++it) { + EverestConnection *connection = it.value(); + const QString host = connection->client()->serverUrl().host(); + if (!connection->available() || !(QHostAddress(host).isLoopback() || host.compare("localhost", Qt::CaseInsensitive) == 0)) + continue; + if (++localConnections > 1) + return nullptr; + if (!everestRfidEligible(connection->client()->serverUrl(), connection->client()->evseInfos())) + continue; + for (Thing *child : myThings().filterByParentId(it.key()->id())) { + if (child->thingClassId() != everestChargerAcRfidThingClassId + || child->paramValue("index").toInt() != connection->client()->evseInfos().first().index) + continue; + if (target) + return nullptr; + target = child; + } + } + return target; +} + +void IntegrationPluginEverest::updateRfidAvailability() +{ + Thing *target = rfidTarget(); + for (Thing *thing : myThings().filterByThingClassId(everestChargerAcRfidThingClassId)) + thing->setStateValue("rfidAvailable", thing == target && m_rfidProvider->ready()); } diff --git a/everest/integrationplugineverest.h b/everest/integrationplugineverest.h index d45a7d5b..55109e3f 100644 --- a/everest/integrationplugineverest.h +++ b/everest/integrationplugineverest.h @@ -30,6 +30,7 @@ #include "mqtt/everestmqttclient.h" #include "jsonrpc/everestconnection.h" +#include "rfid/rfidproviderclient.h" #include @@ -60,6 +61,9 @@ private: QHash m_thingClients; QHash m_everstConnections; + RfidProviderClient *m_rfidProvider = nullptr; + Thing *rfidTarget() const; + void updateRfidAvailability(); }; #endif // INTEGRATIONPLUGINEVEREST_H diff --git a/everest/integrationplugineverest.json b/everest/integrationplugineverest.json index 913b921d..4a1d592d 100644 --- a/everest/integrationplugineverest.json +++ b/everest/integrationplugineverest.json @@ -247,6 +247,313 @@ } ] }, + { + "name": "everestChargerAcRfid", + "displayName": "EVerest AC Charger with RFID", + "id": "d1b2d417-bf9d-4233-bab4-f1f59c227d24", + "setupMethod": "JustAdd", + "createMethods": [ + "auto" + ], + "interfaces": [ + "evchargerac", + "smartmeterconsumer", + "connectable", + "rfid" + ], + "paramTypes": [ + { + "id": "d0be2ecd-3cc0-489f-9ad1-93b118563085", + "name": "index", + "displayName": "Index", + "type": "uint", + "defaultValue": 0 + } + ], + "settingsTypes": [], + "stateTypes": [ + { + "id": "ff8c4bda-6e29-400e-ab6e-da991d8a80d0", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "cached": false, + "defaultValue": false + }, + { + "id": "ea3d96f9-8657-4c34-bf70-91c026314782", + "name": "power", + "displayName": "Charging enabled", + "displayNameAction": "Enable or disable charging", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "id": "1777220f-27f5-458c-a9ac-90383e27b99c", + "name": "maxChargingCurrent", + "displayName": "Maximum charging current", + "displayNameAction": "Set maximum charging current", + "type": "double", + "unit": "Ampere", + "defaultValue": 6, + "minValue": 6, + "maxValue": 32, + "stepSize": 0.01, + "writable": true + }, + { + "id": "ec1864b9-0dd4-40c2-b23b-750ebdbda75d", + "name": "pluggedIn", + "displayName": "Plugged in", + "type": "bool", + "defaultValue": false + }, + { + "id": "5e8d075f-4bc4-4531-aa5c-2112755dc213", + "name": "charging", + "displayName": "Charging", + "type": "bool", + "defaultValue": false + }, + { + "id": "15468c25-f7be-42f1-933e-af5c753478a3", + "name": "phaseCount", + "displayName": "Active phases", + "type": "uint", + "minValue": 1, + "maxValue": 3, + "defaultValue": 1 + }, + { + "id": "5fb08851-36fd-43f5-b202-eb888304dad2", + "name": "desiredPhaseCount", + "displayName": "Desired phase count", + "displayNameAction": "Set desired phase count", + "type": "uint", + "minValue": 1, + "maxValue": 3, + "possibleValues": [ + 1, + 3 + ], + "writable": true, + "defaultValue": 3 + }, + { + "id": "25136724-fd6c-42c8-b88a-7b62ccfde4ba", + "name": "state", + "displayName": "State", + "type": "QString", + "defaultValue": "", + "cached": false + }, + { + "id": "f20ce7c1-9089-4233-b7d9-ff9d9d26d30b", + "name": "totalEnergyConsumed", + "displayName": "Total energy", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0, + "cached": true + }, + { + "id": "fa51df44-fb7b-4aaa-857c-cfdd6ca5cbf3", + "name": "sessionEnergy", + "displayName": "Session energy", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0, + "suggestLogging": true + }, + { + "id": "82f4a7ec-96fd-4c02-bba8-b46102b6d8b8", + "name": "currentPower", + "displayName": "Current power", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "cached": false + }, + { + "id": "1201f1a2-1486-4425-a022-f1f106503c11", + "name": "currentPowerPhaseA", + "displayName": "Current power phase A", + "displayNameEvent": "Current power phase A changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "cached": false + }, + { + "id": "8ae52af8-7d62-4552-b2b7-6c0e4cc9ce01", + "name": "currentPowerPhaseB", + "displayName": "Current power phase B", + "displayNameEvent": "Current power phase B changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "cached": false + }, + { + "id": "41c0cb18-bee3-43f8-92c1-8e9637e5f935", + "name": "currentPowerPhaseC", + "displayName": "Current power phase C", + "displayNameEvent": "Current power phase C changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "cached": false + }, + { + "id": "0abaf10b-a7f5-4a20-94ad-f3f544e8adf1", + "name": "currentPhaseA", + "displayName": "Phase A current", + "displayNameEvent": "Phase A current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0, + "cached": false + }, + { + "id": "a374a8a7-ebe9-47a4-9552-dc477efcd61f", + "name": "currentPhaseB", + "displayName": "Phase B current", + "displayNameEvent": "Phase B current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0, + "cached": false + }, + { + "id": "c84e3184-a98d-40c7-9f46-61ccd92562a8", + "name": "currentPhaseC", + "displayName": "Phase C current", + "displayNameEvent": "Phase C current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0, + "cached": false + }, + { + "id": "e3bfd7ea-b790-4e2a-8086-9a05ea9eccf5", + "name": "voltagePhaseA", + "displayName": "Phase A voltage", + "displayNameEvent": "Phase A volatage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0, + "cached": false + }, + { + "id": "5ed8a2db-93ca-4e4b-9b5e-3452b6677285", + "name": "voltagePhaseB", + "displayName": "Phase B voltage", + "displayNameEvent": "Phase B voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0, + "cached": false + }, + { + "id": "aec3afdc-37d3-4ca8-b7b5-b1454189b082", + "name": "voltagePhaseC", + "displayName": "Phase C voltage", + "displayNameEvent": "Phase C voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0, + "cached": false + }, + { + "id": "c8bf937f-86bf-4fb1-9a21-14c204942977", + "name": "temperature", + "displayName": "Temperature", + "type": "double", + "unit": "DegreeCelsius", + "defaultValue": 0, + "cached": false + }, + { + "id": "ecff27d3-451b-4d4b-b018-d7779d132d81", + "name": "fanSpeed", + "displayName": "Fan speed", + "type": "double", + "unit": "Rpm", + "defaultValue": 0, + "cached": false + }, + { + "id": "f24fc1ef-5827-4f9e-a280-6db07160120e", + "name": "rfidAvailable", + "displayName": "RFID reader available", + "type": "bool", + "cached": false, + "defaultValue": false + }, + { + "id": "c9cbaa00-514c-4832-ba24-1870a1fee382", + "name": "authorizedUsername", + "displayName": "Authorized username", + "type": "QString", + "cached": false, + "defaultValue": "" + }, + { + "id": "8c930a59-656b-4036-b0de-4c77597d4d75", + "name": "authorizedDisplayName", + "displayName": "Authorized display name", + "type": "QString", + "cached": false, + "defaultValue": "" + }, + { + "id": "3bf9bb07-4270-4665-a15c-78efa9b28ed4", + "name": "authorizedTagHash", + "displayName": "Authorized tag hash", + "type": "QString", + "cached": false, + "defaultValue": "" + }, + { + "id": "fc752205-ca01-45c2-9448-13b6325be6b5", + "name": "rfidEnrollmentActive", + "displayName": "RFID enrollment active", + "displayNameAction": "Set RFID enrollment mode", + "type": "bool", + "writable": true, + "cached": false, + "defaultValue": false + } + ], + "actionTypes": [ + { + "id": "dc9cd114-6066-4c11-9282-f2a80963f631", + "name": "tagAccepted", + "displayName": "Accept RFID tag" + }, + { + "id": "915e9030-9dcd-448d-b8cc-cbbb0b2d7957", + "name": "tagRejected", + "displayName": "Reject RFID tag" + } + ], + "eventTypes": [ + { + "id": "43c6e179-a513-4ad8-a88a-c0a4dbda5a3d", + "name": "tagDetected", + "displayName": "RFID tag detected", + "paramTypes": [ + { + "id": "90d55a9d-81a2-4791-9e57-3f6f9efbff75", + "name": "code", + "displayName": "Tag code", + "type": "QString" + } + ] + } + ] + }, { "name": "everestChargerAc", "displayName": "EVerest AC Charger", @@ -745,4 +1052,3 @@ ] } - diff --git a/everest/jsonrpc/everestevse.cpp b/everest/jsonrpc/everestevse.cpp index 24e87162..5f24002a 100644 --- a/everest/jsonrpc/everestevse.cpp +++ b/everest/jsonrpc/everestevse.cpp @@ -211,18 +211,18 @@ void EverestEvse::initialize() void EverestEvse::processEvseStatus() { - if (m_thing->thingClassId() == everestChargerAcThingClassId) { - m_thing->setStateValue(everestChargerAcPowerStateTypeId, m_evseStatus.chargingAllowed); - m_thing->setStateValue(everestChargerAcSessionEnergyStateTypeId, m_evseStatus.chargedEnergyWh / 1000.0); - m_thing->setStateValue(everestChargerAcStateStateTypeId, m_evseStatus.evseStateString); - m_thing->setStateValue(everestChargerAcChargingStateTypeId, m_evseStatus.evseState == EverestJsonRpcClient::EvseStateCharging); - m_thing->setStateValue(everestChargerAcPluggedInStateTypeId, m_evseStatus.evseState != EverestJsonRpcClient::EvseStateUnplugged); + if ((m_thing->thingClassId() == everestChargerAcThingClassId || m_thing->thingClassId() == everestChargerAcRfidThingClassId)) { + m_thing->setStateValue(QStringLiteral("power"), m_evseStatus.chargingAllowed); + m_thing->setStateValue(QStringLiteral("sessionEnergy"), m_evseStatus.chargedEnergyWh / 1000.0); + m_thing->setStateValue(QStringLiteral("state"), m_evseStatus.evseStateString); + m_thing->setStateValue(QStringLiteral("charging"), m_evseStatus.evseState == EverestJsonRpcClient::EvseStateCharging); + m_thing->setStateValue(QStringLiteral("pluggedIn"), m_evseStatus.evseState != EverestJsonRpcClient::EvseStateUnplugged); - m_thing->setStateValue(everestChargerAcPhaseCountStateTypeId, m_evseStatus.acChargeStatus.activePhaseCount); - m_thing->setStateValue(everestChargerAcDesiredPhaseCountStateTypeId, m_evseStatus.acChargeStatus.activePhaseCount); + m_thing->setStateValue(QStringLiteral("phaseCount"), m_evseStatus.acChargeStatus.activePhaseCount); + m_thing->setStateValue(QStringLiteral("desiredPhaseCount"), m_evseStatus.acChargeStatus.activePhaseCount); if (m_evseStatus.acChargeParameters.maxCurrent > 0) - m_thing->setStateValue(everestChargerAcMaxChargingCurrentStateTypeId, m_evseStatus.acChargeParameters.maxCurrent); + m_thing->setStateValue(QStringLiteral("maxChargingCurrent"), m_evseStatus.acChargeParameters.maxCurrent); } else if (m_thing->thingClassId() == everestChargerDcThingClassId) { @@ -248,40 +248,40 @@ void EverestEvse::processEvseStatus() void EverestEvse::processHardwareCapabilities() { - if (m_thing->thingClassId() == everestChargerAcThingClassId) { + if ((m_thing->thingClassId() == everestChargerAcThingClassId || m_thing->thingClassId() == everestChargerAcRfidThingClassId)) { if (!m_hardwareCapabilities.phaseSwitchDuringCharging) { // Only option left is set the desired phase count to 3, force that value - m_thing->setStatePossibleValues(everestChargerAcDesiredPhaseCountStateTypeId, { 3 }); - m_thing->setStateValue(everestChargerAcDesiredPhaseCountStateTypeId, 3); - m_thing->setStateValue(everestChargerAcPhaseCountStateTypeId, 3); + m_thing->setStatePossibleValues(QStringLiteral("desiredPhaseCount"), { 3 }); + m_thing->setStateValue(QStringLiteral("desiredPhaseCount"), 3); + m_thing->setStateValue(QStringLiteral("phaseCount"), 3); } else { - m_thing->setStatePossibleValues(everestChargerAcDesiredPhaseCountStateTypeId, { 1, 3 }); + m_thing->setStatePossibleValues(QStringLiteral("desiredPhaseCount"), { 1, 3 }); } - m_thing->setStateMaxValue(everestChargerAcMaxChargingCurrentStateTypeId, m_hardwareCapabilities.maxCurrentImport); - m_thing->setStateMinValue(everestChargerAcMaxChargingCurrentStateTypeId, m_hardwareCapabilities.minCurrentImport == 0 ? 6 : m_hardwareCapabilities.minCurrentImport); + m_thing->setStateMaxValue(QStringLiteral("maxChargingCurrent"), m_hardwareCapabilities.maxCurrentImport); + m_thing->setStateMinValue(QStringLiteral("maxChargingCurrent"), m_hardwareCapabilities.minCurrentImport == 0 ? 6 : m_hardwareCapabilities.minCurrentImport); } } void EverestEvse::processMeterData() { - if (m_thing->thingClassId() == everestChargerAcThingClassId) { + if ((m_thing->thingClassId() == everestChargerAcThingClassId || m_thing->thingClassId() == everestChargerAcRfidThingClassId)) { - m_thing->setStateValue(everestChargerAcCurrentPowerStateTypeId, m_meterData.powerTotal); + m_thing->setStateValue(QStringLiteral("currentPower"), m_meterData.powerTotal); - m_thing->setStateValue(everestChargerAcCurrentPowerPhaseAStateTypeId, m_meterData.powerL1); - m_thing->setStateValue(everestChargerAcCurrentPowerPhaseBStateTypeId, m_meterData.powerL2); - m_thing->setStateValue(everestChargerAcCurrentPowerPhaseCStateTypeId, m_meterData.powerL3); + m_thing->setStateValue(QStringLiteral("currentPowerPhaseA"), m_meterData.powerL1); + m_thing->setStateValue(QStringLiteral("currentPowerPhaseB"), m_meterData.powerL2); + m_thing->setStateValue(QStringLiteral("currentPowerPhaseC"), m_meterData.powerL3); - m_thing->setStateValue(everestChargerAcCurrentPhaseAStateTypeId, m_meterData.currentL1); - m_thing->setStateValue(everestChargerAcCurrentPhaseBStateTypeId, m_meterData.currentL2); - m_thing->setStateValue(everestChargerAcCurrentPhaseCStateTypeId, m_meterData.currentL3); + m_thing->setStateValue(QStringLiteral("currentPhaseA"), m_meterData.currentL1); + m_thing->setStateValue(QStringLiteral("currentPhaseB"), m_meterData.currentL2); + m_thing->setStateValue(QStringLiteral("currentPhaseC"), m_meterData.currentL3); - m_thing->setStateValue(everestChargerAcVoltagePhaseAStateTypeId, m_meterData.voltageL1); - m_thing->setStateValue(everestChargerAcVoltagePhaseBStateTypeId, m_meterData.voltageL2); - m_thing->setStateValue(everestChargerAcVoltagePhaseCStateTypeId, m_meterData.voltageL3); + m_thing->setStateValue(QStringLiteral("voltagePhaseA"), m_meterData.voltageL1); + m_thing->setStateValue(QStringLiteral("voltagePhaseB"), m_meterData.voltageL2); + m_thing->setStateValue(QStringLiteral("voltagePhaseC"), m_meterData.voltageL3); - m_thing->setStateValue(everestChargerAcTotalEnergyConsumedStateTypeId, m_meterData.energyImportedTotal / 1000.0); + m_thing->setStateValue(QStringLiteral("totalEnergyConsumed"), m_meterData.energyImportedTotal / 1000.0); } else if (m_thing->thingClassId() == everestChargerDcThingClassId) { m_thing->setStateValue(everestChargerDcCurrentPowerStateTypeId, m_meterData.powerTotal); diff --git a/everest/rfid/everestrfidutils.h b/everest/rfid/everestrfidutils.h new file mode 100644 index 00000000..395adbff --- /dev/null +++ b/everest/rfid/everestrfidutils.h @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026, chargebyte austria GmbH + +#ifndef EVERESTRFIDUTILS_H +#define EVERESTRFIDUTILS_H + +#include "jsonrpc/everestjsonrpcclient.h" +#include + +inline bool everestRfidEligible(const QUrl &url, const QList &evses) +{ + if (!(QHostAddress(url.host()).isLoopback() || url.host().compare("localhost", Qt::CaseInsensitive) == 0) + || evses.size() != 1 || evses.first().availableConnectors.size() != 1) + return false; + bool ac = false; + for (auto mode : evses.first().supportedEnergyTransferModes) { + switch (mode) { + case EverestJsonRpcClient::EnergyTransferModeAC_single_phase_core: + case EverestJsonRpcClient::EnergyTransferModeAC_two_phase: + case EverestJsonRpcClient::EnergyTransferModeAC_three_phase_core: + case EverestJsonRpcClient::EnergyTransferModeAC_BPT: + case EverestJsonRpcClient::EnergyTransferModeAC_BPT_DER: + case EverestJsonRpcClient::EnergyTransferModeAC_DER: + ac = true; + break; + default: + // Unknown modes and any DC capability cannot safely select this class. + return false; + } + } + return ac; +} + +#endif // EVERESTRFIDUTILS_H diff --git a/everest/rfid/rfidproviderclient.cpp b/everest/rfid/rfidproviderclient.cpp new file mode 100644 index 00000000..69c7efd2 --- /dev/null +++ b/everest/rfid/rfidproviderclient.cpp @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026, chargebyte austria GmbH + +#include "rfidproviderclient.h" + +#include +#include +#include +#include + +namespace { +const QString service = QStringLiteral("io.nymea.RfidProvider"); +const QString path = QStringLiteral("/io/nymea/RfidProvider"); +const QString interface = QStringLiteral("io.nymea.RfidProvider1"); +const QString properties = QStringLiteral("org.freedesktop.DBus.Properties"); + +bool compatible(const QString &xml) +{ + QXmlStreamReader reader(xml); + bool foundSignal = false; + QMap propertyTypes; + while (reader.readNextStartElement()) { + if (reader.name() == QLatin1String("node")) + continue; + if (reader.name() != QLatin1String("interface") || reader.attributes().value("name") != interface) { + reader.skipCurrentElement(); + continue; + } + while (reader.readNextStartElement()) { + const auto attributes = reader.attributes(); + if (reader.name() == QLatin1String("property")) { + const QString access = attributes.value("access").toString(); + if (access == QLatin1String("read") || access == QLatin1String("readwrite")) + propertyTypes.insert(attributes.value("name").toString(), attributes.value("type").toString()); + reader.skipCurrentElement(); + } else if (reader.name() == QLatin1String("signal") && attributes.value("name") == QLatin1String("tagDetected")) { + QString signature; + while (reader.readNextStartElement()) { + if (reader.name() == QLatin1String("arg")) + signature += reader.attributes().value("type").toString(); + reader.skipCurrentElement(); + } + foundSignal = signature == QLatin1String("sa{sv}"); + } else { + reader.skipCurrentElement(); + } + } + } + return !reader.hasError() && foundSignal && propertyTypes.value("Backend") == QLatin1String("s") + && propertyTypes.value("Ready") == QLatin1String("b") + && propertyTypes.value("SupportsPresence") == QLatin1String("b") + && propertyTypes.value("LastError") == QLatin1String("s"); +} +} + +RfidProviderClient::RfidProviderClient(QObject *parent, const QDBusConnection &connection) : + QObject(parent), m_connection(connection) +{ + m_probeTimer.setSingleShot(true); + m_probeTimer.setInterval(1500); + connect(&m_probeTimer, &QTimer::timeout, this, [this]() { finishProbe(false); }); + auto watcher = new QDBusServiceWatcher(service, m_connection, QDBusServiceWatcher::WatchForOwnerChange, this); + connect(watcher, &QDBusServiceWatcher::serviceOwnerChanged, this, + [this](const QString &, const QString &, const QString &newOwner) { + ++m_generation; + m_owner.clear(); + m_available = false; + m_ready = false; + emit availabilityChanged(); + if (newOwner.isEmpty()) { + finishProbe(false); + } else { + m_probing = false; + probe(); + } + }); + m_connection.connect(service, path, interface, "tagDetected", this, + SLOT(onTagDetected(QString,QVariantMap,QDBusMessage))); + m_connection.connect(service, path, properties, "PropertiesChanged", this, + SLOT(onPropertiesChanged(QString,QVariantMap,QStringList,QDBusMessage))); +} + +bool RfidProviderClient::available() const { return m_available; } +bool RfidProviderClient::ready() const { return m_available && m_ready; } + +void RfidProviderClient::probe(std::function callback) +{ + if (callback) + m_callbacks.append(std::move(callback)); + if (m_probing) + return; + m_probing = true; + const quint64 generation = ++m_generation; + m_probeTimer.start(); + auto request = QDBusMessage::createMethodCall("org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", "GetNameOwner"); + request << service; + auto ownerCall = new QDBusPendingCallWatcher(m_connection.asyncCall(request, 1500), this); + connect(ownerCall, &QDBusPendingCallWatcher::finished, this, [this, ownerCall, generation]() { + ownerCall->deleteLater(); + if (!m_probing || generation != m_generation) + return; + QDBusPendingReply ownerReply = *ownerCall; + if (ownerReply.isError()) { + finishProbe(false); + return; + } + m_owner = ownerReply.value(); + auto request = QDBusMessage::createMethodCall(m_owner, path, "org.freedesktop.DBus.Introspectable", "Introspect"); + auto introspectCall = new QDBusPendingCallWatcher(m_connection.asyncCall(request, 1500), this); + connect(introspectCall, &QDBusPendingCallWatcher::finished, this, [this, introspectCall, generation]() { + introspectCall->deleteLater(); + if (!m_probing || generation != m_generation) + return; + QDBusPendingReply reply = *introspectCall; + if (reply.isError() || !compatible(reply.value())) { + finishProbe(false); + return; + } + auto request = QDBusMessage::createMethodCall(m_owner, path, properties, "GetAll"); + request << interface; + auto propertiesCall = new QDBusPendingCallWatcher(m_connection.asyncCall(request, 1500), this); + connect(propertiesCall, &QDBusPendingCallWatcher::finished, this, [this, propertiesCall, generation]() { + propertiesCall->deleteLater(); + if (!m_probing || generation != m_generation) + return; + QDBusPendingReply reply = *propertiesCall; + const QVariantMap values = reply.value(); + const bool valid = !reply.isError() && values.value("Ready").userType() == QMetaType::Bool + && values.value("Backend").userType() == QMetaType::QString + && values.value("SupportsPresence").userType() == QMetaType::Bool + && values.value("LastError").userType() == QMetaType::QString; + finishProbe(valid, values.value("Ready").toBool()); + }); + }); + }); +} + +void RfidProviderClient::finishProbe(bool available, bool ready) +{ + m_probeTimer.stop(); + m_probing = false; + ++m_generation; + const bool changed = m_available != available || m_ready != ready; + m_available = available; + m_ready = ready; + if (!available) + m_owner.clear(); + if (changed) + emit availabilityChanged(); + const auto callbacks = std::move(m_callbacks); + m_callbacks.clear(); + for (const auto &callback : callbacks) + callback(available); +} + +void RfidProviderClient::onTagDetected(const QString &code, const QVariantMap &metadata, const QDBusMessage &message) +{ + if (ready() && message.service() == m_owner && !code.isEmpty()) + emit tagDetected(code, metadata); +} + +void RfidProviderClient::onPropertiesChanged(const QString &changedInterface, const QVariantMap &changed, + const QStringList &invalidated, const QDBusMessage &message) +{ + if (!m_available || changedInterface != interface || message.service() != m_owner) + return; + if (invalidated.contains("Ready")) { + m_ready = false; + emit availabilityChanged(); + probe(); + } else if (changed.contains("Ready")) { + m_ready = changed.value("Ready").userType() == QMetaType::Bool && changed.value("Ready").toBool(); + emit availabilityChanged(); + } +} diff --git a/everest/rfid/rfidproviderclient.h b/everest/rfid/rfidproviderclient.h new file mode 100644 index 00000000..724feefc --- /dev/null +++ b/everest/rfid/rfidproviderclient.h @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026, chargebyte austria GmbH + +#ifndef RFIDPROVIDERCLIENT_H +#define RFIDPROVIDERCLIENT_H + +#include +#include +#include +#include +#include +#include + +class RfidProviderClient : public QObject +{ + Q_OBJECT +public: + explicit RfidProviderClient(QObject *parent = nullptr, + const QDBusConnection &connection = QDBusConnection::systemBus()); + bool available() const; + bool ready() const; + void probe(std::function callback = {}); + +signals: + void availabilityChanged(); + void tagDetected(const QString &code, const QVariantMap &metadata); + +private slots: + void onTagDetected(const QString &code, const QVariantMap &metadata, const QDBusMessage &message); + void onPropertiesChanged(const QString &interface, const QVariantMap &changed, + const QStringList &invalidated, const QDBusMessage &message); + +private: + QDBusConnection m_connection; + QTimer m_probeTimer; + QList> m_callbacks; + QString m_owner; + quint64 m_generation = 0; + bool m_probing = false; + bool m_available = false; + bool m_ready = false; + + void finishProbe(bool available, bool ready = false); +}; + +#endif // RFIDPROVIDERCLIENT_H diff --git a/everest/tests/CMakeLists.txt b/everest/tests/CMakeLists.txt new file mode 100644 index 00000000..71b59ee9 --- /dev/null +++ b/everest/tests/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.16) +project(everest-rfid-tests LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_AUTOMOC ON) +enable_testing() +set(NYMEA_TEST_QT_MAJOR_VERSION 6 CACHE STRING "Qt major version used by the standalone tests") +find_package(Qt${NYMEA_TEST_QT_MAJOR_VERSION} REQUIRED COMPONENTS Core DBus Test Network WebSockets) +find_program(DBUS_RUN_SESSION dbus-run-session REQUIRED) +set(NYMEA_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../nymea" CACHE PATH "nymea source checkout (headers only)") +add_executable(tst_rfidproviderclient tst_rfidproviderclient.cpp ../rfid/rfidproviderclient.cpp) +target_include_directories(tst_rfidproviderclient PRIVATE .. "${NYMEA_SOURCE_DIR}/libnymea") +target_link_libraries(tst_rfidproviderclient PRIVATE + Qt${NYMEA_TEST_QT_MAJOR_VERSION}::Core Qt${NYMEA_TEST_QT_MAJOR_VERSION}::DBus + Qt${NYMEA_TEST_QT_MAJOR_VERSION}::Test Qt${NYMEA_TEST_QT_MAJOR_VERSION}::Network + Qt${NYMEA_TEST_QT_MAJOR_VERSION}::WebSockets) +add_test(NAME everest-rfid COMMAND ${DBUS_RUN_SESSION} -- $) diff --git a/everest/tests/tst_rfidproviderclient.cpp b/everest/tests/tst_rfidproviderclient.cpp new file mode 100644 index 00000000..16330180 --- /dev/null +++ b/everest/tests/tst_rfidproviderclient.cpp @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include +#include +#include "rfid/rfidproviderclient.h" +#include "rfid/everestrfidutils.h" + +class ProviderFixture : public QObject +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "io.nymea.RfidProvider1") + Q_PROPERTY(QString Backend READ backend) + Q_PROPERTY(bool Ready READ ready) + Q_PROPERTY(bool SupportsPresence READ supportsPresence) + Q_PROPERTY(QString LastError READ lastError) +public: + bool isReady = false; + QString backend() const { return "fake"; } + bool ready() const { return isReady; } + bool supportsPresence() const { return true; } + QString lastError() const { return {}; } +signals: + void tagArrived(); + void tagDetected(const QString &code, const QVariantMap &metadata); + void tagRemoved(); +}; + +class TestRfidProviderClient : public QObject +{ + Q_OBJECT + QDBusConnection bus = QDBusConnection::sessionBus(); +private slots: + void cleanup() + { + bus.unregisterService("io.nymea.RfidProvider"); + bus.unregisterObject("/io/nymea/RfidProvider"); + } + + void absentProvider() + { + RfidProviderClient client(nullptr, bus); + int callbacks = 0; + bool result = true; + client.probe([&](bool found) { ++callbacks; result = found; }); + QTRY_COMPARE_WITH_TIMEOUT(callbacks, 1, 2500); + QVERIFY(!result); + QVERIFY(!client.available()); + QVERIFY(!client.ready()); + } + + void incompatibleProvider() + { + QObject incompatible; + QVERIFY(bus.registerObject("/io/nymea/RfidProvider", &incompatible, QDBusConnection::ExportAllContents)); + QVERIFY(bus.registerService("io.nymea.RfidProvider")); + RfidProviderClient client(nullptr, bus); + int callbacks = 0; + bool result = true; + client.probe([&](bool found) { ++callbacks; result = found; }); + QTRY_COMPARE_WITH_TIMEOUT(callbacks, 1, 2500); + QVERIFY(!result); + QVERIFY(!client.available()); + } + + void presenceReadinessAndRestart() + { + ProviderFixture provider; + QVERIFY(bus.registerObject("/io/nymea/RfidProvider", &provider, QDBusConnection::ExportAllContents)); + QVERIFY(bus.registerService("io.nymea.RfidProvider")); + RfidProviderClient client(nullptr, bus); + int callbacks = 0; + bool result = false; + client.probe([&](bool found) { ++callbacks; result = found; }); + QTRY_COMPARE_WITH_TIMEOUT(callbacks, 1, 2500); + QVERIFY(result); + QVERIFY(client.available()); + QVERIFY(!client.ready()); + provider.isReady = true; + auto update = QDBusMessage::createSignal("/io/nymea/RfidProvider", "org.freedesktop.DBus.Properties", "PropertiesChanged"); + update << QString("io.nymea.RfidProvider1") << QVariantMap{{"Ready", true}} << QStringList{}; + QVERIFY(bus.send(update)); + QTRY_VERIFY(client.ready()); + QSignalSpy detections(&client, &RfidProviderClient::tagDetected); + const QVariantMap metadata{{"technology", "ISO14443A"}}; + emit provider.tagDetected("0001ABFF", metadata); + QTRY_COMPARE(detections.count(), 1); + QCOMPARE(detections.first().at(0).toString(), QString("0001ABFF")); + QCOMPARE(detections.first().at(1).toMap(), metadata); + QVERIFY(bus.unregisterService("io.nymea.RfidProvider")); + QTRY_VERIFY(!client.available()); + QVERIFY(!client.ready()); + QVERIFY(bus.registerService("io.nymea.RfidProvider")); + QTRY_VERIFY_WITH_TIMEOUT(client.available(), 2500); + QTRY_VERIFY(client.ready()); + // A reconnect must neither replay a detection nor multiply subscriptions. + QCOMPARE(detections.count(), 1); + emit provider.tagDetected("0001ABFF", metadata); + QTRY_COMPARE(detections.count(), 2); + QTest::qWait(50); + QCOMPARE(detections.count(), 2); + } + + void eligibility_data() + { + QTest::addColumn("url"); + QTest::addColumn("evses"); + QTest::addColumn("connectors"); + QTest::addColumn("ac"); + QTest::addColumn("dc"); + QTest::addColumn("eligible"); + QTest::newRow("localhost-ac") << "ws://localhost:8849" << 1 << 1 << true << false << true; + QTest::newRow("ipv4-ac") << "ws://127.0.0.1:8849" << 1 << 1 << true << false << true; + QTest::newRow("ipv6-ac") << "ws://[::1]:8849" << 1 << 1 << true << false << true; + QTest::newRow("remote-ac") << "ws://192.168.1.2:8849" << 1 << 1 << true << false << false; + QTest::newRow("deceptive-host") << "ws://localhost.example:8849" << 1 << 1 << true << false << false; + QTest::newRow("multi-evse") << "ws://localhost:8849" << 2 << 1 << true << false << false; + QTest::newRow("multi-connector") << "ws://localhost:8849" << 1 << 2 << true << false << false; + QTest::newRow("no-connector") << "ws://localhost:8849" << 1 << 0 << true << false << false; + QTest::newRow("no-evse") << "ws://localhost:8849" << 0 << 1 << true << false << false; + QTest::newRow("dc") << "ws://localhost:8849" << 1 << 1 << false << true << false; + QTest::newRow("mixed-ac-dc") << "ws://localhost:8849" << 1 << 1 << true << true << false; + QTest::newRow("unknown") << "ws://localhost:8849" << 1 << 1 << false << false << false; + } + + void eligibility() + { + QFETCH(QString, url); + QFETCH(int, evses); + QFETCH(int, connectors); + QFETCH(bool, ac); + QFETCH(bool, dc); + QFETCH(bool, eligible); + QList topology; + for (int i = 0; i < evses; ++i) { + EverestJsonRpcClient::EVSEInfo evse; + evse.index = i; + for (int j = 0; j < connectors; ++j) { + EverestJsonRpcClient::ConnectorInfo connector; + connector.connectorId = j; + evse.availableConnectors.append(connector); + } + if (ac) evse.supportedEnergyTransferModes << EverestJsonRpcClient::EnergyTransferModeAC_three_phase_core; + if (dc) evse.supportedEnergyTransferModes << EverestJsonRpcClient::EnergyTransferModeDC_core; + topology << evse; + } + QCOMPARE(everestRfidEligible(QUrl(url), topology), eligible); + } +}; +QTEST_GUILESS_MAIN(TestRfidProviderClient) +#include "tst_rfidproviderclient.moc"