diff --git a/webasto/README.md b/webasto/README.md index dcfa38a..ad8d3f2 100644 --- a/webasto/README.md +++ b/webasto/README.md @@ -4,6 +4,7 @@ Connects nymea to Webasto wallboxes. Currently supported models: * Webasto Live * Webasto NEXT +* Webasto Unite ## Requirements diff --git a/webasto/integrationpluginwebasto.cpp b/webasto/integrationpluginwebasto.cpp index 292cafc..153e996 100644 --- a/webasto/integrationpluginwebasto.cpp +++ b/webasto/integrationpluginwebasto.cpp @@ -35,12 +35,16 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include "../vestel/evc04discovery.h" IntegrationPluginWebasto::IntegrationPluginWebasto() { @@ -150,6 +154,38 @@ void IntegrationPluginWebasto::discoverThings(ThingDiscoveryInfo *info) return; } + if (info->thingClassId() == webastoUniteThingClassId) { + EVC04Discovery *discovery = new EVC04Discovery(hardwareManager()->networkDeviceDiscovery(), dcWebasto(), info); + connect(discovery, &EVC04Discovery::discoveryFinished, info, [=](){ + foreach (const EVC04Discovery::Result &result, discovery->discoveryResults()) { + + if (result.brand != "Webasto") { + qCDebug(dcWebasto()) << "Skipping Vestel wallbox without Webasto branding..."; + continue; + } + QString name = result.chargepointId; + QString description = result.brand + " " + result.model; + ThingDescriptor descriptor(webastoUniteThingClassId, name, description); + qCDebug(dcWebasto()) << "Discovered:" << descriptor.title() << descriptor.description(); + + // Check if we already have set up this device + Things existingThings = myThings().filterByParam(webastoUniteThingMacAddressParamTypeId, result.networkDeviceInfo.macAddress()); + if (existingThings.count() == 1) { + qCDebug(dcWebasto()) << "This wallbox already exists in the system:" << result.networkDeviceInfo; + descriptor.setThingId(existingThings.first()->id()); + } + + ParamList params; + params << Param(webastoUniteThingMacAddressParamTypeId, result.networkDeviceInfo.macAddress()); + descriptor.setParams(params); + info->addThingDescriptor(descriptor); + } + + info->finish(Thing::ThingErrorNoError); + }); + discovery->startDiscovery(); + + } Q_ASSERT_X(false, "discoverThings", QString("Unhandled thingClassId: %1").arg(info->thingClassId().toString()).toUtf8()); } @@ -247,6 +283,49 @@ void IntegrationPluginWebasto::setupThing(ThingSetupInfo *info) return; } + if (thing->thingClassId() == webastoUniteThingClassId) { + + if (m_evc04Connections.contains(thing)) { + qCDebug(dcWebasto()) << "Reconfiguring existing thing" << thing->name(); + m_evc04Connections.take(thing)->deleteLater(); + + if (m_monitors.contains(thing)) { + hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing)); + } + } + + MacAddress macAddress = MacAddress(thing->paramValue(webastoUniteThingMacAddressParamTypeId).toString()); + if (!macAddress.isValid()) { + qCWarning(dcWebasto()) << "The configured mac address is not valid" << thing->params(); + info->finish(Thing::ThingErrorInvalidParameter, QT_TR_NOOP("The MAC address is not known. Please reconfigure the thing.")); + return; + } + + NetworkDeviceMonitor *monitor = hardwareManager()->networkDeviceDiscovery()->registerMonitor(macAddress); + m_monitors.insert(thing, monitor); + + connect(info, &ThingSetupInfo::aborted, monitor, [=](){ + if (m_monitors.contains(thing)) { + qCDebug(dcWebasto()) << "Unregistering monitor because setup has been aborted."; + hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing)); + } + }); + + if (monitor->reachable()) { + setupEVC04Connection(info); + } else { + qCDebug(dcWebasto()) << "Waiting for the network monitor to get reachable before continuing to set up the connection" << thing->name() << "..."; + connect(monitor, &NetworkDeviceMonitor::reachableChanged, info, [=](bool reachable){ + if (reachable) { + qCDebug(dcWebasto()) << "The monitor for thing setup" << thing->name() << "is now reachable. Continuing setup on" << monitor->networkDeviceInfo().address().toString(); + setupEVC04Connection(info); + } + }); + } + + return; + } + Q_ASSERT_X(false, "setupThing", QString("Unhandled thingClassId: %1").arg(thing->thingClassId().toString()).toUtf8()); } @@ -256,7 +335,7 @@ void IntegrationPluginWebasto::postSetupThing(Thing *thing) qCDebug(dcWebasto()) << "Post setup thing" << thing->name(); if (!m_pluginTimer) { qCDebug(dcWebasto()) << "Setting up refresh timer for Webasto connections"; - m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(1); + m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(2); connect(m_pluginTimer, &PluginTimer::timeout, this, [this] { foreach(Webasto *connection, m_webastoLiveConnections) { @@ -270,6 +349,13 @@ void IntegrationPluginWebasto::postSetupThing(Thing *thing) webastoNext->update(); } } + + foreach(EVC04ModbusTcpConnection *connection, m_evc04Connections) { + qCDebug(dcWebasto()) << "Updating connection" << connection->modbusTcpMaster()->hostAddress().toString(); + connection->update(); + connection->setAliveRegister(1); + } + }); m_pluginTimer->start(); @@ -306,6 +392,12 @@ void IntegrationPluginWebasto::thingRemoved(Thing *thing) connection->deleteLater(); } + if (thing->thingClassId() == webastoUniteThingClassId && m_evc04Connections.contains(thing)) { + EVC04ModbusTcpConnection *connection = m_evc04Connections.take(thing); + delete connection; + } + + // Unregister related hardware resources if (m_monitors.contains(thing)) hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing)); @@ -424,6 +516,94 @@ void IntegrationPluginWebasto::executeAction(ThingActionInfo *info) return; } + if (info->thing()->thingClassId() == webastoUniteThingClassId) { + + EVC04ModbusTcpConnection *evc04Connection = m_evc04Connections.value(info->thing()); + + if (info->action().actionTypeId() == webastoUnitePowerActionTypeId) { + bool power = info->action().paramValue(webastoUnitePowerActionPowerParamTypeId).toBool(); + + // If the car is *not* connected, writing a 0 to the charging current register will cause it to go to 6 A instead of 0 + // Because of this, we we're not connected, we'll do nothing, but once it get's connected, we'll sync the state over (see below in cableStateChanged) + if (!power && evc04Connection->cableState() < EVC04ModbusTcpConnection::CableStateCableConnectedVehicleConnected) { + info->thing()->setStateValue(webastoUnitePowerStateTypeId, false); + info->finish(Thing::ThingErrorNoError); + return; + } + + QModbusReply *reply = evc04Connection->setChargingCurrent(power ? info->thing()->stateValue(webastoUniteMaxChargingCurrentStateTypeId).toUInt() : 0); + connect(reply, &QModbusReply::finished, info, [info, reply, power](){ + if (reply->error() == QModbusDevice::NoError) { + info->thing()->setStateValue(webastoUnitePowerStateTypeId, power); + info->finish(Thing::ThingErrorNoError); + } else { + qCWarning(dcWebasto()) << "Error setting power:" << reply->error() << reply->errorString(); + info->finish(Thing::ThingErrorHardwareFailure); + } + }); + } + if (info->action().actionTypeId() == webastoUniteMaxChargingCurrentActionTypeId) { + int maxChargingCurrent = info->action().paramValue(webastoUniteMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toInt(); + QModbusReply *reply = evc04Connection->setChargingCurrent(maxChargingCurrent); + connect(reply, &QModbusReply::finished, info, [info, reply, maxChargingCurrent](){ + if (reply->error() == QModbusDevice::NoError) { + info->thing()->setStateValue(webastoUniteMaxChargingCurrentStateTypeId, maxChargingCurrent); + info->finish(Thing::ThingErrorNoError); + } else { + info->finish(Thing::ThingErrorHardwareFailure); + } + }); + } + if (info->action().actionTypeId() == webastoUniteDesiredPhaseCountActionTypeId) { + if (validTokenAvailable(thing)) { + executeWebastoUnitePhaseCountAction(info); + } else { + qCDebug(dcWebasto()) << "HTTP: Authentication required. Update access token for" << thing->name(); + QNetworkReply *loginReply = requestWebstoUniteAccessToken(evc04Connection->modbusTcpMaster()->hostAddress()); + connect(loginReply, &QNetworkReply::finished, evc04Connection, [=](){ + if (loginReply->error() != QNetworkReply::NoError) { + info->finish(Thing::ThingErrorAuthenticationFailure); + qCWarning(dcWebasto()) << "HTTP: Authentication request failed for" << evc04Connection->modbusTcpMaster()->hostAddress() << loginReply->error() << loginReply->errorString(); + return; + } + + QByteArray response = loginReply->readAll(); + + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(response, &error); + if (error.error != QJsonParseError::NoError) { + info->finish(Thing::ThingErrorAuthenticationFailure); + qCWarning(dcWebasto()) << "HTTP: Authentication response JSON error:" << error.errorString(); + return; + } + + QVariantMap responseMap = jsonDoc.toVariant().toMap(); + QString accessToken = responseMap.value("access_token").toString(); + QDateTime accessTokenExpireDateTime = QDateTime::fromString(responseMap.value("access_token_exp").toString(), Qt::ISODate); + + QStringList tokenParts = accessToken.split('.'); + if (tokenParts.count() != 3) { + qCWarning(dcWebasto()) << "HTTP: Could not read expiration timestamp. Invalid JWT token formatting. Does not contain 3 parts separated by dot."; + return; + } + + qCDebug(dcWebasto()) << "HTTP: Header" << QByteArray::fromBase64(tokenParts.at(0).toUtf8()); + qCDebug(dcWebasto()) << "HTTP: Payload" << QByteArray::fromBase64(tokenParts.at(1).toUtf8()); + qCDebug(dcWebasto()) << "HTTP: Signature" << tokenParts.at(2); + QJsonDocument payloadJsonDoc = QJsonDocument::fromJson(QByteArray::fromBase64(tokenParts.at(1).toUtf8())); + QVariantMap payloadMap = payloadJsonDoc.toVariant().toMap(); + QDateTime expirationDateTime = QDateTime::fromString(payloadMap.value("access_token_exp").toString(), Qt::ISODate); + qCDebug(dcWebasto()) << "HTTP: Token payload:" << qUtf8Printable(payloadJsonDoc.toJson()); + qCDebug(dcWebasto()) << "HTTP: Token expires:" << expirationDateTime.toString("dd.MM.yyyy hh:mm:ss"); + qCDebug(dcWebasto()) << "HTTP: Authentication finished successfully. Token:" << accessToken << "Expires:" << accessTokenExpireDateTime.toString("dd.MM.yyyy hh:mm:ss"); + m_webastoUniteTokens[thing] = QPair(accessToken, accessTokenExpireDateTime); + executeWebastoUnitePhaseCountAction(info); + }); + } + } + return; + } + Q_ASSERT_X(false, "executeAction", QString("Unhandled thingClassId: %1").arg(thing->thingClassId().toString()).toUtf8()); } @@ -984,3 +1164,319 @@ void IntegrationPluginWebasto::onReceivedRegister(Webasto::TqModbusRegister modb } } } + +void IntegrationPluginWebasto::setupEVC04Connection(ThingSetupInfo *info) +{ + Thing *thing = info->thing(); + + QHostAddress address = m_monitors.value(thing)->networkDeviceInfo().address(); + + EVC04ModbusTcpConnection *evc04Connection = new EVC04ModbusTcpConnection(address, 502, 0xff, this); + connect(info, &ThingSetupInfo::aborted, evc04Connection, &EVC04ModbusTcpConnection::deleteLater); + + // Reconnect on monitor reachable changed + NetworkDeviceMonitor *monitor = m_monitors.value(thing); + connect(monitor, &NetworkDeviceMonitor::reachableChanged, thing, [=](bool reachable){ + qCDebug(dcWebasto()) << "Network device monitor reachable changed for" << thing->name() << reachable; + if (!thing->setupComplete()) + return; + + if (reachable && !thing->stateValue("connected").toBool()) { + evc04Connection->modbusTcpMaster()->setHostAddress(monitor->networkDeviceInfo().address()); + evc04Connection->connectDevice(); + } else if (!reachable) { + // Note: We disable autoreconnect explicitly and we will + // connect the device once the monitor says it is reachable again + evc04Connection->disconnectDevice(); + } + }); + + connect(evc04Connection, &EVC04ModbusTcpConnection::reachableChanged, thing, [thing, evc04Connection](bool reachable){ + qCDebug(dcWebasto()) << "Reachable changed to" << reachable << "for" << thing; + if (reachable) { + evc04Connection->initialize(); + } else { + thing->setStateValue(webastoUniteConnectedStateTypeId, false); + thing->setStateValue(webastoUniteCurrentPowerStateTypeId, 0); + } + }); + + connect(evc04Connection, &EVC04ModbusTcpConnection::initializationFinished, thing, [=](bool success){ + if (!thing->setupComplete()) + return; + + if (success) { + thing->setStateValue(webastoUniteConnectedStateTypeId, true); + } else { + thing->setStateValue(webastoUniteConnectedStateTypeId, false); + thing->setStateValue(webastoUniteCurrentPowerStateTypeId, 0); + + // Try once to reconnect the device + evc04Connection->reconnectDevice(); + } + }); + + connect(evc04Connection, &EVC04ModbusTcpConnection::initializationFinished, info, [=](bool success){ + if (!success) { + qCWarning(dcWebasto()) << "Connection init finished with errors" << thing->name() << evc04Connection->modbusTcpMaster()->hostAddress().toString(); + hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(monitor); + evc04Connection->deleteLater(); + info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Error communicating with the wallbox.")); + return; + } + + qCDebug(dcWebasto()) << "Connection init finished successfully" << evc04Connection; + + m_evc04Connections.insert(thing, evc04Connection); + info->finish(Thing::ThingErrorNoError); + + thing->setStateValue(webastoUniteConnectedStateTypeId, true); + thing->setStateValue(webastoUniteVersionStateTypeId, QString(QString::fromUtf16(evc04Connection->firmwareVersion().data(), evc04Connection->firmwareVersion().length()).toUtf8()).trimmed()); + + evc04Connection->update(); + }); + + connect(evc04Connection, &EVC04ModbusTcpConnection::updateFinished, thing, [this, evc04Connection, thing](){ + qCDebug(dcWebasto()) << "EVC04 update finished:" << thing->name() << evc04Connection; + qCDebug(dcWebasto()) << "Serial:" << QString(QString::fromUtf16(evc04Connection->serialNumber().data(), evc04Connection->serialNumber().length()).toUtf8()).trimmed(); + qCDebug(dcWebasto()) << "ChargePoint ID:" << QString(QString::fromUtf16(evc04Connection->chargepointId().data(), evc04Connection->chargepointId().length()).toUtf8()).trimmed(); + qCDebug(dcWebasto()) << "Brand:" << QString(QString::fromUtf16(evc04Connection->brand().data(), evc04Connection->brand().length()).toUtf8()).trimmed(); + qCDebug(dcWebasto()) << "Model:" << QString(QString::fromUtf16(evc04Connection->model().data(), evc04Connection->model().length()).toUtf8()).trimmed(); + + updateEVC04MaxCurrent(thing); + + // I've been observing the wallbox getting stuck on modbus. It is still functional, but modbus keeps on returning the same old values + // until the TCP connection is closed and reopened. Checking the wallbox time register to detect that and auto-reconnect. + if (m_lastWallboxTime.contains(thing) && m_lastWallboxTime[thing] == evc04Connection->time()) { + qCWarning(dcWebasto()) << "Wallbox seems stuck and returning outdated values. Reconnecting..."; + evc04Connection->disconnectDevice(); + QTimer::singleShot(1000, evc04Connection, &EVC04ModbusTcpConnection::reconnectDevice); + } else { + m_lastWallboxTime[thing] = evc04Connection->time(); + } + }); + + connect(evc04Connection, &EVC04ModbusTcpConnection::chargepointStateChanged, thing, [thing](EVC04ModbusTcpConnection::ChargePointState chargePointState) { + qCDebug(dcWebasto()) << "Chargepoint state changed" << thing->name() << chargePointState; +// switch (chargePointState) { +// case EVC04ModbusTcpConnection::ChargePointStateAvailable: +// case EVC04ModbusTcpConnection::ChargePointStatePreparing: +// case EVC04ModbusTcpConnection::ChargePointStateReserved: +// case EVC04ModbusTcpConnection::ChargePointStateUnavailable: +// case EVC04ModbusTcpConnection::ChargePointStateFaulted: +// thing->setStateValue(evc04PluggedInStateTypeId, false); +// break; +// case EVC04ModbusTcpConnection::ChargePointStateCharging: +// case EVC04ModbusTcpConnection::ChargePointStateSuspendedEVSE: +// case EVC04ModbusTcpConnection::ChargePointStateSuspendedEV: +// case EVC04ModbusTcpConnection::ChargePointStateFinishing: +// thing->setStateValue(evc04PluggedInStateTypeId, true); +// break; +// } + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::chargingStateChanged, thing, [thing](EVC04ModbusTcpConnection::ChargingState chargingState) { + qCDebug(dcWebasto()) << "Charging state changed:" << chargingState; + thing->setStateValue(webastoUniteChargingStateTypeId, chargingState == EVC04ModbusTcpConnection::ChargingStateCharging); + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::activePowerTotalChanged, thing, [thing](quint16 activePowerTotal) { + qCDebug(dcWebasto()) << "Total active power:" << activePowerTotal; + // The wallbox reports some 5-6W even when there's nothing connected. Let's hide that if we're not charging + if (thing->stateValue(webastoUniteChargingStateTypeId).toBool() == true) { + thing->setStateValue(webastoUniteCurrentPowerStateTypeId, activePowerTotal); + } else { + thing->setStateValue(webastoUniteCurrentPowerStateTypeId, 0); + } + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::meterReadingChanged, thing, [thing](quint32 meterReading) { + qCDebug(dcWebasto()) << "Meter reading changed:" << meterReading; + thing->setStateValue(webastoUniteTotalEnergyConsumedStateTypeId, meterReading / 10.0); + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::sessionMaxCurrentChanged, thing, [](quint16 sessionMaxCurrent) { + // This mostly just reflects what we've been writing to cargingCurrent, so not of much use... + qCDebug(dcWebasto()) << "Session max current changed:" << sessionMaxCurrent; + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::cableMaxCurrentChanged, thing, [this, thing](quint16 cableMaxCurrent) { + qCDebug(dcWebasto()) << "Cable max current changed:" << cableMaxCurrent; + updateEVC04MaxCurrent(thing); + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::evseMinCurrentChanged, thing, [thing](quint16 evseMinCurrent) { + qCDebug(dcWebasto()) << "EVSE min current changed:" << evseMinCurrent; + thing->setStateMinValue(webastoUniteMaxChargingCurrentStateTypeId, evseMinCurrent); + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::evseMaxCurrentChanged, thing, [this, thing](quint16 evseMaxCurrent) { + qCDebug(dcWebasto()) << "EVSE max current changed:" << evseMaxCurrent; + updateEVC04MaxCurrent(thing); + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::sessionEnergyChanged, thing, [thing](quint32 sessionEnergy) { + qCDebug(dcWebasto()) << "Session energy changed:" << sessionEnergy; + thing->setStateValue(webastoUniteSessionEnergyStateTypeId, sessionEnergy / 1000.0); + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::chargingCurrentChanged, thing, [thing](quint16 chargingCurrent) { + qCDebug(dcWebasto()) << "Charging current changed:" << chargingCurrent; + if (chargingCurrent > 0) { + thing->setStateValue(webastoUnitePowerStateTypeId, true); + thing->setStateValue(webastoUniteMaxChargingCurrentStateTypeId, chargingCurrent); + } else { + thing->setStateValue(webastoUnitePowerStateTypeId, false); + } + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::numPhasesChanged, thing, [thing](EVC04ModbusTcpConnection::NumPhases numPhases) { + switch (numPhases) { + case EVC04ModbusTcpConnection::NumPhases1: + thing->setStateValue(webastoUnitePhaseCountStateTypeId, 1); + break; + case EVC04ModbusTcpConnection::NumPhases3: + thing->setStateValue(webastoUnitePhaseCountStateTypeId, 3); + break; + } + }); + connect(evc04Connection, &EVC04ModbusTcpConnection::cableStateChanged, thing, [evc04Connection, thing](EVC04ModbusTcpConnection::CableState cableState) { + switch (cableState) { + case EVC04ModbusTcpConnection::CableStateNotConnected: + case EVC04ModbusTcpConnection::CableStateCableConnectedVehicleNotConnected: + thing->setStateValue(webastoUnitePluggedInStateTypeId, false); + break; + case EVC04ModbusTcpConnection::CableStateCableConnectedVehicleConnected: + case EVC04ModbusTcpConnection::CableStateCableConnectedVehicleConnectedCableLocked: + thing->setStateValue(webastoUnitePluggedInStateTypeId, true); + + // The car was plugged in, sync the power state now as the wallbox only allows to set that when the car is connected + if (thing->stateValue(webastoUnitePowerStateTypeId).toBool() == false) { + qCInfo(dcWebasto()) << "Car plugged in. Syncing cached power off state to wallbox"; + evc04Connection->setChargingCurrent(0); + } + + break; + } + }); + + evc04Connection->connectDevice(); +} + +void IntegrationPluginWebasto::updateEVC04MaxCurrent(Thing *thing) +{ + EVC04ModbusTcpConnection *connection = m_evc04Connections.value(thing); + quint16 wallboxMax = connection->maxChargePointPower() > 0 ? connection->maxChargePointPower() / 230 : 32; + quint16 evseMax = connection->evseMaxCurrent() > 0 ? connection->evseMaxCurrent() : wallboxMax; + quint16 cableMax = connection->cableMaxCurrent() > 0 ? connection->cableMaxCurrent() : wallboxMax; + + quint8 overallMax = qMin(qMin(wallboxMax, evseMax), cableMax); + qCDebug(dcWebasto()) << "Adjusting max current: Wallbox max:" << wallboxMax << "EVSE max:" << evseMax << "cable max:" << cableMax << "Overall:" << overallMax; + thing->setStateMinMaxValues(webastoUniteMaxChargingCurrentStateTypeId, 6, overallMax); +} + +bool IntegrationPluginWebasto::validTokenAvailable(Thing *thing) +{ + if (m_webastoUniteTokens.contains(thing)) { + QPair tokenInfo = m_webastoUniteTokens.value(thing); + if (!tokenInfo.first.isEmpty() && tokenInfo.second > QDateTime::currentDateTimeUtc().addSecs(60)) { + qCDebug(dcWebasto()) << "HTTP: Valid access token found for" << thing->name(); + return true; + } else { + qCDebug(dcWebasto()) << "HTTP: Token need to be refreshed. The current token for" << thing->name() << "is expired:" << tokenInfo.second.toString("dd.MM.yyyy hh:mm:ss") << QDateTime::currentDateTimeUtc().toString(); + + } + } else { + qCDebug(dcWebasto()) << "HTTP: Token need to be refreshed. There is no token for" << thing->name(); + } + + return false; +} + +QNetworkReply *IntegrationPluginWebasto::requestWebstoUniteAccessToken(const QHostAddress &address) +{ + // Note: these credentials are documented in the Websto Unite installation manual and also provided using QR code. + QVariantMap requestMap; + requestMap.insert("username", "admin"); + requestMap.insert("password", "0#54&8eV%c+e2y(P2%h0"); + + QJsonDocument jsonDoc = QJsonDocument::fromVariant(requestMap); + + QUrl url; + url.setScheme("https"); // we have to use ssl and ignore the endpoint error + url.setHost(address.toString()); + url.setPath("/api/login"); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + qCDebug(dcWebasto()) << "HTTP: Requesting access token" << url.toString() << qUtf8Printable(jsonDoc.toJson(QJsonDocument::Compact));; + QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QJsonDocument::fromVariant(requestMap).toJson()); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, [reply](const QList &){ + // We ignore SSL errors in the LAN... quiet useless against MITM attacks + reply->ignoreSslErrors(); + }); + + return reply; +} + +QNetworkReply *IntegrationPluginWebasto::requestWebstoUnitePhaseCountChange(const QHostAddress &address, const QString &accessToken, uint desiredPhaseCount) +{ + QVariantList settingsList; + QVariantMap settingMap; + settingMap.insert("fieldKey", "installationSettings.currentLimiterPhase"); + settingMap.insert("value", QString("%1").arg(desiredPhaseCount == 3 ? 1 : 0)); + settingsList.append(settingMap); + + QJsonDocument jsonDoc = QJsonDocument::fromVariant(settingsList); + + QUrl url; + url.setScheme("https"); // we have to use ssl and ignore the endpoint error + url.setHost(address.toString()); + url.setPath("/api/configuration-updates"); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + accessToken.toLocal8Bit()); + qCDebug(dcWebasto()) << "HTTP: Requesting phase count change" << url.toString() << qUtf8Printable(jsonDoc.toJson(QJsonDocument::Compact)); + + QNetworkReply *reply = hardwareManager()->networkManager()->post(request, jsonDoc.toJson()); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, [reply](const QList &){ + // We ignore SSL errors in the LAN... quiet useless against MITM attacks + reply->ignoreSslErrors(); + }); + + return reply; +} + +void IntegrationPluginWebasto::executeWebastoUnitePhaseCountAction(ThingActionInfo *info) +{ + Thing *thing = info->thing(); + Action action = info->action(); + + quint8 desiredPhaseCount = info->action().paramValue(webastoUniteDesiredPhaseCountActionDesiredPhaseCountParamTypeId).toUInt(); + + QNetworkReply *reply = requestWebstoUnitePhaseCountChange(m_evc04Connections.value(thing)->modbusTcpMaster()->hostAddress(), m_webastoUniteTokens.value(thing).first, desiredPhaseCount); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::finished, info, [=](){ + if (reply->error() != QNetworkReply::NoError) { + qCWarning(dcWebasto()) << "HTTP: Error setting desired phase count:" << reply->error() << reply->errorString(); + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + + QByteArray response = reply->readAll(); + + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(response, &error); + if (error.error != QJsonParseError::NoError) { + info->finish(Thing::ThingErrorAuthenticationFailure); + qCWarning(dcWebasto()) << "HTTP: Set desired phase count response JSON error:" << error.errorString(); + return; + } + + qCDebug(dcWebasto()) << "HTTP: Response:" << qUtf8Printable(jsonDoc.toJson(QJsonDocument::Compact)); + + QVariantMap responseMap = jsonDoc.toVariant().toMap(); + if (responseMap.value("status").toString() != "SUCCESS") { + info->finish(Thing::ThingErrorHardwareFailure); + qCWarning(dcWebasto()) << "HTTP: Could not set desired phase count for" << thing->name(); + return; + } + + qCDebug(dcWebasto()) << "HTTP: Set webasto unite phase count to" << desiredPhaseCount << "finished successfully."; + thing->setStateValue(webastoUniteDesiredPhaseCountStateTypeId, desiredPhaseCount); + info->finish(Thing::ThingErrorNoError); + }); +} diff --git a/webasto/integrationpluginwebasto.h b/webasto/integrationpluginwebasto.h index 05da9a6..cb7b51b 100644 --- a/webasto/integrationpluginwebasto.h +++ b/webasto/integrationpluginwebasto.h @@ -37,11 +37,14 @@ #include "webasto.h" #include "webastonextmodbustcpconnection.h" +#include "evc04modbustcpconnection.h" #include #include #include +class QNetworkReply; + class IntegrationPluginWebasto : public IntegrationPlugin { Q_OBJECT @@ -65,6 +68,7 @@ private: QHash m_webastoLiveConnections; QHash m_webastoNextConnections; + QHash m_evc04Connections; QHash m_monitors; void setupWebastoNextConnection(ThingSetupInfo *info); @@ -74,6 +78,15 @@ private: void executeWebastoNextPowerAction(ThingActionInfo *info, bool power); + void setupEVC04Connection(ThingSetupInfo *info); + void updateEVC04MaxCurrent(Thing *thing); + QHash m_lastWallboxTime; + QHash> m_webastoUniteTokens; + bool validTokenAvailable(Thing *thing); + QNetworkReply *requestWebstoUniteAccessToken(const QHostAddress &address); + QNetworkReply *requestWebstoUnitePhaseCountChange(const QHostAddress &address, const QString &accessToken, uint desiredPhaseCount); + void executeWebastoUnitePhaseCountAction(ThingActionInfo *info); + private slots: void onConnectionChanged(bool connected); void onWriteRequestExecuted(const QUuid &requestId, bool success); diff --git a/webasto/integrationpluginwebasto.json b/webasto/integrationpluginwebasto.json index 72c6dda..d26ae78 100644 --- a/webasto/integrationpluginwebasto.json +++ b/webasto/integrationpluginwebasto.json @@ -431,6 +431,124 @@ "defaultValue": "" } ] + }, + { + "name": "webastoUnite", + "displayName": "Webasto Unite", + "id": "f7598439-a794-44d4-ae51-47ab40189d61", + "createMethods": ["discovery", "user"], + "interfaces": ["evcharger", "smartmeterconsumer", "connectable"], + "paramTypes": [ + { + "id": "99aedef2-1b23-4ab8-bee4-7e8b57b8fa18", + "name":"macAddress", + "displayName": "MAC address", + "type": "QString", + "inputType": "MacAddress", + "defaultValue": "" + } + ], + "stateTypes": [ + { + "id": "b670620e-c582-4e6e-8313-32a0f7d2c11c", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "dfa057e0-4e25-48da-967f-2821356ad44f", + "name": "pluggedIn", + "displayName": "Plugged in", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "c31ef8a1-4254-4507-bef5-1e959936bd3f", + "name": "charging", + "displayName": "Charging", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "962762ac-3a94-44ac-b591-1060a68a1376", + "name": "phaseCount", + "displayName": "Connected phases", + "type": "uint", + "minValue": 1, + "maxValue": 3, + "defaultValue": 3 + }, + { + "id": "4cd2d119-0198-4f94-8476-56eebb0b6867", + "name": "desiredPhaseCount", + "displayName": "Desired phase count", + "displayNameAction": "Set desired phase count", + "type": "uint", + "minValue": 1, + "maxValue": 3, + "possibleValues": [1, 3], + "defaultValue": 3, + "writable": true + }, + { + "id": "0d70ae14-15d7-4b1a-a621-2d0bc0bc28f1", + "name": "currentPower", + "displayName": "Active power", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "cached": false + }, + { + "id": "4d5fc8e4-e5f0-46b9-ba01-20e176312e05", + "name": "totalEnergyConsumed", + "displayName": "Total consumed energy", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.0, + "cached": true + }, + { + "id": "ebc0e32e-648e-468f-9566-585768a0d970", + "name": "power", + "displayName": "Charging enabled", + "displayNameAction": "Set charging enabled", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "id": "04aaf21a-9fd3-46f1-8fca-8cbed2117737", + "name": "maxChargingCurrent", + "displayName": "Maximum charging current", + "displayNameAction": "Set maximum charging current", + "type": "uint", + "unit": "Ampere", + "minValue": 6, + "maxValue": 32, + "defaultValue": 6, + "writable": true + }, + { + "id": "106b2bd3-4bd5-4774-8571-d64a2d5bf78b", + "name": "sessionEnergy", + "displayName": "Session energy", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0 + }, + { + "id": "ac1917a8-3445-4774-b0ec-042ebc7258aa", + "name": "version", + "displayName": "Firmware version", + "type": "QString", + "defaultValue": "" + } + ] } ] } diff --git a/webasto/webasto.pro b/webasto/webasto.pro index 51fcca8..07ef64a 100644 --- a/webasto/webasto.pro +++ b/webasto/webasto.pro @@ -1,16 +1,18 @@ include(../plugins.pri) # Generate modbus connection -MODBUS_CONNECTIONS += webasto-next-registers.json +MODBUS_CONNECTIONS += webasto-next-registers.json ../vestel/evc04-registers.json MODBUS_TOOLS_CONFIG += VERBOSE include(../modbus.pri) SOURCES += \ integrationpluginwebasto.cpp \ webasto.cpp \ - webastodiscovery.cpp + webastodiscovery.cpp \ + ../vestel/evc04discovery.cpp HEADERS += \ integrationpluginwebasto.h \ webasto.h \ - webastodiscovery.h + webastodiscovery.h \ + ../vestel/evc04discovery.h diff --git a/webasto/webastodiscovery.cpp b/webasto/webastodiscovery.cpp index 6a1d986..5ee81f9 100644 --- a/webasto/webastodiscovery.cpp +++ b/webasto/webastodiscovery.cpp @@ -143,6 +143,21 @@ void WebastoDiscovery::checkNetworkDevice(const NetworkDeviceInfo &networkDevice // All values good so far, let's assume this is a Webasto NEXT + // Final check if there is a hostname available for this network device, if so it shouls contain the string "NEXT_". + // This is neccessary since Wallboxes from Vestel EVC04 aka. Webasto Unite wallboxes would also match + // the creteria up to here get detected as positiv Webasto NEXT. + + // Example hostname: NEXT-WS10XXXX + + if (!networkDeviceInfo.hostName().isEmpty() && + (!networkDeviceInfo.hostName().contains("NEXT_") || networkDeviceInfo.hostName().contains("VESTEL"))) { + qCDebug(dcWebasto()) << "Discovery: network device has a hostname and it does match kriteria for Webasto next:" << networkDeviceInfo.hostName() << "on" << networkDeviceInfo.address().toString() << "Continue...";; + cleanupConnection(connection); + return; + } + + // Hostname verification also OK, let's assume this is a Webasto NEXT + Result result; result.productName = "Webasto NEXT"; result.type = TypeWebastoNext;