From f20c44c651d33ba9d3cfc20e2897c966893c6a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Mon, 17 Nov 2025 11:21:46 +0100 Subject: [PATCH] New plugin: Add OpenCCU support --- debian-qt5/control | 11 + debian-qt5/nymea-plugin-openccu.install.in | 1 + debian-qt6/control | 11 + debian-qt6/nymea-plugin-openccu.install.in | 1 + nymea-plugins.pro | 3 +- openccu/integrationpluginopenccu.cpp | 770 +++++++++++++++++++++ openccu/integrationpluginopenccu.h | 101 +++ openccu/integrationpluginopenccu.json | 289 ++++++++ openccu/meta.json | 13 + openccu/openccu.png | Bin 0 -> 11088 bytes openccu/openccu.pro | 11 + 11 files changed, 1210 insertions(+), 1 deletion(-) create mode 100644 debian-qt5/nymea-plugin-openccu.install.in create mode 120000 debian-qt6/nymea-plugin-openccu.install.in create mode 100644 openccu/integrationpluginopenccu.cpp create mode 100644 openccu/integrationpluginopenccu.h create mode 100644 openccu/integrationpluginopenccu.json create mode 100644 openccu/meta.json create mode 100644 openccu/openccu.png create mode 100644 openccu/openccu.pro 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 0000000000000000000000000000000000000000..ec5235b92d51f5dbfe9a0c43f46e7b86cf924ee2 GIT binary patch literal 11088 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

(lZfS)ukPSL-_EbtJc@zX=tfmXUz=c% zCHfM3(pJ~2N%$xry)b^C$@_?wZ~;&K&a3!mg(!V z8*xj9=u%H|yWIK9-f5l)q@uP&FEfEiR*8Ka;`v(BhGDCP`)7RhBYlNN+nuP&5>?f% z`6$yv1Gd%D^!K3EuPS9tD|gja8N_O8%PX@^KRl%Db|WgrSG;U-DWF@mb2pO5G7P2# zla+;ZwW_ebdhuK$0|0=-Xkb=i&y+EKu2~w~S=W2PaWI?qm4e~DHtC$sqH+2%G4}$T z?dsQ1V|6aAV`Bh#G^2CTDn1E%+Y$Dp%=lRZJfXixl5>-OfnhLMk)9#|!NTq!iL?5e$i? zZk1CVq+j%ukik3KXXG(1=^2O}ajV@p3aS>*&8S8lA4#UnM)%WZIklBUHYJzNvEDZH z+)G^(q~Y-Z(6No|vBzUTC;Vy&nQao1U^?nO0>$u)=Dtyq-{n$3;174SBO5{x?{e^K> zcGy2B(5Ivxe)%@3bohf$Ysnk9#Q~@FX@{c?l=My?cEivKUH=(R_oU8v%W8r5)MhKs z_dgIpburEHD2+mhdMvPUZNGjI!ZBzr>{W0qk@LzcW3%GAdUK4&FYUdJlewpQm~pB$haYm#*U?io6knG)!i zA?Dh7WyYA*euN*5shH5!4ZnXvOHYt6wM+lt07?KwYTJ<-M=rQ@FdPGSsVxgO!^or&LM*@)QdY8nqB9FeFL|kEiWzxNW%2ulP=URhUvVcQ!{g*tX?9IVle#j zR_0?dRG>Q%VMuLK{(AX!T|Vj3S+M*qQuCh>LGN*O9;E9A8j8MwU#Oy3zcdXjip4hAG9QDd{9j zyhW>zS+zNN7Vt-VwgN2vQx3LE#ZwjCqa0e#_m_;;nP7PF2%`^A8W~srB~k~7;rCxd zq0qX3unjakFmqA%#EYOyyFikEA>rxw^^Blq-=6~kJWKBL*+VFP+1*#5zY7%o=VeUu z4LLK)kLt36o(v06M_qXqg&h@M-oXoMDOtT!yXa;l1K!ozJW7P)W$#6sBRF2Rq^}b# z1u#98{UdhHEY&TsIXGT%`eLH_82==Yj`H%rB3uI^{Jl0lXYiOF0^yM6C7N;?-~yJP}?;1Xq+Qw9W6q` z!}^X)%fgwNj`GXUGd}9Mh32Y9ruj^CPDTe)2AO_ii13Hv?9`E))YV_~TGH)E^tYygz!XUuTMxAY+BJFP=77AFg5*cYr#l{?TJxn3WS9|&lK+rQ z2(_~BF!8~P)%(*8_C!PUa_yN-?o9FBSMpH=)8r2Y&1&J%y9~R0W7wTWJ{K-(m3M81 zg+d5aImJfDT`aVB8~e4OmrXtvGaItFIm||TZQI)0cfl)GJ6htY`zh@Wrf$6;x?ZQK zPYNcDQ4IEsAs-KFSpO+OlE;cxOg&q|rpsJ2hNBF`YE3Ym3$AARfE~Z;{>AVDNwik# z4Y&TW-<{m{u|P^mj;zPOL9G`xaiLUpjzhQYjTyGRd{)7*n++&7cQ{x^o1h3j@*l9; z8-YrLPVQkWiiisIOylm8XZ{g8T-st<_JKrkbhmV*;~Lth7-cPBC7aWpe~)wcVbz!u z-u@h({ytX1ZROAlhY@$3oVVH9#EJS*4oq-o3HoEOs8?opV0^ogg{gzHn>hhH*7b&~ z