diff --git a/debian-qt5/control b/debian-qt5/control index 11c4d634..49515d2f 100644 --- a/debian-qt5/control +++ b/debian-qt5/control @@ -486,6 +486,17 @@ Description: nymea integration plugin for one wire devices This package contains the nymea integration plugin for one wire devices. +Package: nymea-plugin-openccu +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, +Conflicts: nymea-plugins-translations (<< 1.0.1) +Description: nymea integration plugin for OpenCCU + This package contains the nymea integration plugin for OpenCCU + based floor heating systems. The integration connects to the + xmlapi integration of OpenCCU. + + Package: nymea-plugin-openuv Architecture: any Depends: ${shlibs:Depends}, diff --git a/debian-qt5/nymea-plugin-openccu.install.in b/debian-qt5/nymea-plugin-openccu.install.in new file mode 100644 index 00000000..985b3f3b --- /dev/null +++ b/debian-qt5/nymea-plugin-openccu.install.in @@ -0,0 +1 @@ +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationpluginopenccu.so diff --git a/debian-qt6/control b/debian-qt6/control index ece193d0..5fc5822c 100644 --- a/debian-qt6/control +++ b/debian-qt6/control @@ -440,6 +440,17 @@ Description: nymea integration plugin for one wire devices This package contains the nymea integration plugin for one wire devices. +Package: nymea-plugin-openccu +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, +Conflicts: nymea-plugins-translations (<< 1.0.1) +Description: nymea integration plugin for OpenCCU + This package contains the nymea integration plugin for OpenCCU + based floor heating systems. The integration connects to the + xmlapi integration of OpenCCU. + + Package: nymea-plugin-openuv Architecture: any Depends: ${shlibs:Depends}, diff --git a/debian-qt6/nymea-plugin-openccu.install.in b/debian-qt6/nymea-plugin-openccu.install.in new file mode 120000 index 00000000..d2609790 --- /dev/null +++ b/debian-qt6/nymea-plugin-openccu.install.in @@ -0,0 +1 @@ +../debian-qt5/nymea-plugin-openccu.install.in \ No newline at end of file diff --git a/nymea-plugins.pro b/nymea-plugins.pro index 01b19cd3..459ef528 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -47,7 +47,8 @@ PLUGIN_DIRS = \ notifyevents \ nuki \ onewire \ - openuv \ + openccu \ + openuv \ openweathermap \ osdomotics \ philipshue \ diff --git a/openccu/integrationpluginopenccu.cpp b/openccu/integrationpluginopenccu.cpp new file mode 100644 index 00000000..a9de9003 --- /dev/null +++ b/openccu/integrationpluginopenccu.cpp @@ -0,0 +1,770 @@ +// 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-plugins. +* +* nymea-plugins 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-plugins 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-plugins. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "integrationpluginopenccu.h" +#include "plugininfo.h" + +#include +#include + +#include +#include + +#include + + +IntegrationPluginOpenCCU::IntegrationPluginOpenCCU() +{ +} + +void IntegrationPluginOpenCCU::init() +{ +} + + +void IntegrationPluginOpenCCU::discoverThings(ThingDiscoveryInfo *info) +{ + info->finish(Thing::ThingErrorNoError); +} + +void IntegrationPluginOpenCCU::setupThing(ThingSetupInfo *info) +{ + if (info->thing()->thingClassId() == thermostatThingClassId) { + m_thermostats.insert(info->thing(), Thermostat()); + } + + info->finish(Thing::ThingErrorNoError); +} + +void IntegrationPluginOpenCCU::postSetupThing(Thing *thing) +{ + if (!m_pluginTimer) { + m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(10); + connect(m_pluginTimer, &PluginTimer::timeout, this, [this](){ + // Refresh thermostats + foreach (Thing *gatewayThing, myThings().filterByThingClassId(openCCUThingClassId)) { + qCDebug(dcOpenCCU()) << "Refresh" << gatewayThing; + getStateList(gatewayThing); + } + }); + + m_pluginTimer->start(); + } + + if (thing->thingClassId() == openCCUThingClassId) { + // Sync devices + getDevices(thing); + } else if (thing->thingClassId() == floorHeatingControllerThingClassId) { + // Sync channels + getChannels(thing); + } +} + + +void IntegrationPluginOpenCCU::thingRemoved(Thing *thing) +{ + if (m_thermostats.contains(thing)) + m_thermostats.remove(thing); + + if (myThings().isEmpty() && m_pluginTimer) { + hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer); + m_pluginTimer = nullptr; + } +} + +void IntegrationPluginOpenCCU::executeAction(ThingActionInfo *info) +{ + if (info->thing()->thingClassId() == thermostatThingClassId) { + + // Get the parent thing for the URL + Thing *gateway = gatewayThing(info->thing()); + if (!gateway) { + info->finish(Thing::ThingErrorHardwareNotAvailable); + return; + } + + QUrlQuery query; + if (info->action().actionTypeId() == thermostatModeStateTypeId) { + + int iseId = m_thermostats.value(info->thing()).channels.value(1).controlModeId; + if (iseId < 0) { + // A device which has to be controlled using the mode id, not the control mode id... + iseId = m_thermostats.value(info->thing()).channels.value(1).modeId; + } + + if (iseId < 0) { + qCWarning(dcOpenCCU()) << "The ise ID of the mode is not known yet."; + info->finish(Thing::ThingErrorHardwareNotAvailable); + return; + } + + query.addQueryItem("ise_id", QString::number(iseId)); + if (info->action().paramValue(thermostatModeActionModeParamTypeId).toString() == "Auto") { + query.addQueryItem("new_value", QString::number(0)); + } else { + query.addQueryItem("new_value", QString::number(1)); + } + + QUrl requestUrl = buildUrl(gateway, "statechange.cgi", query); + qCDebug(dcOpenCCU()) << "GET statechange.cgi for thermostat mode"; + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(requestUrl)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, &IntegrationPluginOpenCCU::onSslError); + connect(reply, &QNetworkReply::finished, this, [this, info, reply, gateway](){ + if (reply->error() != QNetworkReply::NoError) { + qCDebug(dcOpenCCU()) << "Execute action finished with error" << reply->errorString(); + setGatewayConnected(gateway, false); + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + + setGatewayConnected(gateway, true); + qCDebug(dcOpenCCU()) << "Execute action finished successfully" << reply->readAll(); + info->thing()->setStateValue(thermostatModeStateTypeId, + info->action().paramValue(thermostatModeActionModeParamTypeId).toString()); + + info->finish(Thing::ThingErrorNoError); + }); + + } else if (info->action().actionTypeId() == thermostatTargetTemperatureActionTypeId) { + int iseId = m_thermostats.value(info->thing()).channels.value(1).targetTemperatureId; + if (iseId < 0) { + qCWarning(dcOpenCCU()) << "The ise ID of the target temperature is not known yet."; + info->finish(Thing::ThingErrorHardwareNotAvailable); + return; + } + + double value = info->action().paramValue(thermostatTargetTemperatureActionTargetTemperatureParamTypeId).toDouble(); + value = qRound(value * 2) / 2.0; + qCDebug(dcOpenCCU()) << "Setting target temperature of" << info->thing()->name() << "to" << value << "°C"; + query.addQueryItem("ise_id", QString::number(iseId)); + query.addQueryItem("new_value", QString::number(value)); + + QUrl requestUrl = buildUrl(gateway, "statechange.cgi", query); + qCDebug(dcOpenCCU()) << "GET statechange.cgi for thermostat target temperature"; + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(requestUrl)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, &IntegrationPluginOpenCCU::onSslError); + connect(reply, &QNetworkReply::finished, this, [this, info, reply, value, gateway](){ + if (reply->error() != QNetworkReply::NoError) { + qCDebug(dcOpenCCU()) << "Execute action finished with error" << reply->errorString(); + setGatewayConnected(gateway, false); + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + + setGatewayConnected(gateway, true); + qCDebug(dcOpenCCU()) << "Execute action finished successfully" << reply->readAll(); + info->thing()->setStateValue(thermostatTargetTemperatureStateTypeId, value); + info->finish(Thing::ThingErrorNoError); + }); + } else if (info->action().actionTypeId() == thermostatPowerActionTypeId) { + // Note: this has been implemented just to match the interface, this actually does nothing on this thermostats + info->finish(Thing::ThingErrorNoError); + return; + } + } else if (info->thing()->thingClassId() == floorHeatingValveThingClassId) { + if (info->action().actionTypeId() != floorHeatingValvePercentageActionTypeId) { + info->finish(Thing::ThingErrorUnsupportedFeature); + return; + } + + Thing *gateway = gatewayThing(info->thing()); + if (!gateway) { + info->finish(Thing::ThingErrorHardwareNotAvailable); + return; + } + + int percentage = info->action().paramValue(floorHeatingValvePercentageActionPercentageParamTypeId).toInt(); + percentage = qBound(0, percentage, 100); + + QUrlQuery query; + query.addQueryItem("ise_id", info->thing()->paramValue(floorHeatingValveThingIseIdParamTypeId).toString()); + query.addQueryItem("new_value", QString::number(percentage / 100.0, 'f', 6)); + + QUrl requestUrl = buildUrl(gateway, "statechange.cgi", query); + qCDebug(dcOpenCCU()) << "GET statechange.cgi for floor heating valve percentage"; + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(requestUrl)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, &IntegrationPluginOpenCCU::onSslError); + connect(reply, &QNetworkReply::finished, this, [this, info, reply, percentage, gateway](){ + if (reply->error() != QNetworkReply::NoError) { + qCDebug(dcOpenCCU()) << "Execute action finished with error" << reply->errorString(); + setGatewayConnected(gateway, false); + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + + setGatewayConnected(gateway, true); + qCDebug(dcOpenCCU()) << "Execute action finished successfully" << reply->readAll(); + info->thing()->setStateValue(floorHeatingValveConnectedStateTypeId, true); + info->thing()->setStateValue(floorHeatingValvePercentageStateTypeId, percentage); + info->finish(Thing::ThingErrorNoError); + }); + } +} + +void IntegrationPluginOpenCCU::getDevices(Thing *thing) +{ + QUrl url = buildUrl(thing, "devicelist.cgi"); + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, &IntegrationPluginOpenCCU::onSslError); + connect(reply, &QNetworkReply::finished, this, [this, thing, reply](){ + if (reply->error() != QNetworkReply::NoError) { + qCDebug(dcOpenCCU()) << "Reply finished with error" << reply->errorString(); + setGatewayConnected(thing, false); + return; + } + + setGatewayConnected(thing, true); + + QByteArray data = reply->readAll(); + //qCDebug(dcOpenCCU()) << "-->" << data; + + ThingDescriptors descriptors; + QString deviceName; + QXmlStreamReader xml(data); + while(!xml.atEnd() && !xml.hasError()) { + xml.readNext(); + if (xml.name() == QString("device") && xml.isStartElement()) { + qCDebug(dcOpenCCU()) << "-->" << xml.name() << deviceName; + foreach (const QXmlStreamAttribute &attribute, xml.attributes()) { + qCDebug(dcOpenCCU()) << " " << attribute.name() << attribute.value(); + } + + deviceName = xml.attributes().value("name").toString(); + QString deviceType = xml.attributes().value("device_type").toString(); + int iseId = xml.attributes().value("ise_id").toInt(); + // Thermostats + if (deviceType.startsWith("HmIP-STH")) { + QString serialNumber = xml.attributes().value("address").toString(); + + if (myThings().filterByParam(thermostatThingSerialNumberParamTypeId, serialNumber).isEmpty()) { + qCDebug(dcOpenCCU()) << "Adding new" << deviceType; + ThingDescriptor descriptor(thermostatThingClassId, deviceName, deviceType + " - " + serialNumber, thing->id()); + ParamList params; + params.append(Param(thermostatThingSerialNumberParamTypeId, serialNumber)); + params.append(Param(thermostatThingTypeParamTypeId, deviceType)); + params.append(Param(thermostatThingIseIdParamTypeId, iseId)); + descriptor.setParams(params); + descriptors.append(descriptor); + } else { + qCDebug(dcOpenCCU()) << "Thing for" << deviceType << serialNumber << "already created."; + } + } + + // Floor heating controller // C-8 or C-12 + if (deviceType.startsWith("HmIP-FALMOT-C")) { + QString serialNumber = xml.attributes().value("address").toString(); + + if (myThings().filterByParam(floorHeatingControllerThingSerialNumberParamTypeId, serialNumber).isEmpty()) { + qCDebug(dcOpenCCU()) << "Adding new" << deviceType; + ThingDescriptor descriptor(floorHeatingControllerThingClassId, deviceName, deviceType + " - " + serialNumber, thing->id()); + ParamList params; + params.append(Param(floorHeatingControllerThingSerialNumberParamTypeId, serialNumber)); + params.append(Param(floorHeatingControllerThingTypeParamTypeId, deviceType)); + params.append(Param(floorHeatingControllerThingIseIdParamTypeId, iseId)); + descriptor.setParams(params); + descriptors.append(descriptor); + } else { + qCDebug(dcOpenCCU()) << "Thing for" << deviceType << serialNumber << "already created."; + } + } + } + } + + if (xml.hasError()) { + qCWarning(dcOpenCCU()) << "Could not parse devicelist response:" << xml.errorString(); + setGatewayConnected(thing, false); + return; + } + + if (!descriptors.isEmpty()) { + emit autoThingsAppeared(descriptors); + } + + getStateList(thing); + }); +} + +void IntegrationPluginOpenCCU::getDeviceTypeList(Thing *thing) +{ + QUrl url = buildUrl(thing, "devicetypelist.cgi"); + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, &IntegrationPluginOpenCCU::onSslError); + connect(reply, &QNetworkReply::finished, this, [this, thing, reply](){ + if (reply->error() != QNetworkReply::NoError) { + qCDebug(dcOpenCCU()) << "Reply finished with error" << reply->errorString(); + setGatewayConnected(thing, false); + return; + } + + QByteArray data = reply->readAll(); + //qCDebug(dcOpenCCU()) << "-->" << data; + setGatewayConnected(thing, true); + + QXmlStreamReader xml(data); + while(!xml.atEnd() && !xml.hasError()) { + xml.readNext(); + if (xml.name() == QString("deviceType")) { + qCDebug(dcOpenCCU()) << "-->" << xml.name() << xml.text(); + foreach (const QXmlStreamAttribute &attribute, xml.attributes()) { + qCDebug(dcOpenCCU()) << " " << attribute.name() << attribute.value(); + } + } + } + }); +} + +void IntegrationPluginOpenCCU::getState(Thing *thing) +{ + QUrl url = buildUrl(thing, "state.cgi"); + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, &IntegrationPluginOpenCCU::onSslError); + connect(reply, &QNetworkReply::finished, this, [this, thing, reply](){ + if (reply->error() != QNetworkReply::NoError) { + qCDebug(dcOpenCCU()) << "Reply finished with error" << reply->errorString(); + setGatewayConnected(thing, false); + return; + } + + QByteArray data = reply->readAll(); + //qCDebug(dcOpenCCU()) << "-->" << data; + setGatewayConnected(thing, true); + + QXmlStreamReader xml(data); + while(!xml.atEnd() && !xml.hasError()) { + xml.readNext(); + if (xml.name() == QString("device")) { + qCDebug(dcOpenCCU()) << "-->" << xml.name() << xml.text(); + foreach (const QXmlStreamAttribute &attribute, xml.attributes()) { + qCDebug(dcOpenCCU()) << " " << attribute.name() << attribute.value(); + } + } + } + }); +} + +void IntegrationPluginOpenCCU::getStateList(Thing *thing) +{ + // filtering if ise_id is not working with API version 2.3 + // QUrlQuery query; + // if (iseId >= 0) { + // query.addQueryItem("ise_id", QString::number(iseId)); + // } + + QUrl url = buildUrl(thing, "statelist.cgi"); + qCDebug(dcOpenCCU()) << "GET statelist.cgi"; + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, &IntegrationPluginOpenCCU::onSslError); + connect(reply, &QNetworkReply::finished, this, [this, thing, reply](){ + if (reply->error() != QNetworkReply::NoError) { + qCDebug(dcOpenCCU()) << "Reply finished with error" << reply->errorString(); + setGatewayConnected(thing, false); + return; + } + + QByteArray data = reply->readAll(); + //qCDebug(dcOpenCCU()) << "-->" << data; + setGatewayConnected(thing, true); + + QXmlStreamReader xml(data); + while(!xml.atEnd() && !xml.hasError()) { + xml.readNext(); + if (xml.name() == QString("device") && xml.isStartElement()) { + qCDebug(dcOpenCCU()) << "-->" << xml.name() << xml.attributes().value("name").toString(); + foreach (const QXmlStreamAttribute &attribute, xml.attributes()) { + qCDebug(dcOpenCCU()) << " " << attribute.name() << attribute.value(); + } + + int iseId = xml.attributes().value("ise_id").toInt(); + foreach (Thing *childThing, myThings().filterByParentId(thing->id())) { + if (childThing->paramValue("iseId").toInt() == iseId) { + qCDebug(dcOpenCCU()) << "Updating states of" << childThing; + processThingStateList(&xml, childThing); + } + } + } + } + + if (xml.hasError()) { + qCWarning(dcOpenCCU()) << "Could not parse statelist response:" << xml.errorString(); + setGatewayConnected(thing, false); + } + }); +} + +void IntegrationPluginOpenCCU::getChannels(Thing *floorHeatinController) +{ + qCDebug(dcOpenCCU()) << "Sync channels from" << floorHeatinController; + + Thing *gateway = gatewayThing(floorHeatinController); + if (!gateway) { + setFloorHeatingControllerConnected(floorHeatinController, false); + return; + } + + QUrl url = buildUrl(gateway, "statelist.cgi"); + qCDebug(dcOpenCCU()) << "GET statelist.cgi"; + QNetworkReply *reply = hardwareManager()->networkManager()->get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + connect(reply, &QNetworkReply::sslErrors, this, &IntegrationPluginOpenCCU::onSslError); + connect(reply, &QNetworkReply::finished, this, [this, floorHeatinController, gateway, reply](){ + if (reply->error() != QNetworkReply::NoError) { + qCDebug(dcOpenCCU()) << "Reply finished with error" << reply->errorString(); + setGatewayConnected(gateway, false); + setFloorHeatingControllerConnected(floorHeatinController, false); + return; + } + + QByteArray data = reply->readAll(); + //qCDebug(dcOpenCCU()) << "-->" << qUtf8Printable(data); + setGatewayConnected(gateway, true); + setFloorHeatingControllerConnected(floorHeatinController, true); + int floorHeatingControllerIseId = floorHeatinController->paramValue(floorHeatingControllerThingIseIdParamTypeId).toInt(); + + QXmlStreamReader xml(data); + while(!xml.atEnd() && !xml.hasError()) { + xml.readNext(); + if (xml.name() == QString("device") && xml.isStartElement()) { + qCDebug(dcOpenCCU()) << "-->" << xml.name() << xml.attributes().value("name").toString(); + foreach (const QXmlStreamAttribute &attribute, xml.attributes()) { + qCDebug(dcOpenCCU()) << " " << attribute.name() << attribute.value(); + } + + int iseId = xml.attributes().value("ise_id").toInt(); + if (iseId == floorHeatingControllerIseId) { + + int currentChannelIndex = 0; + ThingDescriptors desciptors; + + // Read all channels of this device and verify if there is a valve connected and if we have already set up device for it + while (!xml.atEnd() && !xml.hasError() && !(xml.name() == QString("device") && xml.isEndElement())) { + xml.readNext(); + if (xml.name() == QString("channel") && xml.isStartElement()) { + + // Channel 0 = Maintainance channel + currentChannelIndex = xml.attributes().value("index").toInt(); + + // qCDebug(dcOpenCCU()) << "-->" << xml->name() << xml->attributes().value("name").toString(); + // foreach (const QXmlStreamAttribute &attribute, xml->attributes()) { + // qCDebug(dcOpenCCU()) << " " << attribute.name() << attribute.value(); + // } + } + + if (xml.name() == QString("datapoint") && xml.isStartElement()) { + qCDebug(dcOpenCCU()) << " -->" << xml.name() << xml.attributes().value("name").toString(); + foreach (const QXmlStreamAttribute &attribute, xml.attributes()) { + qCDebug(dcOpenCCU()) << " " << attribute.name() << attribute.value(); + } + + + QString type = xml.attributes().value("type").toString(); + int iseId = xml.attributes().value("ise_id").toInt(); + + if (currentChannelIndex == 0) { + if (type == "RSSI_DEVICE") { + int signalStrength = getSignalStrenthFromRssi(xml.attributes().value("value").toInt()); + floorHeatinController->setStateValue(floorHeatingControllerSignalStrengthStateTypeId, signalStrength); + qCDebug(dcOpenCCU()) << "Floor heating controller" << floorHeatinController->name() << iseId << "signal strength:" << signalStrength << "%"; + } else if (type == "UNREACH") { + floorHeatinController->setStateValue(floorHeatingControllerReachableStateTypeId, xml.attributes().value("value").toString() == "false"); + setFloorHeatingControllerConnected(floorHeatinController, floorHeatinController->stateValue(floorHeatingControllerReachableStateTypeId).toBool()); + qCDebug(dcOpenCCU()) << "Floor heating controller" << floorHeatinController->name() << iseId << "unreachable:" << xml.attributes().value("value").toString(); + } + } else { + // Actual valve channels, if a valve is connected, the value is not empty + if (type == "LEVEL") { + if (xml.attributes().value("value").toString().isEmpty()) { + qCDebug(dcOpenCCU()) << "Floorheating channel" << currentChannelIndex << "has no valve connected"; + } else { + + QString thingName = floorHeatinController->name() + " Channel " + QString::number(currentChannelIndex); + if (myThings().filterByParam(floorHeatingValveThingIseIdParamTypeId, iseId).isEmpty()) { + qCDebug(dcOpenCCU()) << "Adding new floor heating valve" << iseId; + ThingDescriptor desciptor(floorHeatingValveThingClassId, thingName, QString(), floorHeatinController ->id()); + ParamList params; + params.append(Param(floorHeatingValveThingIseIdParamTypeId, iseId)); + desciptor.setParams(params); + desciptors.append(desciptor); + } else { + qCDebug(dcOpenCCU()) << "Thing for" << thingName << "already created."; + } + } + } + } + } + } + + if (!desciptors.isEmpty()) { + emit autoThingsAppeared(desciptors); + } + } + } + } + + if (xml.hasError()) { + qCWarning(dcOpenCCU()) << "Could not parse statelist response:" << xml.errorString(); + setGatewayConnected(gateway, false); + setFloorHeatingControllerConnected(floorHeatinController, false); + } + }); + +} + + +void IntegrationPluginOpenCCU::processThingStateList(QXmlStreamReader *xml, Thing *thing) +{ + int currentChannelIndex = 0; + int currentLevelIseId = -1; + int currentLevelStatus = -1; + int currentValveState = -1; + QString currentLevelValue; + + if (thing->thingClassId() == floorHeatingControllerThingClassId) { + QString unreach = xml->attributes().value("unreach").toString(); + if (!unreach.isEmpty()) { + bool reachable = unreach == "false"; + thing->setStateValue(floorHeatingControllerReachableStateTypeId, reachable); + setFloorHeatingControllerConnected(thing, reachable); + } + } + + while (!xml->atEnd() && !xml->hasError() && !(xml->name() == QString("device") && xml->isEndElement())) { + xml->readNext(); + if (xml->name() == QString("channel") && xml->isStartElement()) { + // qCDebug(dcOpenCCU()) << "-->" << xml->name() << xml->attributes().value("name").toString(); + // foreach (const QXmlStreamAttribute &attribute, xml->attributes()) { + // qCDebug(dcOpenCCU()) << " " << attribute.name() << attribute.value(); + // } + + currentChannelIndex = xml->attributes().value("index").toInt(); + currentLevelIseId = -1; + currentLevelStatus = -1; + currentValveState = -1; + currentLevelValue.clear(); + + if (thing->thingClassId() == thermostatThingClassId) { + // Channel 0 = Maintainance channel + m_thermostats[thing].channels[currentChannelIndex].index = currentChannelIndex; + m_thermostats[thing].channels[currentChannelIndex].iseId = xml->attributes().value("type").toInt(); + } + } + + if (xml->name() == QString("channel") && xml->isEndElement()) { + if (thing->thingClassId() == floorHeatingControllerThingClassId && currentChannelIndex > 0 && currentLevelIseId >= 0) { + updateFloorHeatingValve(thing, currentLevelIseId, currentLevelValue, currentLevelStatus, currentValveState); + } + } + + if (xml->name() == QString("datapoint") && xml->isStartElement()) { + // qCDebug(dcOpenCCU()) << " -->" << xml->name() << xml->attributes().value("name").toString(); + // foreach (const QXmlStreamAttribute &attribute, xml->attributes()) { + // qCDebug(dcOpenCCU()) << " " << attribute.name() << attribute.value(); + // } + + QString type = xml->attributes().value("type").toString(); + int iseId = xml->attributes().value("ise_id").toInt(); + if (thing->thingClassId() == thermostatThingClassId) { + if (type == "LOW_BAT") { + thing->setStateValue(thermostatBatteryCriticalStateTypeId, xml->attributes().value("value").toString() == "true"); + } else if (type == "RSSI_DEVICE") { + thing->setStateValue(thermostatSignalStrengthStateTypeId, getSignalStrenthFromRssi(xml->attributes().value("value").toInt())); + } else if (type == "UNREACH") { + thing->setStateValue(thermostatReachableStateTypeId, xml->attributes().value("value").toString() == "false"); + thing->setStateValue(thermostatConnectedStateTypeId, thing->stateValue(thermostatReachableStateTypeId)); + qCDebug(dcOpenCCU()) << "Thermostat" << thing->name() << iseId << "unreachable:" << xml->attributes().value("value").toString(); + } else if (type == "ACTUAL_TEMPERATURE") { + thing->setStateValue(thermostatTemperatureStateTypeId, xml->attributes().value("value").toDouble()); + qCDebug(dcOpenCCU()) << "Thermostat" << thing->name() << iseId << "temperature:" << thing->stateValue(thermostatTemperatureStateTypeId).toDouble(); + } else if (type == "SET_POINT_TEMPERATURE") { + thing->setStateValue(thermostatTargetTemperatureStateTypeId, xml->attributes().value("value").toDouble()); + m_thermostats[thing].channels[currentChannelIndex].targetTemperatureId = xml->attributes().value("ise_id").toInt(); + qCDebug(dcOpenCCU()) << "Thermostat" << thing->name() << iseId << "target temperature:" << thing->stateValue(thermostatTargetTemperatureStateTypeId).toDouble(); + } else if (type == "WINDOW_STATE") { + thing->setStateValue(thermostatWindowOpenDetectedStateTypeId, xml->attributes().value("value").toInt() > 1); + qCDebug(dcOpenCCU()) << "Thermostat" << thing->name() << iseId << "window state:" << thing->stateValue(thermostatWindowOpenDetectedStateTypeId).toBool(); + } else if (type == "HUMIDITY") { + thing->setStateValue(thermostatHumidityStateTypeId, xml->attributes().value("value").toDouble()); + qCDebug(dcOpenCCU()) << "Thermostat" << thing->name() << iseId << "humidity:" << thing->stateValue(thermostatHumidityStateTypeId).toInt(); + } else if (type == "SET_POINT_MODE") { + m_thermostats[thing].channels[currentChannelIndex].modeId = iseId; + int setPointMode = xml->attributes().value("value").toInt(); + qCDebug(dcOpenCCU()) << "Thermostat" << thing->name() << iseId << "mode:" << setPointMode; + if (setPointMode == 0) { + thing->setStateValue(thermostatModeStateTypeId, "Auto"); + } else if (setPointMode == 1) { + thing->setStateValue(thermostatModeStateTypeId, "Manual"); + } + } else if (type == "CONTROL_MODE") { + // Some device can be controlled using this data point instead of SET_POINT_MODE. + // If this data point exists, we update manual and auto mode using this iseId, not the + // iseId of the SET_POINT_MODE + m_thermostats[thing].channels[currentChannelIndex].controlModeId = iseId; + } + } else if (thing->thingClassId() == floorHeatingControllerThingClassId) { + if (type == "RSSI_DEVICE") { + int signalStrength = getSignalStrenthFromRssi(xml->attributes().value("value").toInt()); + thing->setStateValue(floorHeatingControllerSignalStrengthStateTypeId, signalStrength); + } else if (type == "UNREACH") { + bool isConnected = xml->attributes().value("value").toString() == "false"; + thing->setStateValue(floorHeatingControllerReachableStateTypeId, isConnected); + setFloorHeatingControllerConnected(thing, isConnected); + } else if (type == "LEVEL") { + currentLevelIseId = iseId; + currentLevelValue = xml->attributes().value("value").toString(); + } else if (type == "LEVEL_STATUS") { + currentLevelStatus = xml->attributes().value("value").toInt(); + } else if (type == "VALVE_STATE") { + currentValveState = xml->attributes().value("value").toInt(); + } + } + } + } +} + +Thing *IntegrationPluginOpenCCU::gatewayThing(Thing *thing) +{ + Thing *currentThing = thing; + while (currentThing) { + if (currentThing->thingClassId() == openCCUThingClassId) { + return currentThing; + } + + currentThing = myThings().findById(currentThing->parentId()); + } + + return nullptr; +} + +void IntegrationPluginOpenCCU::setGatewayConnected(Thing *gatewayThing, bool connected) +{ + if (!gatewayThing) + return; + + gatewayThing->setStateValue(openCCUConnectedStateTypeId, connected); + if (connected) + return; + + foreach (Thing *childThing, myThings().filterByParentId(gatewayThing->id())) { + if (childThing->thingClassId() == thermostatThingClassId) { + childThing->setStateValue(thermostatConnectedStateTypeId, false); + childThing->setStateValue(thermostatReachableStateTypeId, false); + } else if (childThing->thingClassId() == floorHeatingControllerThingClassId) { + setFloorHeatingControllerConnected(childThing, false); + } + } +} + +void IntegrationPluginOpenCCU::setFloorHeatingControllerConnected(Thing *controllerThing, bool connected) +{ + controllerThing->setStateValue(floorHeatingControllerConnectedStateTypeId, connected); + if (connected) + return; + + controllerThing->setStateValue(floorHeatingControllerReachableStateTypeId, false); + + foreach (Thing *childThing, myThings().filterByParentId(controllerThing->id())) { + if (childThing->thingClassId() == floorHeatingValveThingClassId) { + childThing->setStateValue(floorHeatingValveConnectedStateTypeId, false); + } + } +} + +void IntegrationPluginOpenCCU::updateFloorHeatingValve(Thing *controllerThing, int levelIseId, const QString &levelValue, int levelStatus, int valveState) +{ + Thing *valveThing = nullptr; + foreach (Thing *childThing, myThings().filterByParentId(controllerThing->id())) { + if (childThing->thingClassId() == floorHeatingValveThingClassId + && childThing->paramValue(floorHeatingValveThingIseIdParamTypeId).toInt() == levelIseId) { + valveThing = childThing; + break; + } + } + + if (!valveThing) + return; + + bool controllerConnected = controllerThing->stateValue(floorHeatingControllerConnectedStateTypeId).toBool(); + bool valueValid = !levelValue.isEmpty() && levelStatus == 0 && valveState != 0; + valveThing->setStateValue(floorHeatingValveConnectedStateTypeId, controllerConnected && valueValid); + + if (!valueValid) + return; + + int percentage = qBound(0, qRound(levelValue.toDouble() * 100), 100); + valveThing->setStateValue(floorHeatingValvePercentageStateTypeId, percentage); + qCDebug(dcOpenCCU()) << "Valve position" << valveThing->name() << percentage << "%"; +} + +int IntegrationPluginOpenCCU::getSignalStrenthFromRssi(int rssi) +{ + int signalStrength = 0; + if (rssi > -65) { + signalStrength = 100; + } else if (rssi <= -65 && rssi >= -75) { + signalStrength = 75; + } else if (rssi <= -75 && rssi >= -85) { + signalStrength = 50; + } else if (rssi <= -85) { + signalStrength = 25; + } + return signalStrength; +} + +QUrl IntegrationPluginOpenCCU::buildUrl(Thing *thing, const QString &method, const QUrlQuery &query) +{ + QString token = thing->paramValue(openCCUThingTokenParamTypeId).toString(); + QString host = thing->paramValue(openCCUThingAddressParamTypeId).toString(); + if (host.isEmpty()) { + host = thing->paramValue(openCCUThingHostNameParamTypeId).toString(); + } + + QUrlQuery newQuery = query; + newQuery.addQueryItem("sid", token); + + bool usingSsl = m_usingSsl.value(thing, true); + + QUrl url; + url.setScheme(usingSsl ? "https" : "http"); + url.setHost(host); + url.setPath("/addons/xmlapi/" + method); + url.setQuery(newQuery); + return url; +} + +void IntegrationPluginOpenCCU::onSslError(const QList &errors) +{ + QNetworkReply *reply = qobject_cast(sender()); + if (errors.count() == 1 && errors.first().error() == QSslError::SelfSignedCertificate) { + reply->ignoreSslErrors(); + } else { + qCWarning(dcOpenCCU()) << "SSL error:" << errors.first().error() << errors.first().errorString(); + reply->abort(); + } +} diff --git a/openccu/integrationpluginopenccu.h b/openccu/integrationpluginopenccu.h new file mode 100644 index 00000000..3d6ff018 --- /dev/null +++ b/openccu/integrationpluginopenccu.h @@ -0,0 +1,101 @@ +// 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-plugins. +* +* nymea-plugins 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-plugins 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-plugins. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef INTEGRATIONPLUGINOPENCCU_H +#define INTEGRATIONPLUGINOPENCCU_H + +#include +#include + +#include +#include +#include + +#include "extern-plugininfo.h" + +class IntegrationPluginOpenCCU : public IntegrationPlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginopenccu.json") + Q_INTERFACES(IntegrationPlugin) + +public: + explicit IntegrationPluginOpenCCU(); + + void init() override; + void discoverThings(ThingDiscoveryInfo *info) override; + void setupThing(ThingSetupInfo *info) override; + void postSetupThing(Thing *thing) override; + void thingRemoved(Thing *thing) override; + void executeAction(ThingActionInfo *info) override; + +private: + struct DeviceInfo { + int iseId = -1; + QString name; + QString interface; + QString deviceType; + QString serialNumber; + }; + + struct ThermostatChannel { + int index = 1; + int iseId = -1; + int targetTemperatureId = -1; + int modeId = -1; + int controlModeId = -1; + }; + + struct Thermostat { + QHash channels; + }; + + PluginTimer *m_pluginTimer = nullptr; + QHash m_thermostats; + QHash m_usingSsl; + + void getDevices(Thing *thing); + void getDeviceTypeList(Thing *thing); + void getState(Thing *thing); + void getStateList(Thing *thing); + void getChannels(Thing *floorHeatinController); + + void processThingStateList(QXmlStreamReader *xml, Thing *thing); + + Thing *gatewayThing(Thing *thing); + void setGatewayConnected(Thing *gatewayThing, bool connected); + void setFloorHeatingControllerConnected(Thing *controllerThing, bool connected); + void updateFloorHeatingValve(Thing *controllerThing, int levelIseId, const QString &levelValue, int levelStatus, int valveState); + + int getSignalStrenthFromRssi(int rssi); + + QUrl buildUrl(Thing *thing, const QString &method, const QUrlQuery &query = QUrlQuery()); + +private slots: + void onSslError(const QList &errors); + +}; + +#endif // INTEGRATIONPLUGINOPENCCU_H diff --git a/openccu/integrationpluginopenccu.json b/openccu/integrationpluginopenccu.json new file mode 100644 index 00000000..2ef30cc2 --- /dev/null +++ b/openccu/integrationpluginopenccu.json @@ -0,0 +1,289 @@ +{ + "name": "OpenCCU", + "displayName": "OpenCCU", + "id": "1debe558-38dc-4f4f-803f-e8fdd478803c", + "vendors": [ + { + "name": "openCCU", + "displayName": "OpenUCC", + "id": "aebdd54c-0a97-4828-b2a1-806d6c8aef75", + "thingClasses": [ + { + "id": "9fb7b7f0-0a92-40ad-a08e-dde9d88af09f", + "name": "openCCU", + "displayName": "OpenCCU Gateway", + "interfaces": ["gateway", "networkdevice"], + "createMethods": ["discovery", "user"], + "paramTypes": [ + { + "id": "fc016355-4da1-4b24-830e-8ee7eb4406db", + "name": "address", + "displayName": "Host address", + "type": "QString", + "inputType": "IPv4Address", + "defaultValue": "" + }, + { + "id": "71f51cae-bbc1-41fd-b992-87731e21c887", + "name": "hostName", + "displayName": "Host name", + "type": "QString", + "inputType": "TextLine", + "defaultValue": "" + }, + { + "id": "109b9ae2-105f-4ba6-9cb0-f89409cb7aaf", + "name":"macAddress", + "displayName": "MAC address", + "type": "QString", + "inputType": "MacAddress", + "defaultValue": "" + }, + { + "id": "87c02788-0c78-4c9d-a3b8-a0646c9a0d66", + "name":"token", + "displayName": "Token", + "type": "QString", + "defaultValue": "" + } + ], + "stateTypes": [ + { + "id": "3fbebc9b-01ed-4431-94d7-e741b6362de6", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false + } + ] + }, + { + "id": "dbcd9d61-5348-4762-b84f-adc48e0ba2ee", + "name": "thermostat", + "displayName": "Thermostat", + "interfaces": ["thermostat", "heating", "temperaturesensor", "humiditysensor", "battery", "wirelessconnectable"], + "createMethods": ["auto"], + "paramTypes": [ + { + "id": "2b628a08-96e0-47d3-a094-1f5b018c8091", + "name": "serialNumber", + "displayName": "Serial number", + "type": "QString", + "inputType": "TextLine", + "readOnly": true + }, + { + "id": "596108b0-2f77-4bb4-b902-b3f2029ca68f", + "name": "type", + "displayName": "Type", + "type": "QString", + "inputType": "TextLine", + "readOnly": true + }, + { + "id": "6876ce23-86e7-4518-bc52-0d04b7be0b40", + "name": "iseId", + "displayName": "ID", + "type": "QString", + "inputType": "TextLine", + "readOnly": true + } + ], + "stateTypes": [ + { + "id": "46f52c89-7797-43db-99db-0a985e1d4c21", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "defaultValue": false + }, + { + "id": "e848e60e-975a-435f-8aa2-c7c250d441fc", + "name": "signalStrength", + "displayName": "Signal strength", + "type": "uint", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "defaultValue": 0, + "cached": false + }, + { + "id": "97fdbb35-9517-4245-803c-b08a556161f5", + "name": "reachable", + "displayName": "Reachable", + "type": "bool", + "defaultValue": false + }, + { + "id": "c2c89939-3b40-466e-8d67-e10f1ffc23b6", + "name": "batteryCritical", + "displayName": "Battery critical", + "type": "bool", + "defaultValue": false + }, + { + "id": "dba3188d-2231-4269-81bd-561681aba8dd", + "name": "mode", + "displayName": "Mode", + "displayNameAction": "Set mode", + "type": "QString", + "defaultValue": "Auto", + "writable": true, + "possibleValues": [ + "Auto", + "Manual" + ] + }, + { + "id": "2b22147b-beb6-4e42-8343-94fbfa52e09a", + "name": "power", + "displayName": "Power", + "displayNameAction": "Set power", + "type": "bool", + "writable": true, + "defaultValue": false + }, + { + "id": "ae425a76-674b-4a20-99aa-dbdb5c7f495e", + "name": "windowOpenDetected", + "displayName": "Window open detected", + "type": "bool", + "defaultValue": false + }, + { + "id": "18c0de57-df78-4d24-9d30-8756df8615c1", + "name": "temperature", + "displayName": "Temperature", + "unit": "DegreeCelsius", + "type": "double", + "defaultValue": 0 + }, + { + "id": "7be037b7-ba71-4983-b927-0bfbbaa03225", + "name": "targetTemperature", + "displayName": "Target temperature", + "displayNameAction": "Set target temperature", + "unit": "DegreeCelsius", + "type": "double", + "defaultValue": 0, + "minValue": 5, + "maxValue": 25, + "writable": true + }, + { + "id": "31d9bb84-1031-45d9-8b76-308210a8b248", + "name": "humidity", + "displayName": "Humidity", + "unit": "Percentage", + "type": "double", + "defaultValue": 0, + "minValue": 0, + "maxValue": 100 + } + ] + }, + { + "id": "e7c30062-9c74-41de-b4ab-e0ac5863650b", + "name": "floorHeatingController", + "displayName": "Floorheating controller", + "interfaces": ["wirelessconnectable"], + "createMethods": ["auto"], + "paramTypes": [ + { + "id": "1487caff-66d6-425d-9c60-4de022836673", + "name": "serialNumber", + "displayName": "Serial number", + "type": "QString", + "inputType": "TextLine", + "readOnly": true + }, + { + "id": "535c500a-65f7-406c-a05c-6c51f5822bd0", + "name": "type", + "displayName": "Type", + "type": "QString", + "inputType": "TextLine", + "readOnly": true + }, + { + "id": "b81f1849-0acf-4721-b4b7-66c46142fd5c", + "name": "iseId", + "displayName": "ID", + "type": "QString", + "inputType": "TextLine", + "readOnly": true + } + ], + "stateTypes": [ + { + "id": "162c3ba2-4884-4974-a473-89494862317d", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "defaultValue": false + }, + { + "id": "be5a113d-80b1-4ec2-8394-ad5fa089d81f", + "name": "signalStrength", + "displayName": "Signal strength", + "type": "uint", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "defaultValue": 0, + "cached": false + }, + { + "id": "198681be-8514-4590-a06a-1ef97876cf2a", + "name": "reachable", + "displayName": "Reachable", + "type": "bool", + "defaultValue": false + } + ] + }, + { + "id": "5d80574b-0ce0-4fbe-9985-631c854208e4", + "name": "floorHeatingValve", + "displayName": "Floor heating valve", + "interfaces": ["valve", "wirelessconnectable"], + "createMethods": ["auto"], + "paramTypes": [ + { + "id": "b3fd0a41-f4d3-47de-9db7-eb8915e0f6a5", + "name": "iseId", + "displayName": "ID", + "type": "QString", + "inputType": "TextLine", + "readOnly": true + } + ], + "stateTypes": [ + { + "id": "669940b5-0582-4f0f-a250-8eb826c46ac1", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "defaultValue": false + }, + { + "id": "834401ba-865a-48c3-882d-2cf1317c0708", + "name": "percentage", + "displayName": "Valve position", + "displayNameAction": "Set valve position", + "type": "int", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "defaultValue": 0, + "writable": true, + "cached": true + } + ] + } + ] + } + ] +} diff --git a/openccu/meta.json b/openccu/meta.json new file mode 100644 index 00000000..ef3f6cfc --- /dev/null +++ b/openccu/meta.json @@ -0,0 +1,13 @@ +{ + "title": "OpenCCU", + "tagline": "Connect to your OpenCCU gateway (RaspberryPiMatic) and controll your HomematicIP devices.", + "icon": "openccu.png", + "stability": "community", + "offline": true, + "technologies": [ + "network" + ], + "categories": [ + "heating" + ] +} diff --git a/openccu/openccu.png b/openccu/openccu.png new file mode 100644 index 00000000..ec5235b9 Binary files /dev/null and b/openccu/openccu.png differ diff --git a/openccu/openccu.pro b/openccu/openccu.pro new file mode 100644 index 00000000..e2db3eb7 --- /dev/null +++ b/openccu/openccu.pro @@ -0,0 +1,11 @@ +include(../plugins.pri) + +QT += network + +SOURCES += \ + integrationpluginopenccu.cpp + +HEADERS += \ + integrationpluginopenccu.h + +