diff --git a/nymea-plugins.pro b/nymea-plugins.pro index d08024766..689ebdab2 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -53,18 +53,19 @@ PLUGIN_DIRS = \ powerfox \ pushbullet \ pushnotifications \ - shelly \ - solarlog \ - systemmonitor \ reversessh \ senic \ serialportcommander \ sgready \ + shelly \ simpleheatpump \ sma \ + solarlog \ + solarman \ somfytahoma \ sonos \ sunposition \ + systemmonitor \ tado \ tasmota \ tcpcommander \ diff --git a/solarman/.test.py.swp b/solarman/.test.py.swp new file mode 100644 index 000000000..1b9a1fc31 Binary files /dev/null and b/solarman/.test.py.swp differ diff --git a/solarman/README.md b/solarman/README.md new file mode 100644 index 000000000..70e52287b --- /dev/null +++ b/solarman/README.md @@ -0,0 +1,18 @@ +# Bosswerk + +This integration plugin allows to add Bosswerk Micro Inverters to nymea. + +## Supported devices + +* MI-300 +* MI-600 + +## Requirements + +The solar inverter needs to be connected to the same network as nymea. Once powered on, the +inverter will open a wireless hotspot named AP_XXXXXXXXXX where XXXXXXXXXX is the serial number +written on the casing. The default password for this WiFi is 12345678. After connecting, +open [http://10.10.100.254](http://10.10.100.254) with the browser and use the web interface +on the inverter to connect it to a WiFi with nymea in it. + +Once done so, set up the inverter in nymea as any other thing. diff --git a/solarman/bosswerk.png b/solarman/bosswerk.png new file mode 100644 index 000000000..e93b8e4e4 Binary files /dev/null and b/solarman/bosswerk.png differ diff --git a/solarman/deye.yaml b/solarman/deye.yaml new file mode 100644 index 000000000..af1ac3df0 --- /dev/null +++ b/solarman/deye.yaml @@ -0,0 +1,213 @@ + +requests: + - start: 0x0001 + end: 0x0070 + mb_functioncode: 0x03 + +parameters: + - group: solar + items: + - name: "PV1 Voltage" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x006D] + icon: 'mdi:solar-power' + + - name: "PV2 Voltage" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x006F] + icon: 'mdi:solar-power' + + - name: "PV1 Current" + class: "current" + uom: "A" + scale: 0.1 + rule: 1 + registers: [0x006E] + icon: 'mdi:solar-power' + + - name: "PV2 Current" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.1 + rule: 1 + registers: [0x0070] + icon: 'mdi:solar-power' + + - name: "Daily Production" + class: "energy" + state_class: "total" + uom: "kWh" + scale: 0.1 + rule: 1 + registers: [0x003C] + icon: 'mdi:solar-power' + + - name: "Total Production" + class: "energy" + state_class: "total_increasing" + uom: "kWh" + scale: 0.1 + rule: 3 + registers: [0x003F,0x0040] + icon: 'mdi:solar-power' + + + + - group: Grid + items: + - name: "Grid Voltage L-L(A)" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x0049] + icon: 'mdi:transmission-tower' + + - name: "Grid Voltage L-L(B))" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x004A] + icon: 'mdi:transmission-tower' + + - name: "Grid Voltage L-L(C)" + class: "voltage" + state_class: "measurement" + uom: "V" + scale: 0.1 + rule: 1 + registers: [0x004B] + icon: 'mdi:transmission-tower' + + - name: "Grid Current A" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.1 + rule: 2 + registers: [0x004C] + icon: 'mdi:home-lightning-bolt' + + - name: "Grid Current B" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.1 + rule: 2 + registers: [0x004D] + icon: 'mdi:home-lightning-bolt' + + - name: "Grid Current C" + class: "current" + state_class: "measurement" + uom: "A" + scale: 0.1 + rule: 2 + registers: [0x004E] + icon: 'mdi:home-lightning-bolt' + + - name: "Grid Frequency" + class: "current" + state_class: "measurement" + uom: "Hz" + scale: 0.01 + rule: 1 + registers: [0x004F] + icon: 'mdi:home-lightning-bolt' + + - group: Inverter + items: + - name: "Running Status" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 1 + registers: [0x003B] + isstr: true + lookup: + - key: 0 + value: "Stand-by" + - key: 1 + value: "Self-checking" + - key: 2 + value: "Normal" + - key: 3 + value: "FAULT" + icon: 'mdi:home-lightning-bolt' + + - name: "Total Ouput AC Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 0.1 + rule: 3 + registers: [0x0050,0x0051] + icon: 'mdi:home-lightning-bolt' + + - name: "Input Active Power" + class: "power" + state_class: "measurement" + uom: "W" + scale: 0.1 + rule: 3 + registers: [0x0052, 0x0053] + icon: 'mdi:home-lightning-bolt' + + - name: "Output Apparent Power" + class: "apparent_power" + state_class: "measurement" + uom: "VA" + scale: 0.1 + rule: 3 + registers: [0x0054, 0x0055] + icon: 'mdi:home-lightning-bolt' + + - name: "Output Active Power" + class: "energy" + state_class: "measurement" + uom: "kWh" + scale: 0.1 + rule: 3 + registers: [0x0056, 0x0057] + icon: 'mdi:home-lightning-bolt' + + - name: "Output Reactive Power" + class: "reactive_power" + state_class: "measurement" + uom: "VAR" + rule: 3 + scale: 0.1 + registers: [0x0058, 0x0059] + icon: 'mdi:home-lightning-bolt' + + - name: "temp" + class: "temperature" + state_class: "measurement" + uom: "°C" + scale: 0.01 + rule: 1 + offset: 1000 + registers: [0x005A] + icon: 'mdi:battery' + + - name: "Inverter ID" + class: "" + state_class: "" + uom: "" + scale: 1 + rule: 5 + registers: [0x0003,0x0004,0x0005,0x0006,0x0007] + isstr: true diff --git a/solarman/discover.py b/solarman/discover.py new file mode 100644 index 000000000..8a8b0f5ed --- /dev/null +++ b/solarman/discover.py @@ -0,0 +1,21 @@ +import socket + +request = "WIFIKIT-214028-READ" +address = ("", 48899) +with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.settimeout(1.0) + + sock.sendto(request.encode(), address) + + while True: + try: + data = sock.recv(1024) + a = data.decode().split(',') + if 3 == len(a): + print("ip %s" % a[0]) + print("mac %s" % a[1]) + print("serial %s" % int(a[2])) + except socket.timeout: + break diff --git a/solarman/integrationpluginsolarman.cpp b/solarman/integrationpluginsolarman.cpp new file mode 100644 index 000000000..739cb9b9e --- /dev/null +++ b/solarman/integrationpluginsolarman.cpp @@ -0,0 +1,213 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2022, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + + +#include "integrationpluginsolarman.h" +#include "plugininfo.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "solarmandiscovery.h" +#include "solarmanmodbus.h" +#include "solarmanmodbusreply.h" + +IntegrationPluginSolarman::IntegrationPluginSolarman() +{ + QFile registerMappings(":/registermappings.json"); + registerMappings.open(QFile::ReadOnly); + QJsonDocument jsonDoc = QJsonDocument::fromJson(registerMappings.readAll()); + m_registerMappings = jsonDoc.toVariant().toMap(); +} + +IntegrationPluginSolarman::~IntegrationPluginSolarman() +{ +} + +void IntegrationPluginSolarman::discoverThings(ThingDiscoveryInfo *info) +{ + SolarmanDiscovery *discovery = new SolarmanDiscovery(info); + connect(discovery, &SolarmanDiscovery::discoveryResults, info, [this, info](const QList &results) { + foreach (const SolarmanDiscovery::DiscoveryResult &result, results) { + ThingDescriptor descriptor(deyeStringThingClassId, "Deye String Inverter", "Serial: " + result.serial + ", IP: " + result.ip.toString()); + descriptor.setParams({{deyeStringThingSerialParamTypeId, result.serial}}); + + if (myThings().findByParams(descriptor.params())) { + descriptor.setThingId(myThings().findByParams(descriptor.params())->id()); + } + info->addThingDescriptor(descriptor); + } + info->finish(Thing::ThingErrorNoError); + }); + discovery->discover(); +} + +void IntegrationPluginSolarman::setupThing(ThingSetupInfo *info) +{ + Thing *thing = info->thing(); + + SolarmanDiscovery *monitor = m_monitors.value(thing); + if (!monitor) { + monitor = new SolarmanDiscovery(thing); + m_monitors.insert(thing, monitor); + } + + if (!monitor->monitor(thing->paramValue(deyeStringThingSerialParamTypeId).toString())) { + info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Unalbe to open network connection.")); + m_monitors.take(thing)->deleteLater(); + return; + } + + SolarmanModbus *modbus = new SolarmanModbus(thing); + m_connections.insert(thing, modbus); + + // For testing +// modbus->connectToHost(monitor->monitorResult().ip, 8899, monitor->monitorResult().serial); +// pollDevice(thing); + + connect(monitor, &SolarmanDiscovery::monitorStateChanged, thing, [modbus, monitor](const SolarmanDiscovery::DiscoveryResult &result) { + if (result.online && !modbus->isConnected()) { + modbus->connectToHost(monitor->monitorResult().ip, 8899, monitor->monitorResult().serial); + } + }); + + connect(modbus, &SolarmanModbus::connectedChanged, thing, [this, thing, monitor, modbus](bool connected){ + thing->setStateValue(deyeStringConnectedStateTypeId, connected); + if (connected) { + pollDevice(thing); + m_timers.value(thing)->reset(); + m_timers.value(thing)->start(); + } else { + m_timers.value(thing)->stop(); + if (monitor->monitorResult().online) { + qCDebug(dcSolarman()) << "Reconnecting to solarman inverter"; + modbus->connectToHost(monitor->monitorResult().ip, 8899, monitor->monitorResult().serial); + } + } + }); + + PluginTimer *timer = m_timers.take(thing); + if (!timer) { + // the inverter updates its internal values once per minute... + timer = hardwareManager()->pluginTimerManager()->registerTimer(21); + m_timers.insert(thing, timer); + + connect(timer, &PluginTimer::timeout, thing, [this, thing](){ + pollDevice(thing); + }); + } + + info->finish(Thing::ThingErrorNoError); +} + +void IntegrationPluginSolarman::thingRemoved(Thing *thing) +{ + m_monitors.remove(thing); + m_connections.remove(thing); + hardwareManager()->pluginTimerManager()->unregisterTimer(m_timers.take(thing)); +} + +void IntegrationPluginSolarman::pollDevice(Thing *thing) +{ + SolarmanModbus *modbus = m_connections.value(thing); + if (!modbus->isConnected()) { + qCDebug(dcSolarman()) << "Inverter offline. Not polling."; + return; + } + + QVariantMap mapping = m_registerMappings.value(thing->thingClass().name()).toMap(); + quint8 slaveId = mapping.value("slaveId").toUInt(); + quint16 startRegister = mapping.value("startRegister").toUInt(); + quint16 endRegister = mapping.value("endRegister").toUInt(); + QMetaEnum fcEnum = QMetaEnum::fromType(); + SolarmanModbus::FunctionCode functionCode = static_cast(fcEnum.keyToValue(mapping.value("functionCode").toByteArray())); + QVariantList registers = mapping.value("registers").toList(); + + + SolarmanModbusReply *reply = modbus->readRegisters(slaveId, startRegister, endRegister, functionCode); + qCDebug(dcSolarman()) << "Polling inverter" << thing->name() << "slaveID:" << slaveId << "Start:" << startRegister << "End:" << endRegister << "Request ID" << reply->requestId(); + connect(reply, &SolarmanModbusReply::finished, thing, [modbus, thing, reply, registers](bool success){ + if (!success) { + qCWarning(dcSolarman()) << "Polling failed..." << reply->requestId(); + modbus->disconnectFromHost(); + return; + } + qCDebug(dcSolarman()) << "modbus reply for" << thing->name() << reply->requestId(); + + foreach (const QVariant ®isterVariant, registers) { + QVariantMap definition = registerVariant.toMap(); + quint16 reg = definition.value("index").toUInt(); + quint8 length = definition.value("length").toUInt(); + QString type = definition.value("type").toString(); + QString stateName = definition.value("state").toString(); + QString registerName = definition.value("name").toString(); + int offset = definition.value("offset", 0).toInt(); + double scale = definition.value("scale", 1).toDouble(); + + + QVariant value; + if (type == "uint16") { + double val = reply->readRegister16(reg); + val += offset; + val *= scale; + value = val; + + } else if (type == "uint32") { + double val = reply->readRegister32(reg); + val += offset; + val *= scale; + value = val; + } else if (type == "string") { + value = reply->readRegisterString(reg, length); + } + qCDebug(dcSolarman()) << "Register" << reg << registerName << ":" << value; + if (!stateName.isEmpty()) { + thing->setStateValue(stateName, value); + } + } + + +// qCDebug(dcSolarman()) << "Today's production:" << reply->readRegister16(0x003C); +// qCDebug(dcSolarman()) << "Inverter status:" << reply->readRegister16(0x003B); +// qCDebug(dcSolarman()) << "Total production:" << reply->readRegister32(0x003F); + }); + +} diff --git a/solarman/integrationpluginsolarman.h b/solarman/integrationpluginsolarman.h new file mode 100644 index 000000000..e0248281e --- /dev/null +++ b/solarman/integrationpluginsolarman.h @@ -0,0 +1,73 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2022, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project 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 +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef INTEGRATIONPLUGINSOLARMAN_H +#define INTEGRATIONPLUGINSOLARMAN_H + +#include "integrations/integrationplugin.h" +#include "extern-plugininfo.h" + +class PluginTimer; +class SolarmanDiscovery; +class SolarmanModbus; + +class IntegrationPluginSolarman: public IntegrationPlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginsolarman.json") + Q_INTERFACES(IntegrationPlugin) + +public: + enum Method { + GET, + SET + }; + Q_ENUM(Method) + + explicit IntegrationPluginSolarman(); + ~IntegrationPluginSolarman(); + + void discoverThings(ThingDiscoveryInfo *info) override; + void setupThing(ThingSetupInfo *info) override; + void thingRemoved(Thing *thing) override; + +private slots: + void pollDevice(Thing *thing); + +private: + QHash m_monitors; + QHash m_connections; + QHash m_timers; + + QVariantMap m_registerMappings; +}; + +#endif // INTEGRATIONPLUGINSOLARMAN_H diff --git a/solarman/integrationpluginsolarman.json b/solarman/integrationpluginsolarman.json new file mode 100644 index 000000000..9ffc2d118 --- /dev/null +++ b/solarman/integrationpluginsolarman.json @@ -0,0 +1,82 @@ +{ + "name": "solarman", + "displayName": "Solarman", + "id": "9a37f9db-4b82-4bbc-b1ab-7eed5a1f4b70", + "vendors": [ + { + "name": "solarman", + "displayName": "Solarman", + "id": "d6bc0ecd-8cbe-4d5d-a606-0712c5f10978", + "thingClasses": [ + { + "id": "9712b0f0-45a3-4c27-a675-4ef6a0f1dc1e", + "name": "deyeString", + "displayName": "Deye String Inverter", + "createMethods": ["discovery"], + "interfaces": [ "solarinverter", "wirelessconnectable" ], + "paramTypes": [ + { + "id": "e23e8186-e3df-4dc1-87cf-59627e1d2a3d", + "name":"serial", + "displayName": "Serial number", + "type": "QString" + } + ], + "stateTypes": [ + { + "id": "7b110485-7b12-425b-b4c2-6a0a76db69cf", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "1303b3fa-072f-468e-949c-2e1474ce8f1e", + "name": "signalStrength", + "displayName": "Signal strength", + "displayNameEvent": "Signal strength changed", + "type": "uint", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "defaultValue": 0, + "filter": "adaptive", + "cached": false + }, + { + "id": "49e29f3b-d04b-4972-94e1-d5485405f716", + "name": "currentPower", + "displayName": "Current power consumption", + "displayNameEvent": "Current power consumption changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0, + "cached": false + }, + { + "id": "0118a424-6f93-4f37-a9da-17617b508a45", + "name": "totalEnergyProduced", + "displayName": "Total produced energy", + "displayNameEvent": "Total produced energy changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0 + }, + { + "id": "bc8ed7f6-6139-43e4-8d49-32ec5020d493", + "name": "temperature", + "displayName": "Inverter temperature", + "displayNameEvent": "Inverter temperature changed", + "type": "double", + "unit": "DegreeCelsius", + "defaultValue": 0 + } + + ] + } + ] + } + ] +} diff --git a/solarman/meta.json b/solarman/meta.json new file mode 100644 index 000000000..c6aa1fee6 --- /dev/null +++ b/solarman/meta.json @@ -0,0 +1,13 @@ +{ + "title": "Bosswerk", + "tagline": "Integrates Bosswerk solar inverters with nymea.", + "icon": "bosswerk.jpg", + "stability": "consumer", + "offline": true, + "technologies": [ + "network" + ], + "categories": [ + "energy" + ] +} diff --git a/solarman/nc b/solarman/nc new file mode 100644 index 000000000..4b5b270d4 --- /dev/null +++ b/solarman/nc @@ -0,0 +1 @@ +WIFIKIT-214028-READ 10.10.10.255 48899 diff --git a/solarman/registermappings.json b/solarman/registermappings.json new file mode 100644 index 000000000..e020c4a29 --- /dev/null +++ b/solarman/registermappings.json @@ -0,0 +1,97 @@ +{ + "deyeString": { + "slaveId": 1, + "startRegister": 1, + "endRegister": 116, + "functionCode": "ReadHoldingRegisters", + "registers": [ + { + "index": 3, + "type": "string", + "length": 5, + "name": "Inverter serial" + }, + { + "index": 16, + "type": "uint16", + "name": "Rated power", + "scale": 0.1 + }, + { + "index": 59, + "type": "uint16", + "name": "Inverter status" + }, + { + "index": 60, + "type": "uint16", + "name": "Daily production (Active)", + "scale": 0.1 + }, + { + "index": 63, + "type": "uint32", + "name": "Total Production (Active)", + "state": "totalEnergyProduced", + "scale": 0.1 + }, + { + "index": 73, + "type": "uint16", + "name": "AC Voltage 1", + "scale": 0.1 + }, + { + "index": 74, + "type": "uint16", + "name": "AC Voltage 2", + "scale": 0.1 + }, + { + "index": 75, + "type": "uint16", + "name": "AC Voltage 3", + "scale": 0.1 + }, + { + "index": 76, + "type": "uint16", + "name": "AC Current 1", + "scale": 0.1 + }, + { + "index": 77, + "type": "uint16", + "name": "AC Current 2", + "scale": 0.1 + }, + { + "index": 78, + "type": "uint16", + "name": "AC Current 3", + "scale": 0.1 + }, + { + "index": 79, + "type": "uint16", + "name": "AC Output Frequency", + "scale": 0.01 + }, + { + "index": 86, + "name": "Total AC Output Power (Active)", + "type": "uint32", + "state": "currentPower", + "scale": -0.1 + }, + { + "index": 90, + "name": "Temerpature", + "type": "uint16", + "state": "temperature", + "offset": -1000, + "scale": 0.01 + } + ] + } +} diff --git a/solarman/registermappings.qrc b/solarman/registermappings.qrc new file mode 100644 index 000000000..863a379d3 --- /dev/null +++ b/solarman/registermappings.qrc @@ -0,0 +1,5 @@ + + + registermappings.json + + diff --git a/solarman/solarman.pro b/solarman/solarman.pro new file mode 100644 index 000000000..4ea094c26 --- /dev/null +++ b/solarman/solarman.pro @@ -0,0 +1,21 @@ +include(../plugins.pri) + +QT += network + +SOURCES += \ + integrationpluginsolarman.cpp \ + solarmandiscovery.cpp \ + solarmanmodbus.cpp \ + solarmanmodbusreply.cpp + +HEADERS += \ + integrationpluginsolarman.h \ + solarmandiscovery.h \ + solarmanmodbus.h \ + solarmanmodbusreply.h + +DISTFILES += \ + registermappings.json + +RESOURCES += \ + registermappings.qrc diff --git a/solarman/solarmandiscovery.cpp b/solarman/solarmandiscovery.cpp new file mode 100644 index 000000000..7c26937f3 --- /dev/null +++ b/solarman/solarmandiscovery.cpp @@ -0,0 +1,152 @@ +#include "solarmandiscovery.h" + +#include +#include + +#include +Q_DECLARE_LOGGING_CATEGORY(dcSolarman) + +SolarmanDiscovery::SolarmanDiscovery(QObject *parent) + : QObject{parent} +{ +} + +bool SolarmanDiscovery::discover() +{ + if (!initUdp()) { + return false; + } + + if (m_discoveryTimer) { + qCDebug(dcSolarman()) << "Already discovering..."; + return true; + } + + m_discoveryTimer = new QTimer(this); + m_discoveryTimer->start(1000); + + connect(m_discoveryTimer, &QTimer::timeout, this, [=](){ + int counter = m_discoveryTimer->property("counter").toInt(); + if (counter < 5) { + m_discoveryTimer->setProperty("counter", counter+1); + sendDiscoveryMessage(); + } else { + emit discoveryResults(m_discoveryResults); + m_discoveryResults.clear(); + delete m_discoveryTimer; + m_discoveryTimer = nullptr; + } + }); + + bool status = sendDiscoveryMessage(); + if (!status) { + delete m_discoveryTimer; + m_discoveryTimer = nullptr; + } + return status; +} + +bool SolarmanDiscovery::monitor(const QString &serial) +{ + if (!initUdp()) { + return false; + } + + m_monitoringResult.serial = serial; + + if (!m_monitorTimer) { + m_monitorTimer = new QTimer(this); + m_monitorTimer->start(15000); + connect(m_monitorTimer, &QTimer::timeout, this, [=](){ + sendDiscoveryMessage(); + + if (m_monitoringResult.lastSeen.msecsTo(QDateTime::currentDateTime()) > 60000) { + m_monitoringResult.online = false; + emit monitorStateChanged(m_monitoringResult); + } + }); + } + + bool status = sendDiscoveryMessage(); + if (!status) { + delete m_monitorTimer; + m_monitorTimer = nullptr; + } + return status; +} + +SolarmanDiscovery::DiscoveryResult SolarmanDiscovery::monitorResult() const +{ + return m_monitoringResult; +} + +void SolarmanDiscovery::onReadyRead() +{ + while (m_udp->hasPendingDatagrams()) { + QByteArray data; + data.resize(m_udp->pendingDatagramSize()); + QHostAddress host; + m_udp->readDatagram(data.data(), m_udp->pendingDatagramSize(), &host); + if (data == "WIFIKIT-214028-READ") { + continue; + } + qCDebug(dcSolarman) << "UDP datagram from" << host.toString() << data; + + QStringList parts = QString(data).split(","); + if (parts.count() != 3) { + qCDebug(dcSolarman()) << "Unexpected discovery reply format:" << data; + continue; + } + DiscoveryResult result; + result.ip = parts.at(0); + result.mac = parts.at(1); + result.serial = parts.at(2); + result.online = true; + + // Sanity check if the claimed IP matches with the sender + if (result.ip.toIPv4Address() != host.toIPv4Address()) { + qCDebug(dcSolarman) << "Sender IP doesn't match claimed IP:" << result.ip << host; + continue; + } + + if (m_discoveryTimer) { + qCDebug(dcSolarman()) << "Found solarman device" << result.serial << "on" << result.ip; + if (!m_discoveryResults.contains(result)) { + m_discoveryResults.append(result); + } + } + + if (m_monitorTimer) { + if (result.serial == m_monitoringResult.serial) { + m_monitoringResult = result; + m_monitoringResult.lastSeen = QDateTime::currentDateTime(); + emit monitorStateChanged(result); + } + } + } +} + +bool SolarmanDiscovery::initUdp() +{ + if (m_udp) { + return true; + } + + m_udp = new QUdpSocket(this); + connect(m_udp, &QUdpSocket::readyRead, this, &SolarmanDiscovery::onReadyRead); + bool status = m_udp->bind(48899, QUdpSocket::ShareAddress); + if (!status) { + qCWarning(dcSolarman()) << "Unable to bind UDP port 48899. SolarmanDiscovery won't work."; + delete m_udp; + m_udp = nullptr; + } + return status; +} + +bool SolarmanDiscovery::sendDiscoveryMessage() +{ + qCDebug(dcSolarman()) << "Discovering solarman inverters..."; + QByteArray discoveryString("WIFIKIT-214028-READ"); + int len = m_udp->writeDatagram(discoveryString, QHostAddress::Broadcast, 48899); + return len == discoveryString.length(); +} diff --git a/solarman/solarmandiscovery.h b/solarman/solarmandiscovery.h new file mode 100644 index 000000000..5d14132ef --- /dev/null +++ b/solarman/solarmandiscovery.h @@ -0,0 +1,52 @@ +#ifndef SOLARMANDISCOVERY_H +#define SOLARMANDISCOVERY_H + +#include +#include +#include +#include +#include + +class SolarmanDiscovery : public QObject +{ + Q_OBJECT +public: + class DiscoveryResult { + public: + QString serial; + QHostAddress ip; + QString mac; + bool online; + QDateTime lastSeen; + bool operator==(const DiscoveryResult &other) { + return serial == other.serial; + } + }; + + explicit SolarmanDiscovery(QObject *parent = nullptr); + + bool discover(); + + bool monitor(const QString &serial); + DiscoveryResult monitorResult() const; + +signals: + void discoveryResults(const QList &results); + void monitorStateChanged(const DiscoveryResult &result); + +private slots: + void onReadyRead(); + +private: + bool initUdp(); + bool sendDiscoveryMessage(); + + QList m_discoveryResults; + + QUdpSocket *m_udp = nullptr;; + DiscoveryResult m_monitoringResult; + QTimer *m_discoveryTimer = nullptr; + QTimer *m_monitorTimer = nullptr; +}; + +#endif // SOLARMANDISCOVERY_H diff --git a/solarman/solarmanmodbus.cpp b/solarman/solarmanmodbus.cpp new file mode 100644 index 000000000..f5b51b716 --- /dev/null +++ b/solarman/solarmanmodbus.cpp @@ -0,0 +1,305 @@ +#include "solarmanmodbus.h" +#include "solarmanmodbusreply.h" + +#include +#include +#include +#include +#include +#include + + +Q_DECLARE_LOGGING_CATEGORY(dcSolarman) + +#define MOCK_DATA 0 + +quint8 START_OF_MESSAGE = 0xA5; +quint8 END_OF_MESSAGE = 0x15; +quint8 FRAME_TYPE = 0x02; +quint16 requestCode = 0x4510; +quint16 responseCode = 0x1510; + +SolarmanModbus::SolarmanModbus(QObject *parent) + : QObject{parent} +{ + m_socket = new QTcpSocket(this); + connect(m_socket, &QTcpSocket::stateChanged, this, [=](QAbstractSocket::SocketState state){ + qCDebug(dcSolarman()) << "Socket state changed:" << state; + if (state == QAbstractSocket::ConnectedState) { + emit connectedChanged(true); + } else if (state == QAbstractSocket::UnconnectedState){ + emit connectedChanged(false); + } + }, Qt::QueuedConnection); // Otherwise socket->isOpen() may stll be true if the user reconnects right away. + + connect(m_socket, &QTcpSocket::readyRead, this, [this](){ + QByteArray data = m_socket->readAll(); + processData(data); + }); +} + +void SolarmanModbus::connectToHost(const QHostAddress &host, quint16 port, const QString &serial) +{ + if (m_serial != serial || m_host != host || m_port != port) { + m_serial = serial; + m_host = host; + m_port = port; + m_socket->close(); + } + + if (!m_socket->isOpen()) { + qCDebug(dcSolarman()) << "Connecting to" << host.toString() << port; + m_socket->connectToHost(host, port); + } else { + qCDebug(dcSolarman()) << "Still connected. Not reconnecting"; + } +} + +void SolarmanModbus::disconnectFromHost() +{ + m_serial.clear(); + m_socket->disconnectFromHost(); + emit connectedChanged(false); +} + +bool SolarmanModbus::isConnected() const +{ + return m_socket->isOpen(); +} + +SolarmanModbusReply *SolarmanModbus::readRegisters(int slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode) +{ + +#if MOCK_DATA == 1 + quint8 requestId = 0; + SolarmanModbusReply *reply = new SolarmanModbusReply(requestId, slaveId, startRegister, endRegister, functionCode, this); + + QByteArray mockData = QByteArray::fromHex( + // Late afternoon + "a5f30010150033fdc5fff40201aaab35004b0600001e2dba620103e0010002013232303230393036353500010000120c070000000112020700000bb800000101004b0000003c160807111210000000000abe07081450128e000000000000139c002c000000000000000000640000000000010000000000010000000000010000000000000000000000000000000000000004001100000000042a00000011000000000000042a0000000000000938000000000000000000001388000000000000000000000000042e00000000000014d20000000000000000000000000000000000000000000000000000000000000000000000000174001c0000000000356b15" + // End of day +// "a5f3001015002bfdc5fff402011a2900005a0600006adcef620103e0010002013232303230393036353500010000120c070000000112020700000bb800000101004b0000003c160807141d08000000000abe07081450128e000000000000139c002c000000000000000000640000000000010000000000010000000000010000000000000000000000000000000000000004001300000000042c00000013000000000000042c0000000000000924000000000000000000001388000000000000000000000000001e000000000000106800000000000000000000000000000000000000000000000000000000000000000000000001460000000000008ce6c615" + ); + m_pendingReplies.append(reply); + QMetaObject::invokeMethod(this, "processData", Qt::QueuedConnection, Q_ARG(QByteArray, mockData)); +#else + + quint8 requestId = m_requestIdCounter++; + SolarmanModbusReply *reply = new SolarmanModbusReply(requestId, slaveId, startRegister, endRegister, functionCode, this); + + if (!m_socket->isOpen()) { + reply->finish(false); + return reply; + } + m_pendingReplies.append(reply); + connect(reply, &SolarmanModbusReply::finished, this, [this, reply](){ + m_pendingReplies.removeAll(reply); + }); + QByteArray request = createRequest(requestId, slaveId, startRegister, endRegister, functionCode, m_serial); + qCDebug(dcSolarman) << "Requesting:" << request.toHex(); + m_socket->write(request); +#endif + + return reply; +} + +void SolarmanModbus::processData(const QByteArray &data) +{ + qCDebug(dcSolarman) << "Data received:" << data.toHex(); + + if (!validateChecksum(data)) { + qCWarning(dcSolarman()) << "Checksum verification failed for payload:" << data.toHex(); + return; + } + + QDataStream stream(data); + stream.setByteOrder(QDataStream::LittleEndian); + quint8 startOfMessage; + stream >> startOfMessage; + if (startOfMessage != START_OF_MESSAGE) { + qCWarning(dcSolarman()) << "Skipping message... No Start of message found."; + return; + } + quint16 payloadLength; + stream >> payloadLength; + qCDebug(dcSolarman()) << "Payload length:" << payloadLength; + quint16 commandCode; + stream >> commandCode; + + quint8 requestId; + stream >> requestId; + quint8 packetCounter; + stream >> packetCounter; // Probably for deduplication, dunno, just counts up to 0x6a (100) and resets to 1... + + + if (commandCode != responseCode) { + qCWarning(dcSolarman()) << "Unknown packet received:" << data.toHex(); + disconnectFromHost(); + return; + QByteArray rsp; + QDataStream rs(&rsp, QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + rs << static_cast(START_OF_MESSAGE); + rs << static_cast(1); // len + rs << static_cast(0x1710); + rs << requestId; + rs << packetCounter; + QByteArray serialHex = getSerialHex(m_serial); + qDebug() << "Serial hex:" << serialHex.toHex(); + rs.writeRawData(serialHex.data(), serialHex.length()); + rs << static_cast(0); + rs << createChecksum(rsp); + rs << static_cast(END_OF_MESSAGE); + m_socket->write(rsp); + return; + } + + + char serialStr[4]; + stream.readRawData(serialStr, 4); + QByteArray serial(serialStr, 4); + + if (serial != getSerialHex(m_serial)) { + qCWarning(dcSolarman()) << "Serial number does not match:" << serial.toHex() << getSerialHex(m_serial).toHex(); + return; + } + + // Skipping 10 unknown characters + quint8 frameType; + stream >> frameType; + + quint8 sensorType; + stream >> sensorType; + + quint32 deliveryTime; + stream >> deliveryTime; + + quint32 powerOnTime; + stream >> powerOnTime; +// char unknownStr[10]; +// stream.readRawData(unknownStr, 10); + + quint32 timestamp; + stream >> timestamp; + qCDebug(dcSolarman()) << "Device time:" << QDateTime::fromMSecsSinceEpoch((qulonglong)timestamp * 1000, QTimeZone::utc()); + + quint8 slaveId; + stream >> slaveId; + + quint8 rt; + stream >> rt; + FunctionCode functionCode = static_cast(rt); + + quint8 registersLength; + stream >> registersLength; + + char dataStr[registersLength]; + stream.readRawData(dataStr, registersLength); + QByteArray registers(dataStr, registersLength); + + quint16 modbusCRC; + stream >> modbusCRC; + // While the checksum on the outer package is quite crappy (I've seen it colliding easily), we still don't bother checking the modbus CRC if that matches... + + qCDebug(dcSolarman()) << "Reply received: SlaveID" << slaveId << functionCode << "payload len" << registersLength << "poweruptime" << powerOnTime << "deliverytime" << deliveryTime << "frametype" << frameType; + + foreach (SolarmanModbusReply *reply, m_pendingReplies) { + if (reply->requestId() == requestId) { + reply->finish(true, registers); + } + } +} + +QByteArray SolarmanModbus::createRequest(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode, const QString &serialNumber) { + + + QByteArray packet; + QDataStream stream(&packet, QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + stream << START_OF_MESSAGE; + QByteArray modbusPayload = createModbusPayload(slaveId, startRegister, endRegister, functionCode); + stream << static_cast(15 + modbusPayload.length()); +// stream.writeRawData((char*)CONTROL_CODE, 2); + stream << requestCode; + stream << requestId; + stream << requestId;//static_cast(0x00); // This seems to be a static counter counting up unrelated on both ends, but the inverter doesn't care if we don't use it. + QByteArray serialHex = getSerialHex(serialNumber); + qDebug() << "Serial hex:" << serialHex.toHex(); + stream.writeRawData(serialHex.data(), serialHex.length()); + stream << FRAME_TYPE; + stream << static_cast(0x0000); // Sensor type + stream << static_cast(0x00000000); // Deliverytime + stream << static_cast(0x00000000); // PowerOnTime + stream << static_cast(QDateTime::currentDateTimeUtc().toMSecsSinceEpoch() / 1000); + stream.writeRawData(modbusPayload.data(), modbusPayload.length()); + stream << createChecksum(packet); + stream << END_OF_MESSAGE; + return packet; +} + +QByteArray SolarmanModbus::createModbusPayload(quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode) +{ + quint16 registerCount = endRegister - startRegister + 1; + + QByteArray data; + QDataStream stream(&data, QIODevice::WriteOnly); + stream << static_cast(slaveId); + stream << static_cast(functionCode); + stream << startRegister; + stream << registerCount; + quint16 crc = createModbusCRC(data); + stream.setByteOrder(QDataStream::LittleEndian); +// stream.setByteOrder(QDataStream::BigEndian); + stream << crc; + return data; +} + +quint16 SolarmanModbus::createModbusCRC(const QByteArray &data) +{ + quint16 poly = 0xA001; + QDataStream stream(data); + quint16 crc = 0xFFFF; + + while (!stream.atEnd()) { + quint8 byte; + stream >> byte; + crc ^= byte; + for (int i = 0; i < 8; i++) { + if (crc & 0x0001) { + crc = (crc >> 1) ^ poly; + } else { + crc = crc >> 1; + } + } + } + return crc; +} + +QByteArray SolarmanModbus::getSerialHex(const QString &serialNumber) { + QByteArray serialHex = QByteArray::fromHex(QByteArray::number(serialNumber.toLongLong(), 16)); + QByteArray reversed; + reversed.reserve(serialHex.size()); + for (int i = serialHex.size() - 1; i >= 0; i--) { + reversed.append(serialHex.at(i)); + } + return reversed; +} + +quint8 SolarmanModbus::createChecksum(const QByteArray &data) +{ + quint16 checksum = 0; + for (int i = 1; i < data.length(); i++) { + checksum += (quint8)data.at(i); + } + return checksum; +} + +bool SolarmanModbus::validateChecksum(const QByteArray &packet) { + quint16 checksum = 0; + // Don't include the checksum and END OF MESSAGE (-2) + for (int i = 1; i < packet.length() - 2; i++) { + checksum += packet[i]; + } + quint8 final = static_cast(checksum); + return final == (quint8)packet.at(packet.length() - 2); +} diff --git a/solarman/solarmanmodbus.h b/solarman/solarmanmodbus.h new file mode 100644 index 000000000..2d13123ef --- /dev/null +++ b/solarman/solarmanmodbus.h @@ -0,0 +1,59 @@ +#ifndef SOLARMANMODBUS_H +#define SOLARMANMODBUS_H + +#include +#include +#include + +class SolarmanModbusReply; + +class SolarmanModbus : public QObject +{ + Q_OBJECT +public: + enum FunctionCode { + Invalid = 0x00, + ReadCoilStatus = 0x01, + ReadInputStatus = 0x02, + ReadHoldingRegisters = 0x03, + ReadInputRegisters = 0x04, + ForceSingleCoil = 0x05, + ForceSingleRegister = 0x06, + ForceMultipleCoils = 0x0F, + PresetMultipleRegisters = 0x10 + }; + Q_ENUM(FunctionCode) + + explicit SolarmanModbus(QObject *parent = nullptr); + + void connectToHost(const QHostAddress &host, quint16 port, const QString &serial); + void disconnectFromHost(); + bool isConnected() const; + + SolarmanModbusReply* readRegisters(int slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode); + +signals: + void connectedChanged(bool connected); + +private slots: + void processData(const QByteArray &data); + +private: + QByteArray createRequest(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode, const QString &serialNumber); + QByteArray createModbusPayload(quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode); + quint16 createModbusCRC(const QByteArray &data); + QByteArray getSerialHex(const QString &serialNumber); + + quint8 createChecksum(const QByteArray &data); + bool validateChecksum(const QByteArray &data); + + QString m_serial; + QHostAddress m_host; + quint16 m_port = 0; + QTcpSocket *m_socket = nullptr; + quint8 m_requestIdCounter = 0; + + QList m_pendingReplies; +}; + +#endif // SOLARMANMODBUS_H diff --git a/solarman/solarmanmodbusreply.cpp b/solarman/solarmanmodbusreply.cpp new file mode 100644 index 000000000..5de9c9b3e --- /dev/null +++ b/solarman/solarmanmodbusreply.cpp @@ -0,0 +1,94 @@ +#include "solarmanmodbusreply.h" + +#include +#include +#include + +Q_DECLARE_LOGGING_CATEGORY(dcSolarman) + +SolarmanModbusReply::SolarmanModbusReply(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, SolarmanModbus::FunctionCode functionCode, QObject *parent) + : QObject{parent}, + m_requestId(requestId), + m_slaveId(slaveId), + m_startRegister(startRegister), + m_endRegister(endRegister), + m_functionCode(functionCode) +{ + QTimer::singleShot(20000, this, [this](){ + finish(false); + }); + +} + +quint8 SolarmanModbusReply::requestId() const +{ + return m_requestId; +} + +quint8 SolarmanModbusReply::slaveId() const +{ + return m_slaveId; +} + +quint16 SolarmanModbusReply::startRegister() const +{ + return m_startRegister; +} + +quint16 SolarmanModbusReply::endRegister() const +{ + return m_endRegister; +} + +quint16 SolarmanModbusReply::readRegister16(quint16 reg) const +{ + int index = (reg - m_startRegister) * 2; + if (index < 0 || index + 1 >= m_data.length()) { + qCWarning(dcSolarman()) << "Register" << reg << "out of range:" << m_startRegister << "-" << m_endRegister; + return 0; + } + QByteArray clipped = m_data.right(m_data.length() - (reg - m_startRegister) * 2); + QDataStream stream(clipped); + quint16 ret = 0; + stream >> ret; + return ret; +} + +quint32 SolarmanModbusReply::readRegister32(quint16 reg) const +{ + int index = (reg - m_startRegister) * 2; + if (index < 0 || index + 3 >= m_data.length()) { + qCWarning(dcSolarman()) << "Register" << reg << "out of range:" << m_startRegister << "-" << m_endRegister; + return 0; + } + QByteArray clipped = m_data.right(m_data.length() - (reg - m_startRegister) * 2); + QDataStream stream(clipped); + quint32 ret = 0; + quint16 tmp; + stream >> tmp; + ret = tmp; + stream >> tmp; + ret += (tmp << 16); + return ret; +} + +QString SolarmanModbusReply::readRegisterString(quint16 reg, quint8 registerCount) const +{ + int index = (reg - m_startRegister) * 2; + if (index < 0 || (index + registerCount) * 2 >= m_data.length()) { + return QString(); + } + QByteArray string = m_data.right(m_data.length() - (reg - m_startRegister) * 2).left(registerCount * 2); + return string; +} + +void SolarmanModbusReply::finish(bool success, const QByteArray &data) +{ + if (m_finished) { + return; + } + m_finished = true; + m_success = success; + m_data = data; + QMetaObject::invokeMethod(this, "finished", Qt::QueuedConnection, Q_ARG(bool, success)); +} diff --git a/solarman/solarmanmodbusreply.h b/solarman/solarmanmodbusreply.h new file mode 100644 index 000000000..6662b3510 --- /dev/null +++ b/solarman/solarmanmodbusreply.h @@ -0,0 +1,40 @@ +#ifndef SOLARMANMODBUSREPLY_H +#define SOLARMANMODBUSREPLY_H + +#include +#include "solarmanmodbus.h" + +class SolarmanModbusReply : public QObject +{ + friend class SolarmanModbus; + Q_OBJECT +public: + explicit SolarmanModbusReply(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, SolarmanModbus::FunctionCode, QObject *parent = nullptr); + + quint8 requestId() const; + quint8 slaveId() const; + quint16 startRegister() const; + quint16 endRegister() const; + + quint16 readRegister16(quint16 reg) const; + quint32 readRegister32(quint16 reg) const; + QString readRegisterString(quint16 reg, quint8 registerCount) const; + +signals: + void finished(bool success); + +private: + void finish(bool success, const QByteArray &data = QByteArray()); + + quint8 m_requestId = 0; + quint8 m_slaveId = 0; + quint16 m_startRegister = 0; + quint16 m_endRegister = 0; + SolarmanModbus::FunctionCode m_functionCode = SolarmanModbus::FunctionCode::Invalid; + QByteArray m_data; + bool m_success = false; + bool m_finished = false; + +}; + +#endif // SOLARMANMODBUSREPLY_H diff --git a/solarman/test.py b/solarman/test.py new file mode 100644 index 000000000..e7a8fd7a9 --- /dev/null +++ b/solarman/test.py @@ -0,0 +1,281 @@ +import socket + +import yaml +import struct + + + + +# The parameters start in the "business field" +# just after the first two bytes. +OFFSET_PARAMS = 28 + + +class ParameterParser: + def __init__(self, lookups): + self.result = {} + self._lookups = lookups + return + + def parse (self, rawData, start, length): + for i in self._lookups['parameters']: + for j in i['items']: + print("parsing %s" % j["name"]) + self.try_parse_field(rawData, j, start, length) + return + + def get_result(self): + return self.result + + + def try_parse_field (self, rawData, definition, start, length): + rule = definition['rule'] + if rule == 1: + self.try_parse_unsigned(rawData,definition, start, length) + elif rule == 2: + self.try_parse_signed(rawData,definition, start, length) + elif rule == 3: + self.try_parse_unsigned(rawData,definition, start, length) + elif rule == 4: + self.try_parse_signed(rawData,definition, start, length) + elif rule == 5: + self.try_parse_ascii(rawData,definition, start, length) + elif rule == 6: + self.try_parse_bits(rawData,definition, start, length) + return + + def try_parse_signed (self, rawData, definition, start, length): + title = definition['name'] + scale = definition['scale'] + value = 0 + found = True + shift = 0 + maxint = 0 + for r in definition['registers']: + index = r - start # get the decimal value of the register' + if (index >= 0) and (index < length): + maxint <<= 16 + maxint |= 0xFFFF + offset = OFFSET_PARAMS + (index * 2) + temp = struct.unpack('>H', rawData[offset:offset + 2])[0] + value += (temp & 0xFFFF) << shift + shift += 16 + else: + found = False + if found: + if 'offset' in definition: + value = value - definition['offset'] + + if value > maxint/2: + value = (value - maxint) * scale + else: + value = value * scale + + if self.is_integer_num (value): + self.result[title] = int(value) + else: + self.result[title] = value + return + + def try_parse_unsigned (self, rawData, definition, start, length): + title = definition['name'] + scale = definition['scale'] + value = 0 + found = True + shift = 0 + for r in definition['registers']: + index = r - start # get the decimal value of the register' + print("Reg: %s, index: %s" % (r, index)) + if (index >= 0) and (index < length): + offset = OFFSET_PARAMS + (index * 2) + temp = struct.unpack('>H', rawData[offset:offset + 2])[0] + value += (temp & 0xFFFF) << shift + shift += 16 + else: + found = False + if found: + if 'lookup' in definition: + self.result[title] = self.lookup_value (value, definition['lookup']) + else: + if 'offset' in definition: + value = value - definition['offset'] + + value = value * scale + if self.is_integer_num (value): + self.result[title] = int(value) + else: + self.result[title] = value + return + + + def lookup_value (self, value, options): + for o in options: + if (o['key'] == value): + return o['value'] + return "LOOKUP" + + + def try_parse_ascii (self, rawData, definition, start, length): + title = definition['name'] + found = True + value = '' + for r in definition['registers']: + index = r - start # get the decimal value of the register' + if (index >= 0) and (index < length): + offset = OFFSET_PARAMS + (index * 2) + temp = struct.unpack('>H', rawData[offset:offset + 2])[0] + value = value + chr(temp >> 8) + chr(temp & 0xFF) + else: + found = False + + if found: + self.result[title] = value + return + + def try_parse_bits (self, rawData, definition, start, length): + title = definition['name'] + found = True + value = [] + for r in definition['registers']: + index = r - start # get the decimal value of the register' + if (index >= 0) and (index < length): + offset = OFFSET_PARAMS + (index * 2) + temp = struct.unpack('>H', rawData[offset:offset + 2])[0] + value.append(hex(temp)) + else: + found = False + + if found: + self.result[title] = value + return + + def get_sensors (self): + result = [] + for i in self._lookups['parameters']: + for j in i['items']: + result.append(j) + return result + + def is_integer_num(self, n): + if isinstance(n, int): + return True + if isinstance(n, float): + return n.is_integer() + return False + +START_OF_MESSAGE = 0xA5 +SEND_DATA_FIELD = [0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] +CONTROL_CODE = [0x10, 0x45] +SERIAL_NO = [0x00, 0x00] +END_OF_MESSAGE = 0x15 + +mb_slaveid = 1 +_serial = 4110403069 + +def modbus(data): + POLY = 0xA001 + crc = 0xFFFF + for byte in data: + crc ^= byte +# print("C1: %s" % crc) + for _ in range(8): + crc = ((crc >> 1) ^ POLY + if (crc & 0x0001) + else crc >> 1) +# print("C: %s" % crc) + return crc + +def get_serial_hex(): +# print("s0: %s %s" % (_serial, hex(_serial))) + serial_hex = hex(_serial)[2:] +# print("s1: %s" % serial_hex) + serial_bytes = bytearray.fromhex(serial_hex) + serial_bytes.reverse() +# print("serial_bytes: %s" % serial_bytes.hex()) + return serial_bytes + + +def get_read_business_field(start, length, mb_fc): + request_data = bytearray([mb_slaveid, mb_fc]) # Function Code + request_data.extend(start.to_bytes(2, 'big')) + request_data.extend(length.to_bytes(2, 'big')) + crc = modbus(request_data) +# print("CRC %s" % crc) + request_data.extend(crc.to_bytes(2, 'little')) + return request_data + + +def generate_request(start, length, mb_fc): + packet = bytearray([START_OF_MESSAGE]) + packet_data = [] + packet_data.extend (SEND_DATA_FIELD) + buisiness_field = get_read_business_field(start, length, mb_fc) + print("Biz field: %s" % buisiness_field.hex()); + packet_data.extend(buisiness_field) + print("packet_data: %s" % bytearray(packet_data).hex()) + length = packet_data.__len__() + print("Length %s" % length) + packet.extend(length.to_bytes(2, "little")) + packet.extend(CONTROL_CODE) + packet.extend(SERIAL_NO) + print("serialHex: %s" % get_serial_hex().hex()) + packet.extend(get_serial_hex()) + packet.extend(packet_data) + #Checksum + checksum = 0 + for i in range(1,len(packet),1): +# print("c: %s" % packet[i]) + checksum += packet[i] +# print("checksum: %s" % checksum) + packet.append(checksum & 0xFF) + packet.append(END_OF_MESSAGE) + + del packet_data + del buisiness_field + return packet + + +def validate_checksum(packet): + checksum = 0 + length = len(packet) + # Don't include the checksum and END OF MESSAGE (-2) + for i in range(1,length-2,1): + # print("c: %s" % packet[i]) + checksum += packet[i] + #print("c1: %s" % checksum) + #print("checksum1: %s" % checksum) + checksum &= 0xFF + #print("checksum2: %s" % checksum) + if checksum == packet[length-2]: + return 1 + else: + return 0 + +start = 0x0001 +end = 0x0070 +fc = 0x03 + +print("Request: %s" % generate_request(start, end - start + 1, fc).hex()) + + +request = generate_request(start, end - start + 1, fc) +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.settimeout(10) +sock.connect(("10.10.10.195", 8899)) +print(request.hex()) +sock.sendall(request) # Request param 0x3B up to 0x71 +raw_msg = sock.recv(1024) +print(raw_msg.hex()) + +#raw_msg = bytearray.fromhex("a5ef0010150063fdc5fff40201d6e63400db2000003da3b9620103dc3232303230393036353500010000120c070000000112020700000bb800000101004b0000003c16080611200f000000000abe07081450128e000000000000139c002c000000000000000000640000000000010000000000010000000000010000000000000000000000000000000000000004000a0000000004170000000a0000000000000417000000000000092400000000000000000000138800000000000000000000000002ee0000000000001270000000000000000000000000000000000000000000000000000000000000000000000000017d00130000000066149615") + +if validate_checksum(raw_msg) == 1: + print("Good checksum") + +with open("deye.yaml") as f: + parameter_definition = yaml.full_load(f) + +params = ParameterParser(parameter_definition) +params.parse(raw_msg, start, end - start + 1) + +print("Result: %s" % params.get_result()) diff --git a/solarman/translations/595b7759-336d-4677-a014-1b0fd11f45ea-en_US.ts b/solarman/translations/595b7759-336d-4677-a014-1b0fd11f45ea-en_US.ts new file mode 100644 index 000000000..7f795b8d8 --- /dev/null +++ b/solarman/translations/595b7759-336d-4677-a014-1b0fd11f45ea-en_US.ts @@ -0,0 +1,70 @@ + + + + + IntegrationPluginBosswerk + + + Please enter your login credentials. + + + + + An error happened in the network communication. + + + + + Failed to log in at the inverter. + + + + + bosswerk + + + + Bosswerk + The name of the vendor ({26ec1591-cc37-4ac1-b943-04844e002601}) +---------- +The name of the plugin bosswerk ({595b7759-336d-4677-a014-1b0fd11f45ea}) + + + + + Connected + The name of the StateType ({b1a9bdf7-1c87-4c5d-b7e5-835697e7b7e5}) of ThingClass mix00 + + + + + Current power consumption + The name of the StateType ({044c26ce-67a7-4c81-99b1-4aa35285b109}) of ThingClass mix00 + + + + + MAC address + The name of the ParamType (ThingClass: mix00, Type: thing, ID: {6fbe5f08-3539-447d-9281-916abe9d8128}) + + + + + MI-300/600 + The name of the ThingClass ({31ee3e61-eb3f-470b-8957-293fe65f404d}) + + + + + Signal strength + The name of the StateType ({4187873d-50dd-4470-8bd1-2787436db84d}) of ThingClass mix00 + + + + + Total produced energy + The name of the StateType ({4a596301-3a8d-41de-bc97-275d23c0e5cd}) of ThingClass mix00 + + + +