SolarManager unter Versionsverwaltung

Erster Stand der Hintergrundprozesse, die auf der Synology unter
/volume1/homes/wagner/SolarManager laufen: der Manager selbst, die Sammler
je Geraet, die MQTT-Bruecke, der Wecker und - neu hinzugezogen - der
AutoAction-Runner, der als Hintergrundprozess hierher gehoert und nicht ins
Web-Verzeichnis.

Zugangsdaten stehen nicht mehr im Quelltext, sondern in config.ini, die
nicht mit eingecheckt wird. Vorlage ist config.ini.example, gelesen wird sie
von konfig.py. Betroffen waren solarManager.py (Datenbank und Wattpilot),
zeit.py, gatherWaterData.py, wecker.py und skoda_testdaten.py, das sich das
Passwort bisher aus dem Quelltext eines anderen Moduls herausgesucht hat.

Die Kia-Anbindung ist mit dem Fahrzeug entfallen: kiaTest.py,
gatherCarData.py und hyundai_kia_connect_api sind nicht mehr dabei, ebenso
gatherInverterData.py, auf das nur noch eine auskommentierte Zeile zeigte.

Die mitgelieferten Bibliotheken bleiben im Repository - die NAS hat kein
pip, sie muessen neben den Skripten liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 20:46:59 +02:00
co-authored by Claude Opus 5
commit 79843aa2ae
968 changed files with 261182 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
import asyncio
import logging
from typing import Type
from .dt import DT
from .es import ES
from .et import ET
from .exceptions import InverterError, RequestFailedException
from .goodwe import GoodWeXSProcessor, AbstractDataProcessor, GoodWeInverter
from .inverter import Inverter, OperationMode, Sensor, SensorKind
from .model import DT_MODEL_TAGS, ES_MODEL_TAGS, ET_MODEL_TAGS
from .protocol import ProtocolCommand, UdpInverterProtocol, Aa55ProtocolCommand
logger = logging.getLogger(__name__)
# Inverter family names
ET_FAMILY = ["ET", "EH", "BT", "BH"]
ES_FAMILY = ["ES", "EM", "BP"]
DT_FAMILY = ["DT", "MS", "NS", "XS"]
# Initial discovery command
DISCOVERY_COMMAND = Aa55ProtocolCommand("010200", "0182")
# supported inverter protocols
_SUPPORTED_PROTOCOLS = [ET, DT, ES]
async def connect(host: str, family: str = None, comm_addr: int = 0, timeout: int = 1, retries: int = 3,
do_discover: bool = True) -> Inverter:
"""Contact the inverter at the specified host/port and answer appropriate Inverter instance.
The specific inverter family/type will be detected automatically, but it can be passed explicitly.
Supported inverter family names are ET, EH, BT, BH, ES, EM, BP, DT, MS, D-NS and XS.
Inverter communication address may be explicitly passed, if not the usual default value
will be used (0xf7 for ET/EH/BT/BH/ES/EM/BP inverters, 0x7f for DT/MS/D-NS/XS inverters).
Since the UDP communication is by definition unreliable, when no (valid) response is received by the specified
timeout, it is considered lost and the command will be re-tried up to retries times.
Raise InverterError if unable to contact or recognise supported inverter.
"""
if family in ET_FAMILY:
inv = ET(host, comm_addr, timeout, retries)
elif family in ES_FAMILY:
inv = ES(host, comm_addr, timeout, retries)
elif family in DT_FAMILY:
inv = DT(host, comm_addr, timeout, retries)
elif do_discover:
return await discover(host, timeout, retries)
logger.debug("Connecting to %s family inverter at %s.", family, host)
await inv.read_device_info()
logger.debug("Connected to inverter %s, S/N:%s.", inv.model_name, inv.serial_number)
return inv
async def discover(host: str, timeout: int = 1, retries: int = 3) -> Inverter:
"""Contact the inverter at the specified value and answer appropriate Inverter instance
Raise InverterError if unable to contact or recognise supported inverter
"""
failures = []
# Try the common AA55C07F0102000241 command first and detect inverter type from serial_number
try:
logger.debug("Probing inverter at %s.", host)
response = await DISCOVERY_COMMAND.execute(host, timeout, retries)
model_name = response[12:22].decode("ascii").rstrip()
serial_number = response[38:54].decode("ascii")
inverter_class: Type[Inverter] | None = None
for model_tag in ET_MODEL_TAGS:
if model_tag in serial_number:
logger.debug("Detected ET/EH/BT/BH/GEH inverter %s, S/N:%s.", model_name, serial_number)
inverter_class = ET
for model_tag in ES_MODEL_TAGS:
if model_tag in serial_number:
logger.debug("Detected ES/EM/BP inverter %s, S/N:%s.", model_name, serial_number)
inverter_class = ES
for model_tag in DT_MODEL_TAGS:
if model_tag in serial_number:
logger.debug("Detected DT/MS/D-NS/XS/GEP inverter %s, S/N:%s.", model_name, serial_number)
inverter_class = DT
if inverter_class:
i = inverter_class(host, 0, timeout, retries)
await i.read_device_info()
return i
except InverterError as ex:
failures.append(ex)
# Probe inverter specific protocols
for inv in _SUPPORTED_PROTOCOLS:
i = inv(host, 0, timeout, retries)
try:
logger.debug("Probing %s inverter at %s.", inv.__name__, host)
await i.read_device_info()
await i.read_runtime_data()
logger.debug("Detected %s family inverter %s, S/N:%s.", inv.__name__, i.model_name, i.serial_number)
return i
except InverterError as ex:
failures.append(ex)
raise InverterError(
"Unable to connect to the inverter at "
f"host={host}, or your inverter is not supported yet.\n"
f"Failures={str(failures)}"
)
async def search_inverters() -> bytes:
"""Scan the network for inverters.
Answer the inverter discovery response string (which includes it IP address)
Raise InverterError if unable to contact any inverter
"""
logger.debug("Searching inverters by broadcast to port 48899")
loop = asyncio.get_running_loop()
command = ProtocolCommand("WIFIKIT-214028-READ".encode("utf-8"), lambda r: True)
response_future = loop.create_future()
transport, _ = await loop.create_datagram_endpoint(
lambda: UdpInverterProtocol(response_future, command, 1, 3),
remote_addr=("255.255.255.255", 48899),
allow_broadcast=True,
)
try:
await response_future
result = response_future.result()
if result is not None:
return result
else:
raise InverterError("No response received to broadcast request.")
except asyncio.CancelledError:
raise InverterError("No valid response received to broadcast request.") from None
finally:
transport.close()
+265
View File
@@ -0,0 +1,265 @@
from typing import Dict
GOODWE_UDP_PORT = 8899
BATTERY_MODES: Dict[int, str] = {
0: "No battery",
1: "Standby",
2: "Discharge",
3: "Charge",
4: "To be charged",
5: "To be discharged",
}
ENERGY_MODES: Dict[int, str] = {
0: "Check Mode",
1: "Wait Mode",
2: "Normal (On-Grid)",
4: "Normal (Off-Grid)",
8: "Flash Mode",
16: "Fault Mode",
32: "Battery Standby",
64: "Battery Charging",
128: "Battery Discharging",
}
GRID_MODES: Dict[int, str] = {
0: "Not connected to grid",
1: "Connected to grid",
2: "Fault",
}
GRID_IN_OUT_MODES: Dict[int, str] = {
0: "Idle",
1: "Exporting",
2: "Importing",
}
LOAD_MODES: Dict[int, str] = {
0: "Inverter and the load is disconnected",
1: "The inverter is connected to a load",
}
PV_MODES: Dict[int, str] = {
0: "PV panels not connected",
1: "PV panels connected, no power",
2: "PV panels connected, producing power",
}
WORK_MODES: Dict[int, str] = {
0: "Wait Mode",
1: "Normal",
2: "Error",
4: "Check Mode",
}
WORK_MODES_ET: Dict[int, str] = {
0: "Wait Mode",
1: "Normal (On-Grid)",
2: "Normal (Off-Grid)",
3: "Fault Mode",
4: "Flash Mode",
5: "Check Mode",
}
WORK_MODES_ES: Dict[int, str] = {
0: "Inverter Off - Standby",
1: "Inverter On",
2: "Inverter Abnormal, stopping power",
3: "Inverter Severly Abnormal, 20 seconds to restart",
}
SAFETY_COUNTRIES: Dict[int, str] = {
0: "Italy",
1: "Czechia",
2: "Germany",
3: "Spain",
4: "Greece Mainland",
5: "Denmark",
6: "Belgium",
7: "Romania",
8: "G98",
9: "Australia",
10: "France",
11: "China",
12: "60Hz Grid Default",
13: "Poland",
14: "South Africa",
15: "AustraliaL",
16: "Brazil",
17: "Thailand MEA",
18: "Thailand PEA",
19: "Mauritius",
20: "Holland",
21: "G99",
22: "China Special",
23: "French 50Hz",
24: "French 60Hz",
25: "Australia Ergon",
26: "Australia Energex",
27: "Holland 16/20A",
28: "Korea",
29: "China Station",
30: "Austria",
31: "India",
32: "50Hz Grid Default",
33: "Warehouse",
34: "Philippines",
35: "Ireland",
36: "Taiwan",
37: "Bulgaria",
38: "Barbados",
39: "China Special High",
40: "G99",
41: "Sweden",
42: "Chile",
43: "Brazil LV",
44: "NewZealand",
45: "IEEE1547 208VAC",
46: "IEEE1547 220VAC",
47: "IEEE1547 240VAC",
48: "60Hz LV Default",
49: "50Hz LV Default",
50: "AU_WAPN",
51: "AU_MicroGrid",
52: "JP_50Hz",
53: "JP_60Hz",
54: "India Higher",
55: "DEWA LV",
56: "DEWA MV",
57: "Slovakia",
58: "GreenGrid",
59: "Hungary",
60: "Sri Lanka",
61: "Spain Islands",
62: "Ergon30K",
63: "Energex30K",
64: "IEEE1547 230/400V",
65: "IEC61727 60Hz",
66: "Switzerland",
67: "CEI-016",
68: "AU_Horizon",
69: "Cyprus",
70: "AU_SAPN",
71: "AU_Ausgrid",
72: "AU_Essential",
73: "AU_Pwcore&CitiPW",
74: "Hong Kong",
75: "Poland MV",
76: "Holland MV",
77: "Sweden MV",
78: "VDE4110",
96: "cUSA_208VacDefault",
97: "cUSA_240VacDefault",
98: "cUSA_208VacCA_SCE",
99: "cUSA_240VacCA_SCE",
100: "cUSA_208VacCA_SDGE",
101: "cUSA_240VacCA_SDGE",
102: "cUSA_208VacCA_PGE",
103: "cUSA_240VacCA_PGE",
104: "cUSA_208VacHECO_14HO",
105: "cUSA_240VacHECO_14HO0x69",
106: "cUSA_208VacHECO_14HM",
107: "cUSA_240VacHECO_14HM",
}
ERROR_CODES: Dict[int, str] = {
31: 'Internal Communication Failure',
30: 'EEPROM R/W Failure',
29: 'Fac Failure',
28: 'DSP communication failure',
27: 'PhaseAngleFailure',
26: '',
25: 'Relay Check Failure',
24: '',
23: 'Vac Consistency Failure',
22: 'Fac Consistency Failure',
21: '',
20: 'Back-Up Over Load',
19: 'DC Injection High',
18: 'Isolation Failure',
17: 'Vac Failure',
16: 'External Fan Failure',
15: 'PV Over Voltage',
14: 'Utility Phase Failure',
13: 'Over Temperature',
12: 'InternalFan Failure',
11: 'DC Bus High',
10: 'Ground I Failure',
9: 'Utility Loss',
8: 'AC HCT Failure',
7: 'Relay Device Failure',
6: 'GFCI Device Failure',
5: '',
4: 'GFCI Consistency Failure',
3: 'DCI Consistency Failure',
2: '',
1: 'AC HCT Check Failure',
0: 'GFCI Device Check Failure',
}
DIAG_STATUS_CODES: Dict[int, str] = {
0: "Battery voltage low",
1: "Battery SOC low",
2: "Battery SOC in back",
3: "BMS: Discharge disabled",
4: "Discharge time on",
5: "Charge time on",
6: "Discharge Driver On",
7: "BMS: Discharge current low",
8: "APP: Discharge current too low",
9: "Meter communication failure",
10: "Meter connection reversed",
11: "Self-use load light",
12: "EMS: discharge current is zero",
13: "Discharge BUS high PV voltage",
14: "Battery Disconnected",
15: "Battery Overcharged",
16: "BMS: Temperature too high",
17: "BMS: Charge too high",
18: "BMS: Charge disabled",
19: "Self-use off",
20: "SOC delta too volatile",
21: "Battery self discharge too high",
22: "Battery SOC low (off-grid)",
23: "Grid wave unstable",
24: "Export power limit set",
25: "PF value set",
26: "Real power limit set",
27: "DC output on",
28: "SOC protect off",
}
BMS_ALARM_CODES: Dict[int, str] = {
15: 'Charging over-voltage 3',
14: 'Discharging under-voltage 3',
13: 'Cell temperature high 3',
12: 'Communication failure 2',
11: 'Charging circuit failure',
10: 'Discharging circuit failure',
9: 'Battery lock',
8: 'Battery break',
7: 'DC bus fault',
6: 'Precharge fault',
5: 'Discharging over-current 2',
4: 'Charging over-current 2',
3: 'Cell temperature low 2',
2: 'Cell temperature high 2',
1: 'Discharging under-voltage 2',
0: 'Charging over-voltage 2',
}
BMS_WARNING_CODES: Dict[int, str] = {
11: 'System temperature high',
10: 'System temperature low 2',
9: 'System temperature low 1',
8: 'Cell imbalance',
7: 'System reboot',
6: 'Communication failure 1',
5: 'Discharging over-current 1',
4: 'Charging over-current 1',
3: 'Cell temperature low 1',
2: 'Cell temperature high 1',
1: 'Discharging under-voltage 1',
0: 'Charging over-voltage 1',
}
+235
View File
@@ -0,0 +1,235 @@
from __future__ import annotations
from typing import Tuple
from .exceptions import InverterError
from .inverter import Inverter
from .inverter import OperationMode
from .inverter import SensorKind as Kind
from .model import is_3_mptt, is_single_phase
from .protocol import ProtocolCommand, ModbusReadCommand, ModbusWriteCommand, ModbusWriteMultiCommand
from .sensor import *
class DT(Inverter):
"""Class representing inverter of DT/MS/D-NS/XS or GE's GEP(PSB/PSC) families"""
__all_sensors: Tuple[Sensor, ...] = (
Timestamp("timestamp", 0, "Timestamp"),
Voltage("vpv1", 6, "PV1 Voltage", Kind.PV),
Current("ipv1", 8, "PV1 Current", Kind.PV),
Calculated("ppv1",
lambda data: round(read_voltage(data, 6) * read_current(data, 8)),
"PV1 Power", "W", Kind.PV),
Voltage("vpv2", 10, "PV2 Voltage", Kind.PV),
Current("ipv2", 12, "PV2 Current", Kind.PV),
Calculated("ppv2",
lambda data: round(read_voltage(data, 10) * read_current(data, 12)),
"PV2 Power", "W", Kind.PV),
Voltage("vpv3", 14, "PV3 Voltage", Kind.PV),
Current("ipv3", 16, "PV3 Current", Kind.PV),
Calculated("ppv3",
lambda data: round(read_voltage(data, 14) * read_current(data, 16)),
"PV3 Power", "W", Kind.PV),
# Voltage("vpv4", 14, "PV4 Voltage", Kind.PV),
# Current("ipv4", 16, "PV4 Current", Kind.PV),
# Voltage("vpv5", 14, "PV5 Voltage", Kind.PV),
# Current("ipv5", 16, "PV5 Current", Kind.PV),
# Voltage("vpv6", 14, "PV6 Voltage", Kind.PV),
# Current("ipv6", 16, "PV7 Current", Kind.PV),
Voltage("vline1", 30, "On-grid L1-L2 Voltage", Kind.AC),
Voltage("vline2", 32, "On-grid L2-L3 Voltage", Kind.AC),
Voltage("vline3", 34, "On-grid L3-L1 Voltage", Kind.AC),
Voltage("vgrid1", 36, "On-grid L1 Voltage", Kind.AC),
Voltage("vgrid2", 38, "On-grid L2 Voltage", Kind.AC),
Voltage("vgrid3", 40, "On-grid L3 Voltage", Kind.AC),
Current("igrid1", 42, "On-grid L1 Current", Kind.AC),
Current("igrid2", 44, "On-grid L2 Current", Kind.AC),
Current("igrid3", 46, "On-grid L3 Current", Kind.AC),
Frequency("fgrid1", 48, "On-grid L1 Frequency", Kind.AC),
Frequency("fgrid2", 50, "On-grid L2 Frequency", Kind.AC),
Frequency("fgrid3", 52, "On-grid L3 Frequency", Kind.AC),
Calculated("pgrid1",
lambda data: round(read_voltage(data, 36) * read_current(data, 42)),
"On-grid L1 Power", "W", Kind.AC),
Calculated("pgrid2",
lambda data: round(read_voltage(data, 38) * read_current(data, 44)),
"On-grid L2 Power", "W", Kind.AC),
Calculated("pgrid3",
lambda data: round(read_voltage(data, 40) * read_current(data, 46)),
"On-grid L3 Power", "W", Kind.AC),
Integer("xx54", 54, "Unknown sensor@54"),
Power("ppv", 56, "PV Power", Kind.PV),
Integer("work_mode", 58, "Work Mode code"),
Enum2("work_mode_label", 58, WORK_MODES, "Work Mode"),
Long("error_codes", 60, "Error Codes"),
Integer("warning_code", 64, "Warning code"),
Integer("xx66", 66, "Unknown sensor@66"),
Integer("xx68", 68, "Unknown sensor@68"),
Integer("xx70", 70, "Unknown sensor@70"),
Integer("xx72", 72, "Unknown sensor@72"),
Integer("xx74", 74, "Unknown sensor@74"),
Integer("xx76", 76, "Unknown sensor@76"),
Integer("xx78", 78, "Unknown sensor@78"),
Integer("xx80", 80, "Unknown sensor@80"),
Temp("temperature", 82, "Inverter Temperature", Kind.AC),
Integer("xx84", 84, "Unknown sensor@84"),
Integer("xx86", 86, "Unknown sensor@86"),
Energy("e_day", 88, "Today's PV Generation", Kind.PV),
Energy4("e_total", 90, "Total PV Generation", Kind.PV),
Long("h_total", 94, "Hours Total", "h", Kind.PV),
Integer("safety_country", 98, "Safety Country code", "", Kind.AC),
Enum2("safety_country_label", 98, SAFETY_COUNTRIES, "Safety Country", Kind.AC),
Integer("xx100", 100, "Unknown sensor@100"),
Integer("xx102", 102, "Unknown sensor@102"),
Integer("xx104", 104, "Unknown sensor@104"),
Integer("xx106", 106, "Unknown sensor@106"),
Integer("xx108", 108, "Unknown sensor@108"),
Integer("xx110", 110, "Unknown sensor@110"),
Integer("xx112", 112, "Unknown sensor@112"),
Integer("xx114", 114, "Unknown sensor@114"),
Integer("xx116", 116, "Unknown sensor@116"),
Integer("xx118", 118, "Unknown sensor@118"),
Integer("xx120", 120, "Unknown sensor@120"),
Integer("xx122", 122, "Unknown sensor@122"),
Integer("funbit", 124, "FunBit", "", Kind.PV),
Voltage("vbus", 126, "Bus Voltage", Kind.PV),
Voltage("vnbus", 128, "NBus Voltage", Kind.PV),
Integer("xx130", 130, "Unknown sensor@130"),
Integer("xx132", 132, "Unknown sensor@132"),
Integer("xx134", 134, "Unknown sensor@134"),
Integer("xx136", 136, "Unknown sensor@136"),
Integer("xx138", 138, "Unknown sensor@138"),
Integer("xx140", 140, "Unknown sensor@140"),
Integer("xx142", 142, "Unknown sensor@142"),
Integer("xx144", 144, "Unknown sensor@144"),
)
# Modbus registers of inverter settings, offsets are modbus register addresses
__all_settings: Tuple[Sensor, ...] = (
Timestamp("time", 40313, "Inverter time"),
Integer("shadow_scan", 40326, "Shadow Scan", "", Kind.PV),
Integer("grid_export", 40327, "Grid Export Enabled", "", Kind.GRID),
Integer("grid_export_limit", 40328, "Grid Export Limit", "%", Kind.GRID),
)
# Settings for single phase inverters
__settings_single_phase: Tuple[Sensor, ...] = (
Long("grid_export_limit", 40328, "Grid Export Limit", "W", Kind.GRID),
)
# Settings for three phase inverters
__settings_three_phase: Tuple[Sensor, ...] = (
Integer("grid_export_limit", 40336, "Grid Export Limit", "%", Kind.GRID),
)
def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
super().__init__(host, comm_addr, timeout, retries)
if not self.comm_addr:
# Set the default inverter address
self.comm_addr = 0x7f
self._READ_DEVICE_VERSION_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x7531, 0x0028)
self._READ_DEVICE_RUNNING_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x7594, 0x0049)
self._sensors = self.__all_sensors
self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings}
@staticmethod
def _single_phase_only(s: Sensor) -> bool:
"""Filter to exclude phase2/3 sensors on single phase inverters"""
return not ((s.id_.endswith('2') or s.id_.endswith('3')) and 'pv' not in s.id_ and not s.id_.startswith('xx'))
@staticmethod
def _pv1_pv2_only(s: Sensor) -> bool:
"""Filter to exclude sensors on < 3 PV inverters"""
return not s.id_.endswith('pv3')
async def read_device_info(self):
response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO)
response = response[5:-2]
try:
self.model_name = response[22:32].decode("ascii").rstrip()
except:
print("No model name sent from the inverter.")
self.serial_number = response[6:22].decode("ascii")
self.dsp1_version = read_unsigned_int(response, 66)
self.dsp2_version = read_unsigned_int(response, 68)
self.arm_version = read_unsigned_int(response, 70)
self.firmware = "{}.{}.{:02x}".format(self.dsp1_version, self.dsp2_version, self.arm_version)
if is_single_phase(self):
# this is single phase inverter, filter out all L2 and L3 sensors
self._sensors = tuple(filter(self._single_phase_only, self.__all_sensors))
self._settings.update({s.id_: s for s in self.__settings_single_phase})
else:
self._settings.update({s.id_: s for s in self.__settings_three_phase})
if is_3_mptt(self):
# this is 3 PV strings inverter, keep all sensors
pass
else:
# this is only 2 PV strings inverter
self._sensors = tuple(filter(self._pv1_pv2_only, self._sensors))
pass
async def read_runtime_data(self, include_unknown_sensors: bool = False) -> Dict[str, Any]:
raw_data = await self._read_from_socket(self._READ_DEVICE_RUNNING_DATA)
data = self._map_response(raw_data[5:-2], self._sensors, include_unknown_sensors)
return data
async def read_setting(self, setting_id: str) -> Any:
setting = self._settings.get(setting_id)
if not setting:
raise ValueError(f'Unknown setting "{setting_id}"')
count = (setting.size_ + (setting.size_ % 2)) // 2
raw_data = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count))
with io.BytesIO(raw_data[5:-2]) as buffer:
return setting.read_value(buffer)
async def write_setting(self, setting_id: str, value: Any):
setting = self._settings.get(setting_id)
if not setting:
raise ValueError(f'Unknown setting "{setting_id}"')
raw_value = setting.encode_value(value)
if len(raw_value) <= 2:
value = int.from_bytes(raw_value, byteorder="big", signed=True)
await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value))
else:
await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value))
async def read_settings_data(self) -> Dict[str, Any]:
data = {}
for setting in self.settings():
value = await self.read_setting(setting.id_)
data[setting.id_] = value
return data
async def get_grid_export_limit(self) -> int:
return await self.read_setting('grid_export_limit')
async def set_grid_export_limit(self, export_limit: int) -> None:
setting = self._settings.get('grid_export_limit')
if (setting.unit == "%" and 0 <= export_limit <= 100) or (setting.unit != "%" and 0 <= export_limit <= 10000):
return await self.write_setting('grid_export_limit', export_limit)
async def get_operation_modes(self, include_emulated: bool) -> Tuple[OperationMode, ...]:
return ()
async def get_operation_mode(self) -> OperationMode:
raise InverterError("Operation not supported.")
async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power: int = 100,
eco_mode_soc: int = 100) -> None:
raise InverterError("Operation not supported.")
async def get_ongrid_battery_dod(self) -> int:
raise InverterError("Operation not supported, inverter has no batteries.")
async def set_ongrid_battery_dod(self, dod: int) -> None:
raise InverterError("Operation not supported, inverter has no batteries.")
def sensors(self) -> Tuple[Sensor, ...]:
return self._sensors
def settings(self) -> Tuple[Sensor, ...]:
return tuple(self._settings.values())
+440
View File
@@ -0,0 +1,440 @@
from __future__ import annotations
import logging
from typing import Tuple, cast
from .exceptions import InverterError
from .inverter import Inverter
from .inverter import OperationMode
from .inverter import SensorKind as Kind
from .protocol import ProtocolCommand, Aa55ProtocolCommand, Aa55ReadCommand, Aa55WriteCommand, Aa55WriteMultiCommand, \
ModbusReadCommand, ModbusWriteCommand, ModbusWriteMultiCommand
from .sensor import *
logger = logging.getLogger(__name__)
class ES(Inverter):
"""Class representing inverter of ES/EM/BP family"""
_READ_DEVICE_VERSION_INFO: ProtocolCommand = Aa55ProtocolCommand("010200", "0182")
_READ_DEVICE_RUNNING_DATA: ProtocolCommand = Aa55ProtocolCommand("010600", "0186")
_READ_DEVICE_SETTINGS_DATA: ProtocolCommand = Aa55ProtocolCommand("010900", "0189")
__sensors: Tuple[Sensor, ...] = (
Voltage("vpv1", 0, "PV1 Voltage", Kind.PV), # modbus 0x500
Current("ipv1", 2, "PV1 Current", Kind.PV),
Calculated("ppv1",
lambda data: round(read_voltage(data, 0) * read_current(data, 2)),
"PV1 Power", "W", Kind.PV),
Byte("pv1_mode", 4, "PV1 Mode code", "", Kind.PV),
Enum("pv1_mode_label", 4, PV_MODES, "PV1 Mode", Kind.PV),
Voltage("vpv2", 5, "PV2 Voltage", Kind.PV),
Current("ipv2", 7, "PV2 Current", Kind.PV),
Calculated("ppv2",
lambda data: round(read_voltage(data, 5) * read_current(data, 7)),
"PV2 Power", "W", Kind.PV),
Byte("pv2_mode", 9, "PV2 Mode code", "", Kind.PV),
Enum("pv2_mode_label", 9, PV_MODES, "PV2 Mode", Kind.PV),
Calculated("ppv",
lambda data: round(read_voltage(data, 0) * read_current(data, 2)) + round(
read_voltage(data, 5) * read_current(data, 7)),
"PV Power", "W", Kind.PV),
Voltage("vbattery1", 10, "Battery Voltage", Kind.BAT), # modbus 0x506
# Voltage("vbattery2", 12, "Battery Voltage 2", Kind.BAT),
Integer("battery_status", 14, "Battery Status", "", Kind.BAT),
Temp("battery_temperature", 16, "Battery Temperature", Kind.BAT),
Calculated("ibattery1",
lambda data: abs(read_current(data, 18)) * (-1 if read_byte(data, 30) == 3 else 1),
"Battery Current", "A", Kind.BAT),
# round(vbattery1 * ibattery1),
Calculated("pbattery1",
lambda data: abs(
round(read_voltage(data, 10) * read_current(data, 18))
) * (-1 if read_byte(data, 30) == 3 else 1),
"Battery Power", "W", Kind.BAT),
Integer("battery_charge_limit", 20, "Battery Charge Limit", "A", Kind.BAT),
Integer("battery_discharge_limit", 22, "Battery Discharge Limit", "A", Kind.BAT),
Integer("battery_error", 24, "Battery Error Code", "", Kind.BAT),
Byte("battery_soc", 26, "Battery State of Charge", "%", Kind.BAT), # modbus 0x50E
# Byte("cbattery2", 27, "Battery State of Charge 2", "%", Kind.BAT),
# Byte("cbattery3", 28, "Battery State of Charge 3", "%", Kind.BAT),
Byte("battery_soh", 29, "Battery State of Health", "%", Kind.BAT),
Byte("battery_mode", 30, "Battery Mode code", "", Kind.BAT),
Enum("battery_mode_label", 30, BATTERY_MODES, "Battery Mode", Kind.BAT),
Integer("battery_warning", 31, "Battery Warning", "", Kind.BAT),
Byte("meter_status", 33, "Meter Status code", "", Kind.AC),
Voltage("vgrid", 34, "On-grid Voltage", Kind.AC),
Current("igrid", 36, "On-grid Current", Kind.AC),
Calculated("pgrid",
lambda data: abs(read_bytes2(data, 38)) * (-1 if read_byte(data, 80) == 2 else 1),
"On-grid Export Power", "W", Kind.AC),
Frequency("fgrid", 40, "On-grid Frequency", Kind.AC),
Byte("grid_mode", 42, "Work Mode code", "", Kind.GRID),
Enum("grid_mode_label", 42, WORK_MODES_ES, "Work Mode", Kind.GRID),
Voltage("vload", 43, "Back-up Voltage", Kind.UPS), # modbus 0x51b
Current("iload", 45, "Back-up Current", Kind.UPS),
Power("pload", 47, "On-grid Power", Kind.AC),
Frequency("fload", 49, "Back-up Frequency", Kind.UPS),
Byte("load_mode", 51, "Load Mode code", "", Kind.AC),
Enum("load_mode_label", 51, LOAD_MODES, "Load Mode", Kind.AC),
Byte("work_mode", 52, "Energy Mode code", "", Kind.AC),
Enum("work_mode_label", 52, ENERGY_MODES, "Energy Mode", Kind.AC),
Temp("temperature", 53, "Inverter Temperature"),
Long("error_codes", 55, "Error Codes"),
Energy4("e_total", 59, "Total PV Generation", Kind.PV),
Long("h_total", 63, "Hours Total", "h", Kind.PV),
Energy("e_day", 67, "Today's PV Generation", Kind.PV),
Energy("e_load_day", 69, "Today's Load", Kind.AC),
Energy4("e_load_total", 71, "Total Load", Kind.AC),
Power("total_power", 75, "Total Power", Kind.AC), # modbus 0x52c
Byte("effective_work_mode", 77, "Effective Work Mode code"),
Integer("effective_relay_control", 78, "Effective Relay Control", "", None),
Byte("grid_in_out", 80, "On-grid Mode code", "", Kind.GRID),
Enum("grid_in_out_label", 80, GRID_IN_OUT_MODES, "On-grid Mode", Kind.GRID),
Power("pback_up", 81, "Back-up Power", Kind.UPS),
# pload + pback_up
Calculated("plant_power",
lambda data: round(read_bytes2(data, 47) + read_bytes2(data, 81)),
"Plant Power", "W", Kind.AC),
Decimal("meter_power_factor", 83, 1000, "Meter Power Factor", "", Kind.GRID), # modbus 0x531
Integer("xx85", 85, "Unknown sensor@85"),
Integer("xx87", 87, "Unknown sensor@87"),
Long("diagnose_result", 89, "Diag Status Code"),
EnumBitmap4("diagnose_result_label", 89, DIAG_STATUS_CODES, "Diag Status"),
# Energy4("e_total_exp", 93, "Total Energy (export)", Kind.GRID),
# Energy4("e_total_imp", 97, "Total Energy (import)", Kind.GRID),
# Voltage("vpv3", 101, "PV3 Voltage", Kind.PV), # modbus 0x500
# Current("ipv3", 103, "PV3 Current", Kind.PV),
# Byte("pv3_mode", 104, "PV1 Mode", "", Kind.PV),
# Voltage("vgrid_uo", 105, "On-grid Uo Voltage", Kind.AC),
# Current("igrid_uo", 107, "On-grid Uo Current", Kind.AC),
# Voltage("vgrid_wo", 109, "On-grid Wo Voltage", Kind.AC),
# Current("igrid_wo", 111, "On-grid Wo Current", Kind.AC),
# Energy4("e_bat_charge_total", 113, "Total Battery Charge", Kind.BAT),
# Energy4("e_bat_discharge_total", 117, "Total Battery Discharge", Kind.BAT),
# ppv1 + ppv2 + pbattery - pgrid
Calculated("house_consumption",
lambda data:
round(read_voltage(data, 0) * read_current(data, 2)) +
round(read_voltage(data, 5) * read_current(data, 7)) +
(abs(round(read_voltage(data, 10) * read_current(data, 18))) *
(-1 if read_byte(data, 30) == 3 else 1)) -
(abs(read_bytes2(data, 38)) * (-1 if read_byte(data, 80) == 2 else 1)),
"House Consumption", "W", Kind.AC),
)
__all_settings: Tuple[Sensor, ...] = (
Integer("backup_supply", 12, "Backup Supply"),
Integer("off-grid_charge", 14, "Off-grid Charge"),
Integer("shadow_scan", 16, "Shadow Scan", "", Kind.PV),
Integer("grid_export", 18, "Grid Export Enabled", "", Kind.GRID),
Integer("capacity", 22, "Capacity"),
Integer("charge_v", 24, "Charge Voltage", "V"),
Integer("charge_i", 26, "Charge Current", "A", ),
Integer("discharge_i", 28, "Discharge Current", "A", ),
Integer("discharge_v", 30, "Discharge Voltage", "V"),
Calculated("dod", lambda data: 100 - read_bytes2(data, 32), "Depth of Discharge", "%"),
Integer("battery_activated", 34, "Battery Activated"),
Integer("bp_off_grid_charge", 36, "BP Off-grid Charge"),
Integer("bp_pv_discharge", 38, "BP PV Discharge"),
Integer("bp_bms_protocol", 40, "BP BMS Protocol"),
Integer("power_factor", 42, "Power Factor"),
Integer("grid_export_limit", 52, "Grid Export Limit", "W", Kind.GRID),
Integer("battery_soc_protection", 56, "Battery SoC Protection", "", Kind.BAT),
Integer("work_mode", 66, "Work Mode"),
Integer("grid_quality_check", 68, "Grid Quality Check"),
EcoModeV1("eco_mode_1", 1793, "Eco Mode Group 1"), # 0x701
ByteH("eco_mode_1_switch", 1796, "Eco Mode Group 1 Switch", "", Kind.BAT),
EcoModeV1("eco_mode_2", 1797, "Eco Mode Group 2"),
ByteH("eco_mode_2_switch", 1800, "Eco Mode Group 2 Switch", "", Kind.BAT),
EcoModeV1("eco_mode_3", 1801, "Eco Mode Group 3"),
ByteH("eco_mode_3_switch", 1804, "Eco Mode Group 3 Switch", "", Kind.BAT),
EcoModeV1("eco_mode_4", 1805, "Eco Mode Group 4"),
ByteH("eco_mode_4_switch", 1808, "Eco Mode Group 4 Switch", "", Kind.BAT),
)
# Settings added in ARM firmware 14
__settings_arm_fw_14: Tuple[Sensor, ...] = (
EcoModeV2("eco_mode_1", 47547, "Eco Mode Group 1"),
ByteH("eco_mode_1_switch", 47549, "Eco Mode Group 1 Switch"),
EcoModeV2("eco_mode_2", 47553, "Eco Mode Group 2"),
ByteH("eco_mode_2_switch", 47555, "Eco Mode Group 2 Switch"),
EcoModeV2("eco_mode_3", 47559, "Eco Mode Group 3"),
ByteH("eco_mode_3_switch", 47561, "Eco Mode Group 3 Switch"),
EcoModeV2("eco_mode_4", 47565, "Eco Mode Group 4"),
ByteH("eco_mode_4_switch", 47567, "Eco Mode Group 4 Switch"),
)
def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
super().__init__(host, comm_addr, timeout, retries)
if not self.comm_addr:
# Set the default inverter address
self.comm_addr = 0xf7
self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings}
def _supports_eco_mode_v2(self) -> bool:
if self.arm_version < 14:
return False
if "EMU" in self.serial_number:
return self.dsp1_version >= 11
if "ESU" in self.serial_number:
return self.dsp1_version >= 22
if "BPS" in self.serial_number:
return self.dsp1_version >= 10
return False
async def read_device_info(self):
response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO)
self.firmware = self._decode(response[7:12]).rstrip()
self.model_name = self._decode(response[12:22]).rstrip()
self.serial_number = response[38:54].decode("ascii")
self.software_version = self._decode(response[58:70])
try:
if len(self.firmware) >= 2:
self.dsp1_version = int(self.firmware[0:2])
if len(self.firmware) >= 4:
self.dsp2_version = int(self.firmware[2:4])
if len(self.firmware) >= 5:
self.arm_version = int(self.firmware[4], base=36)
except ValueError:
logger.exception("Error decoding firmware version %s.", self.firmware)
if self._supports_eco_mode_v2():
self._settings.update({s.id_: s for s in self.__settings_arm_fw_14})
async def read_runtime_data(self, include_unknown_sensors: bool = False) -> Dict[str, Any]:
raw_data = await self._read_from_socket(self._READ_DEVICE_RUNNING_DATA)
data = self._map_response(raw_data[7:-2], self.__sensors, include_unknown_sensors)
return data
async def read_setting(self, setting_id: str) -> Any:
if setting_id == 'time':
# Fake setting, just to enable write_setting to work (if checked as pair in read as in HA)
# There does not seem to be time setting/sensor available (or is not known)
return datetime.now()
elif setting_id in ('eco_mode_1', 'eco_mode_2', 'eco_mode_3', 'eco_mode_4'):
setting: Sensor | None = self._settings.get(setting_id)
if not setting:
raise ValueError(f'Unknown setting "{setting_id}"')
count = (setting.size_ + (setting.size_ % 2)) // 2
if self._is_modbus_setting(setting):
raw_data = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count))
with io.BytesIO(raw_data[5:-2]) as buffer:
return setting.read_value(buffer)
else:
raw_data = await self._read_from_socket(Aa55ReadCommand(setting.offset, count))
with io.BytesIO(raw_data[7:-2]) as buffer:
return setting.read_value(buffer)
else:
all_settings = await self.read_settings_data()
return all_settings.get(setting_id)
async def write_setting(self, setting_id: str, value: Any):
if setting_id == 'time':
await self._read_from_socket(
Aa55ProtocolCommand("030206" + Timestamp("time", 0, "").encode_value(value).hex(), "0382")
)
else:
setting: Sensor | None = self._settings.get(setting_id)
if not setting:
raise ValueError(f'Unknown setting "{setting_id}"')
if setting.size_ == 1:
# modbus can address/store only 16 bit values, read the other 8 bytes
if self._is_modbus_setting(setting):
register_data = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, 1))
raw_value = setting.encode_value(value, register_data[5:7])
else:
register_data = await self._read_from_socket(Aa55ReadCommand(self.comm_addr, setting.offset, 1))
raw_value = setting.encode_value(value, register_data[7:9])
else:
raw_value = setting.encode_value(value)
if len(raw_value) <= 2:
value = int.from_bytes(raw_value, byteorder="big", signed=True)
if self._is_modbus_setting(setting):
await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value))
else:
await self._read_from_socket(Aa55WriteCommand(setting.offset, value))
else:
if self._is_modbus_setting(setting):
await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value))
else:
await self._read_from_socket(Aa55WriteMultiCommand(setting.offset, raw_value))
async def read_settings_data(self) -> Dict[str, Any]:
raw_data = await self._read_from_socket(self._READ_DEVICE_SETTINGS_DATA)
data = self._map_response(raw_data[7:-2], self.settings())
return data
async def get_grid_export_limit(self) -> int:
return await self.read_setting('grid_export_limit')
async def set_grid_export_limit(self, export_limit: int) -> None:
if 0 <= export_limit <= 10000:
await self._read_from_socket(
Aa55ProtocolCommand("033502" + "{:04x}".format(export_limit), "03b5")
)
async def get_operation_modes(self, include_emulated: bool) -> Tuple[OperationMode, ...]:
result = [e for e in OperationMode]
result.remove(OperationMode.PEAK_SHAVING)
if not include_emulated:
result.remove(OperationMode.ECO_CHARGE)
result.remove(OperationMode.ECO_DISCHARGE)
return tuple(result)
async def get_operation_mode(self) -> OperationMode:
mode = OperationMode(await self.read_setting('work_mode'))
if OperationMode.ECO != mode:
return mode
ecomode = await self.read_setting('eco_mode_1')
if ecomode.is_eco_charge_mode():
return OperationMode.ECO_CHARGE
elif ecomode.is_eco_discharge_mode():
return OperationMode.ECO_DISCHARGE
else:
return OperationMode.ECO
async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power: int = 100,
eco_mode_soc: int = 100) -> None:
if operation_mode == OperationMode.GENERAL:
await self._set_general_mode()
elif operation_mode == OperationMode.OFF_GRID:
await self._set_offgrid_mode()
elif operation_mode == OperationMode.BACKUP:
await self._set_backup_mode()
elif operation_mode == OperationMode.ECO:
await self._set_eco_mode()
elif operation_mode == OperationMode.PEAK_SHAVING:
raise InverterError("Operation not supported.")
elif operation_mode in (OperationMode.ECO_CHARGE, OperationMode.ECO_DISCHARGE):
if eco_mode_power < 0 or eco_mode_power > 100:
raise ValueError()
if eco_mode_soc < 0 or eco_mode_soc > 100:
raise ValueError()
eco_mode: EcoMode = self._convert_eco_mode(EcoModeV2("", 0, ""))
if operation_mode == OperationMode.ECO_CHARGE:
await self.write_setting('eco_mode_1', eco_mode.encode_charge(eco_mode_power, eco_mode_soc))
else:
await self.write_setting('eco_mode_1', eco_mode.encode_discharge(eco_mode_power))
await self.write_setting('eco_mode_2_switch', 0)
await self.write_setting('eco_mode_3_switch', 0)
await self.write_setting('eco_mode_4_switch', 0)
await self._set_eco_mode()
async def get_ongrid_battery_dod(self) -> int:
return await self.read_setting('dod')
async def set_ongrid_battery_dod(self, dod: int) -> None:
if 0 <= dod <= 89:
await self._read_from_socket(Aa55WriteCommand(0x560, 100 - dod))
async def _reset_inverter(self) -> None:
await self._read_from_socket(Aa55ProtocolCommand("031d00", "039d"))
def sensors(self) -> Tuple[Sensor, ...]:
return self.__sensors
def settings(self) -> Tuple[Sensor, ...]:
return tuple(self._settings.values())
async def _set_general_mode(self) -> None:
if self.arm_version >= 7:
if self._supports_eco_mode_v2():
await self._clear_battery_mode_param()
else:
await self._set_limit_power_for_charge(0, 0, 0, 0, 0)
await self._set_limit_power_for_discharge(0, 0, 0, 0, 0)
await self._clear_battery_mode_param()
else:
await self._set_limit_power_for_charge(0, 0, 0, 0, 0)
await self._set_limit_power_for_discharge(0, 0, 0, 0, 0)
await self._set_offgrid_work_mode(0)
await self._set_work_mode(0)
async def _set_offgrid_mode(self) -> None:
if self.arm_version >= 7:
await self._clear_battery_mode_param()
else:
await self._set_limit_power_for_charge(0, 0, 23, 59, 0)
await self._set_limit_power_for_discharge(0, 0, 0, 0, 0)
await self._set_offgrid_work_mode(1)
await self._set_relay_control(3)
await self._set_store_energy_mode(0)
await self._set_work_mode(1)
async def _set_backup_mode(self) -> None:
if self.arm_version >= 7:
if self._supports_eco_mode_v2():
await self._clear_battery_mode_param()
else:
await self._clear_battery_mode_param()
await self._set_limit_power_for_charge(0, 0, 23, 59, 10)
else:
await self._set_limit_power_for_charge(0, 0, 23, 59, 10)
await self._set_limit_power_for_discharge(0, 0, 0, 0, 0)
await self._set_offgrid_work_mode(0)
await self._set_work_mode(2)
async def _set_eco_mode(self) -> None:
await self._set_offgrid_work_mode(0)
await self._set_work_mode(3)
async def _clear_battery_mode_param(self) -> None:
await self._read_from_socket(Aa55WriteCommand(0x0700, 1))
async def _set_limit_power_for_charge(self, startH: int, startM: int, stopH: int, stopM: int, limit: int) -> None:
if limit < 0 or limit > 100:
raise ValueError()
await self._read_from_socket(Aa55ProtocolCommand("032c05"
+ "{:02x}".format(startH) + "{:02x}".format(startM)
+ "{:02x}".format(stopH) + "{:02x}".format(stopM)
+ "{:02x}".format(limit), "03AC"))
async def _set_limit_power_for_discharge(self, startH: int, startM: int, stopH: int, stopM: int,
limit: int) -> None:
if limit < 0 or limit > 100:
raise ValueError()
await self._read_from_socket(Aa55ProtocolCommand("032d05"
+ "{:02x}".format(startH) + "{:02x}".format(startM)
+ "{:02x}".format(stopH) + "{:02x}".format(stopM)
+ "{:02x}".format(limit), "03AD"))
async def _set_offgrid_work_mode(self, mode: int) -> None:
await self._read_from_socket(Aa55ProtocolCommand("033601" + "{:02x}".format(mode), "03B6"))
async def _set_relay_control(self, mode: int) -> None:
param = 0
if mode == 2:
param = 16
elif mode == 3:
param = 48
await self._read_from_socket(Aa55ProtocolCommand("03270200" + "{:02x}".format(param), "03B7"))
async def _set_store_energy_mode(self, mode: int) -> None:
param = 0
if mode == 0:
param = 4
elif mode == 1:
param = 2
elif mode == 2:
param = 8
elif mode == 3:
param = 1
await self._read_from_socket(Aa55ProtocolCommand("032601" + "{:02x}".format(param), "03B6"))
async def _set_work_mode(self, mode: int) -> None:
await self._read_from_socket(Aa55ProtocolCommand("035901" + "{:02x}".format(mode), "03D9"))
def _convert_eco_mode(self, sensor: Sensor) -> Sensor | EcoMode:
if EcoModeV1 == type(sensor) and self._supports_eco_mode_v2():
return cast(EcoModeV1, sensor).as_eco_mode_v2()
elif EcoModeV2 == type(sensor) and not self._supports_eco_mode_v2():
return cast(EcoModeV2, sensor).as_eco_mode_v1()
else:
return sensor
def _is_modbus_setting(self, sensor: Sensor) -> bool:
return EcoModeV2 == type(sensor) or sensor.offset > 30000
+508
View File
@@ -0,0 +1,508 @@
from __future__ import annotations
import logging
from typing import Tuple, cast
from .inverter import Inverter
from .inverter import OperationMode
from .inverter import SensorKind as Kind
from .model import is_4_mptt, is_single_phase
from .protocol import ProtocolCommand, ModbusReadCommand, ModbusWriteCommand, ModbusWriteMultiCommand
from .sensor import *
logger = logging.getLogger(__name__)
class ET(Inverter):
"""Class representing inverter of ET/EH/BT/BH or GE's GEH families"""
# Modbus registers from offset 0x891c (35100), count 0x7d (125)
__all_sensors: Tuple[Sensor, ...] = (
Timestamp("timestamp", 0, "Timestamp"),
Voltage("vpv1", 6, "PV1 Voltage", Kind.PV),
Current("ipv1", 8, "PV1 Current", Kind.PV),
Power4("ppv1", 10, "PV1 Power", Kind.PV),
Voltage("vpv2", 14, "PV2 Voltage", Kind.PV),
Current("ipv2", 16, "PV2 Current", Kind.PV),
Power4("ppv2", 18, "PV2 Power", Kind.PV),
Voltage("vpv3", 22, "PV3 Voltage", Kind.PV), # modbus35111
Current("ipv3", 24, "PV3 Current", Kind.PV),
Power4("ppv3", 26, "PV3 Power", Kind.PV),
Voltage("vpv4", 30, "PV4 Voltage", Kind.PV),
Current("ipv4", 32, "PV4 Current", Kind.PV),
Power4("ppv4", 34, "PV4 Power", Kind.PV),
# ppv1 + ppv2 + ppv3 + ppv4
Calculated("ppv",
lambda data:
read_bytes4(data, 10) +
read_bytes4(data, 18) +
read_bytes4(data, 26) +
read_bytes4(data, 34),
"PV Power", "W", Kind.PV),
Byte("pv4_mode", 38, "PV4 Mode code", "", Kind.PV),
Enum("pv4_mode_label", 38, PV_MODES, "PV4 Mode", Kind.PV),
Byte("pv3_mode", 39, "PV3 Mode code", "", Kind.PV),
Enum("pv3_mode_label", 39, PV_MODES, "PV3 Mode", Kind.PV),
Byte("pv2_mode", 40, "PV2 Mode code", "", Kind.PV),
Enum("pv2_mode_label", 40, PV_MODES, "PV2 Mode", Kind.PV),
Byte("pv1_mode", 41, "PV1 Mode code", "", Kind.PV),
Enum("pv1_mode_label", 41, PV_MODES, "PV1 Mode", Kind.PV),
Voltage("vgrid", 42, "On-grid L1 Voltage", Kind.AC), # modbus 35121
Current("igrid", 44, "On-grid L1 Current", Kind.AC),
Frequency("fgrid", 46, "On-grid L1 Frequency", Kind.AC),
# 48 reserved
Power("pgrid", 50, "On-grid L1 Power", Kind.AC),
Voltage("vgrid2", 52, "On-grid L2 Voltage", Kind.AC),
Current("igrid2", 54, "On-grid L2 Current", Kind.AC),
Frequency("fgrid2", 56, "On-grid L2 Frequency", Kind.AC),
# 58 reserved
Power("pgrid2", 60, "On-grid L2 Power", Kind.AC),
Voltage("vgrid3", 62, "On-grid L3 Voltage", Kind.AC),
Current("igrid3", 64, "On-grid L3 Current", Kind.AC),
Frequency("fgrid3", 66, "On-grid L3 Frequency", Kind.AC),
# 68 reserved
Power("pgrid3", 70, "On-grid L3 Power", Kind.AC),
Integer("grid_mode", 72, "Grid Mode code", "", Kind.PV),
Enum2("grid_mode_label", 72, GRID_MODES, "Grid Mode", Kind.PV),
# 74 reserved
Power("total_inverter_power", 76, "Total Power", Kind.AC),
# 78 reserved
Power("active_power", 80, "Active Power", Kind.GRID),
Calculated("grid_in_out",
lambda data: read_grid_mode(data, 80),
"On-grid Mode code", "", Kind.GRID),
EnumCalculated("grid_in_out_label",
lambda data: read_grid_mode(data, 80), GRID_IN_OUT_MODES,
"On-grid Mode", Kind.GRID),
# 82 reserved
Reactive("reactive_power", 84, "Reactive Power", Kind.GRID),
# 86 reserved
Apparent("apparent_power", 88, "Apparent Power", Kind.GRID),
Voltage("backup_v1", 90, "Back-up L1 Voltage", Kind.UPS), # modbus 35145
Current("backup_i1", 92, "Back-up L1 Current", Kind.UPS),
Frequency("backup_f1", 94, "Back-up L1 Frequency", Kind.UPS),
Integer("load_mode1", 96, "Load Mode L1"),
# 98 reserved
Power("backup_p1", 100, "Back-up L1 Power", Kind.UPS),
Voltage("backup_v2", 102, "Back-up L2 Voltage", Kind.UPS),
Current("backup_i2", 104, "Back-up L2 Current", Kind.UPS),
Frequency("backup_f2", 106, "Back-up L2 Frequency", Kind.UPS),
Integer("load_mode2", 108, "Load Mode L2"),
# 110 reserved
Power("backup_p2", 112, "Back-up L2 Power", Kind.UPS),
Voltage("backup_v3", 114, "Back-up L3 Voltage", Kind.UPS),
Current("backup_i3", 116, "Back-up L3 Current", Kind.UPS),
Frequency("backup_f3", 118, "Back-up L3 Frequency", Kind.UPS),
Integer("load_mode3", 120, "Load Mode L3"),
# 122 reserved
Power("backup_p3", 124, "Back-up L3 Power", Kind.UPS),
# 126 reserved
Power("load_p1", 128, "Load L1", Kind.AC),
# 130 reserved
Power("load_p2", 132, "Load L2", Kind.AC),
# 134 reserved
Power("load_p3", 136, "Load L3", Kind.AC),
# 138 reserved
Power("backup_ptotal", 140, "Back-up Load", Kind.UPS),
# 142 reserved
Power("load_ptotal", 144, "Load", Kind.AC),
Integer("ups_load", 146, "Ups Load", "%", Kind.UPS),
Temp("temperature_air", 148, "Inverter Temperature (Air)", Kind.AC),
Temp("temperature_module", 150, "Inverter Temperature (Module)"),
Temp("temperature", 152, "Inverter Temperature (Radiator)", Kind.AC),
Integer("function_bit", 154, "Function Bit"),
Voltage("bus_voltage", 156, "Bus Voltage", None),
Voltage("nbus_voltage", 158, "NBus Voltage", None),
Voltage("vbattery1", 160, "Battery Voltage", Kind.BAT), # modbus 35180
Current("ibattery1", 162, "Battery Current", Kind.BAT),
# round(vbattery1 * ibattery1),
Calculated("pbattery1",
lambda data: round(read_voltage(data, 160) * read_current(data, 162)),
"Battery Power", "W", Kind.BAT),
Integer("battery_mode", 168, "Battery Mode code", "", Kind.BAT),
Enum2("battery_mode_label", 168, BATTERY_MODES, "Battery Mode", Kind.BAT),
Integer("warning_code", 170, "Warning code"),
Integer("safety_country", 172, "Safety Country code", "", Kind.AC),
Enum2("safety_country_label", 172, SAFETY_COUNTRIES, "Safety Country", Kind.AC),
Integer("work_mode", 174, "Work Mode code"),
Enum2("work_mode_label", 174, WORK_MODES_ET, "Work Mode"),
Integer("operation_mode", 176, "Operation Mode code"),
Long("error_codes", 178, "Error Codes"),
EnumBitmap4("errors", 178, ERROR_CODES, "Errors"),
Energy4("e_total", 182, "Total PV Generation", Kind.PV),
Energy4("e_day", 186, "Today's PV Generation", Kind.PV),
Energy4("e_total_exp", 190, "Total Energy (export)", Kind.AC),
Long("h_total", 194, "Hours Total", "h", Kind.PV),
Energy("e_day_exp", 198, "Today Energy (export)", Kind.AC),
Energy4("e_total_imp", 200, "Total Energy (import)", Kind.AC),
Energy("e_day_imp", 204, "Today Energy (import)", Kind.AC),
Energy4("e_load_total", 206, "Total Load", Kind.AC),
Energy("e_load_day", 210, "Today Load", Kind.AC),
Energy4("e_bat_charge_total", 212, "Total Battery Charge", Kind.BAT),
Energy("e_bat_charge_day", 216, "Today Battery Charge", Kind.BAT),
Energy4("e_bat_discharge_total", 218, "Total Battery Discharge", Kind.BAT),
Energy("e_bat_discharge_day", 222, "Today Battery Discharge", Kind.BAT),
Long("diagnose_result", 240, "Diag Status Code"),
EnumBitmap4("diagnose_result_label", 240, DIAG_STATUS_CODES, "Diag Status"),
# ppv1 + ppv2 + pbattery - active_power
Calculated("house_consumption",
lambda data:
read_bytes4(data, 10) +
read_bytes4(data, 18) +
read_bytes4(data, 26) +
read_bytes4(data, 34) +
round(read_voltage(data, 160) * read_current(data, 162)) -
read_bytes2(data, 80),
"House Consumption", "W", Kind.AC),
)
# Modbus registers from offset 0x9088 (37000)
__all_sensors_battery: Tuple[Sensor, ...] = (
Integer("battery_bms", 0, "Battery BMS", "", Kind.BAT),
Integer("battery_index", 2, "Battery Index", "", Kind.BAT),
Integer("battery_status", 4, "Battery Status", "", Kind.BAT),
Temp("battery_temperature", 6, "Battery Temperature", Kind.BAT),
Integer("battery_charge_limit", 8, "Battery Charge Limit", "A", Kind.BAT),
Integer("battery_discharge_limit", 10, "Battery Discharge Limit", "A", Kind.BAT),
Integer("battery_error_l", 12, "Battery Error L", "", Kind.BAT),
Integer("battery_soc", 14, "Battery State of Charge", "%", Kind.BAT),
Integer("battery_soh", 16, "Battery State of Health", "%", Kind.BAT),
Integer("battery_modules", 18, "Battery Modules", "", Kind.BAT), # modbus 37009
Integer("battery_warning_l", 20, "Battery Warning L", "", Kind.BAT),
Integer("battery_protocol", 22, "Battery Protocol", "", Kind.BAT),
Integer("battery_error_h", 24, "Battery Error H", "", Kind.BAT),
EnumBitmap22("battery_error", 24, 12, BMS_ALARM_CODES, "Battery Error", Kind.BAT),
Integer("battery_warning_h", 28, "Battery Warning H", "", Kind.BAT),
EnumBitmap22("battery_warning", 28, 20, BMS_WARNING_CODES, "Battery Warning", Kind.BAT),
Integer("battery_sw_version", 30, "Battery Software Version", "", Kind.BAT),
Integer("battery_hw_version", 32, "Battery Hardware Version", "", Kind.BAT),
Integer("battery_max_cell_temp_id", 34, "Battery Max Cell Temperature ID", "", Kind.BAT),
Integer("battery_min_cell_temp_id", 36, "Battery Min Cell Temperature ID", "", Kind.BAT),
Integer("battery_max_cell_voltage_id", 38, "Battery Max Cell Voltage ID", "", Kind.BAT),
Integer("battery_min_cell_voltage_id", 40, "Battery Min Cell Voltage ID", "", Kind.BAT),
Temp("battery_max_cell_temp", 42, "Battery Max Cell Temperature", Kind.BAT),
Temp("battery_min_cell_temp", 44, "Battery Min Cell Temperature", Kind.BAT),
Voltage("battery_max_cell_voltage", 46, "Battery Max Cell Voltage", Kind.BAT),
Voltage("battery_min_cell_voltage", 48, "Battery Min Cell Voltage", Kind.BAT),
)
# Inverter's meter data
# Modbus registers from offset 0x8ca0 (36000)
__all_sensors_meter: Tuple[Sensor, ...] = (
Integer("commode", 0, "Commode"),
Integer("rssi", 2, "RSSI"),
Integer("manufacture_code", 4, "Manufacture Code"),
Integer("meter_test_status", 6, "Meter Test Status"), # 1: correct2: reverse3: incorrect0: not checked
Integer("meter_comm_status", 8, "Meter Communication Status"), # 1 OK, 0 NotOK
Power("active_power1", 10, "Active Power L1", Kind.GRID), # modbus 36005
Power("active_power2", 12, "Active Power L2", Kind.GRID),
Power("active_power3", 14, "Active Power L3", Kind.GRID),
Power("active_power_total", 16, "Active Power Total", Kind.GRID),
Reactive("reactive_power_total", 18, "Reactive Power Total", Kind.GRID),
Decimal("meter_power_factor1", 20, 1000, "Meter Power Factor L1", "", Kind.GRID),
Decimal("meter_power_factor2", 22, 1000, "Meter Power Factor L2", "", Kind.GRID),
Decimal("meter_power_factor3", 24, 1000, "Meter Power Factor L3", "", Kind.GRID),
Decimal("meter_power_factor", 26, 1000, "Meter Power Factor", "", Kind.GRID),
Frequency("meter_freq", 28, "Meter Frequency", Kind.GRID), # modbus 36014
Float("meter_e_total_exp", 30, 1000, "Meter Total Energy (export)", "kWh", Kind.GRID),
Float("meter_e_total_imp", 34, 1000, "Meter Total Energy (import)", "kWh", Kind.GRID),
Power4("meter_active_power1", 38, "Meter Active Power L1", Kind.GRID),
Power4("meter_active_power2", 42, "Meter Active Power L2", Kind.GRID),
Power4("meter_active_power3", 46, "Meter Active Power L3", Kind.GRID),
Power4("meter_active_power_total", 50, "Meter Active Power Total", Kind.GRID),
Reactive4("meter_reactive_power1", 54, "Meter Reactive Power L1", Kind.GRID),
Reactive4("meter_reactive_power2", 58, "Meter Reactive Power L2", Kind.GRID),
Reactive4("meter_reactive_power3", 62, "Meter Reactive Power L2", Kind.GRID),
Reactive4("meter_reactive_power_total", 66, "Meter Reactive Power Total", Kind.GRID),
Apparent4("meter_apparent_power1", 70, "Meter Apparent Power L1", Kind.GRID),
Apparent4("meter_apparent_power2", 74, "Meter Apparent Power L2", Kind.GRID),
Apparent4("meter_apparent_power3", 78, "Meter Apparent Power L3", Kind.GRID),
Apparent4("meter_apparent_power_total", 82, "Meter Apparent Power Total", Kind.GRID),
Integer("meter_type", 86, "Meter Type", "", Kind.GRID),
Integer("meter_sw_version", 88, "Meter Software Version", "", Kind.GRID),
)
# Modbus registers of inverter settings, offsets are modbus register addresses
__all_settings: Tuple[Sensor, ...] = (
Integer("comm_address", 45127, "Communication Address", ""),
Timestamp("time", 45200, "Inverter time"),
Integer("sensitivity_check", 45246, "Sensitivity Check Mode", "", Kind.AC),
Integer("cold_start", 45248, "Cold Start", "", Kind.AC),
Integer("shadow_scan", 45251, "Shadow Scan", "", Kind.PV),
Integer("backup_supply", 45252, "Backup Supply", "", Kind.UPS),
Integer("unbalanced_output", 45264, "Unbalanced Output", "", Kind.AC),
Integer("pen_relay", 45288, "PE-N Relay", "", Kind.AC),
Integer("battery_capacity", 45350, "Battery Capacity", "Ah", Kind.BAT),
Integer("battery_modules", 45351, "Battery Modules", "", Kind.BAT),
Voltage("battery_charge_voltage", 45352, "Battery Charge Voltage", Kind.BAT),
Current("battery_charge_current", 45353, "Battery Charge Current", Kind.BAT),
Voltage("battery_discharge_voltage", 45354, "Battery Discharge Voltage", Kind.BAT),
Current("battery_discharge_current", 45355, "Battery Discharge Current", Kind.BAT),
Integer("battery_discharge_depth", 45356, "Battery Discharge Depth", "%", Kind.BAT),
Voltage("battery_discharge_voltage_offline", 45357, "Battery Discharge Voltage (off-line)", Kind.BAT),
Integer("battery_discharge_depth_offline", 45358, "Battery Discharge Depth (off-line)", "%", Kind.BAT),
Decimal("power_factor", 45482, 100, "Power Factor"),
Integer("work_mode", 47000, "Work Mode", "", Kind.AC),
Integer("dred", 47010, "DRED/Remote Shutdown", "", Kind.AC),
Integer("battery_soc_protection", 47500, "Battery SoC Protection", "", Kind.BAT),
Integer("grid_export", 47509, "Grid Export Enabled", "", Kind.GRID),
Integer("grid_export_limit", 47510, "Grid Export Limit", "W", Kind.GRID),
Integer("battery_protocol_code", 47514, "Battery Protocol Code", "", Kind.BAT),
EcoModeV1("eco_mode_1", 47515, "Eco Mode Group 1"),
ByteH("eco_mode_1_switch", 47518, "Eco Mode Group 1 Switch"),
EcoModeV1("eco_mode_2", 47519, "Eco Mode Group 2"),
ByteH("eco_mode_2_switch", 47522, "Eco Mode Group 2 Switch"),
EcoModeV1("eco_mode_3", 47523, "Eco Mode Group 3"),
ByteH("eco_mode_3_switch", 47526, "Eco Mode Group 3 Switch"),
EcoModeV1("eco_mode_4", 47527, "Eco Mode Group 4"),
ByteH("eco_mode_4_switch", 47530, "Eco Mode Group 4 Switch"),
)
# Settings added in ARM firmware 19
__settings_arm_fw_19: Tuple[Sensor, ...] = (
Integer("fast_charging", 47545, "Fast Charging Enabled", "", Kind.BAT),
Integer("fast_charging_soc", 47546, "Fast Charging SoC", "%", Kind.BAT),
EcoModeV2("eco_mode_1", 47547, "Eco Mode Group 1"),
ByteH("eco_mode_1_switch", 47549, "Eco Mode Group 1 Switch"),
EcoModeV2("eco_mode_2", 47553, "Eco Mode Group 2"),
ByteH("eco_mode_2_switch", 47555, "Eco Mode Group 2 Switch"),
EcoModeV2("eco_mode_3", 47559, "Eco Mode Group 3"),
ByteH("eco_mode_3_switch", 47561, "Eco Mode Group 3 Switch"),
EcoModeV2("eco_mode_4", 47565, "Eco Mode Group 4"),
ByteH("eco_mode_4_switch", 47567, "Eco Mode Group 4 Switch"),
Integer("load_control_mode", 47595, "Load Control Mode", "", Kind.AC),
Integer("load_control_switch", 47596, "Load Control Switch", "", Kind.AC),
Integer("load_control_soc", 47596, "Load Control SoC", "", Kind.AC),
Integer("fast_charging_power", 47603, "Fast Charging Power", "%", Kind.BAT),
)
# Settings added in ARM firmware 22
__settings_arm_fw_22: Tuple[Sensor, ...] = (
# EcoModeV2("eco_modeV2_5", 47571, "Eco Mode Version 2 Power Group 5"),
# EcoModeV2("eco_modeV2_6", 47577, "Eco Mode Version 2 Power Group 6"),
# EcoModeV2("eco_modeV2_7", 47583, "Eco Mode Version 2 Power Group 7"),
PeakShavingMode("peak_shaving_mode", 47589, "Peak Shaving Mode"),
Integer("dod_holding", 47602, "DoD Holding", "", Kind.BAT),
)
def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
super().__init__(host, comm_addr, timeout, retries)
if not self.comm_addr:
# Set the default inverter address
self.comm_addr = 0xf7
self._READ_DEVICE_VERSION_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x88b8, 0x0021)
self._READ_RUNNING_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x891c, 0x007d)
self._READ_METER_DATA: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x8ca0, 0x2d)
self._READ_BATTERY_INFO: ProtocolCommand = ModbusReadCommand(self.comm_addr, 0x9088, 0x0018)
self._has_battery: bool = True
# By default, we set up only PV1 on PV2 sensors, only few inverters support PV3 and PV4
# In case they are needed, they are added later in read_device_info
self._sensors = tuple(filter(self._pv1_pv2_only, self.__all_sensors))
self._sensors_battery = self.__all_sensors_battery
self._sensors_meter = self.__all_sensors_meter
self._settings: dict[str, Sensor] = {s.id_: s for s in self.__all_settings}
def _supports_eco_mode_v2(self) -> bool:
return self.arm_version >= 19
def _supports_peak_shaving(self) -> bool:
return self.arm_version >= 22
@staticmethod
def _single_phase_only(s: Sensor) -> bool:
"""Filter to exclude phase2/3 sensors on single phase inverters"""
return not ((s.id_.endswith('2') or s.id_.endswith('3')) and 'pv' not in s.id_)
@staticmethod
def _pv1_pv2_only(s: Sensor) -> bool:
"""Filter to exclude sensors on < 4 PV inverters"""
return not (('pv3' in s.id_) or ('pv4' in s.id_))
async def read_device_info(self):
response = await self._read_from_socket(self._READ_DEVICE_VERSION_INFO)
response = response[5:-2]
# Modbus registers from offset (35000)
self.modbus_version = read_unsigned_int(response, 0)
self.rated_power = read_unsigned_int(response, 2)
self.ac_output_type = read_unsigned_int(response, 4) # 0: 1-phase, 1: 3-phase (4 wire), 2: 3-phase (3 wire)
self.serial_number = response[6:22].decode("ascii")
self.model_name = response[22:32].decode("ascii").rstrip()
self.dsp1_version = read_unsigned_int(response, 32)
self.dsp2_version = read_unsigned_int(response, 34)
self.dsp_svn_version = read_unsigned_int(response, 36)
self.arm_version = read_unsigned_int(response, 38)
self.arm_svn_version = read_unsigned_int(response, 40)
self.firmware = self._decode(response[42:54])
self.arm_firmware = self._decode(response[54:66])
if is_4_mptt(self):
# this is PV3/PV4 re-include all sensors
self._sensors = tuple(self.__all_sensors)
self._sensors_meter = tuple(self._sensors_meter)
if is_single_phase(self):
# this is single phase inverter, filter out all L2 and L3 sensors
self._sensors = tuple(filter(self._single_phase_only, self._sensors))
self._sensors_meter = tuple(filter(self._single_phase_only, self._sensors_meter))
if self.arm_version >= 19:
self._settings.update({s.id_: s for s in self.__settings_arm_fw_19})
if self.arm_version >= 22:
self._settings.update({s.id_: s for s in self.__settings_arm_fw_22})
async def read_runtime_data(self, include_unknown_sensors: bool = False) -> Dict[str, Any]:
raw_data = await self._read_from_socket(self._READ_RUNNING_DATA)
data = self._map_response(raw_data[5:-2], self._sensors, include_unknown_sensors)
self._has_battery = data.get('battery_mode', 0) != 0
if self._has_battery:
raw_data = await self._read_from_socket(self._READ_BATTERY_INFO)
data.update(self._map_response(raw_data[5:-2], self._sensors_battery, include_unknown_sensors))
raw_data = await self._read_from_socket(self._READ_METER_DATA)
data.update(self._map_response(raw_data[5:-2], self._sensors_meter, include_unknown_sensors))
return data
async def read_setting(self, setting_id: str) -> Any:
setting = self._settings.get(setting_id)
if not setting:
raise ValueError(f'Unknown setting "{setting_id}"')
count = (setting.size_ + (setting.size_ % 2)) // 2
raw_data = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, count))
with io.BytesIO(raw_data[5:-2]) as buffer:
return setting.read_value(buffer)
async def write_setting(self, setting_id: str, value: Any):
setting = self._settings.get(setting_id)
if not setting:
raise ValueError(f'Unknown setting "{setting_id}"')
if setting.size_ == 1:
# modbus can address/store only 16 bit values, read the other 8 bytes
register_data = await self._read_from_socket(ModbusReadCommand(self.comm_addr, setting.offset, 1))
raw_value = setting.encode_value(value, register_data[5:7])
else:
raw_value = setting.encode_value(value)
if len(raw_value) <= 2:
value = int.from_bytes(raw_value, byteorder="big", signed=True)
await self._read_from_socket(ModbusWriteCommand(self.comm_addr, setting.offset, value))
else:
await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, setting.offset, raw_value))
async def read_settings_data(self) -> Dict[str, Any]:
data = {}
for setting in self.settings():
try:
value = await self.read_setting(setting.id_)
data[setting.id_] = value
except ValueError:
logger.exception("Error reading setting %s.", setting.id_)
data[setting.id_] = None
return data
async def get_grid_export_limit(self) -> int:
return await self.read_setting('grid_export_limit')
async def set_grid_export_limit(self, export_limit: int) -> None:
if 0 <= export_limit <= 10000:
await self.write_setting('grid_export_limit', export_limit)
async def get_operation_modes(self, include_emulated: bool) -> Tuple[OperationMode, ...]:
result = [e for e in OperationMode]
if not self._supports_peak_shaving():
result.remove(OperationMode.PEAK_SHAVING)
if not include_emulated:
result.remove(OperationMode.ECO_CHARGE)
result.remove(OperationMode.ECO_DISCHARGE)
return tuple(result)
async def get_operation_mode(self) -> OperationMode:
mode = OperationMode(await self.read_setting('work_mode'))
if OperationMode.ECO != mode:
return mode
ecomode = await self.read_setting('eco_mode_1')
if ecomode.is_eco_charge_mode():
return OperationMode.ECO_CHARGE
elif ecomode.is_eco_discharge_mode():
return OperationMode.ECO_DISCHARGE
else:
return OperationMode.ECO
async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power: int = 100,
eco_mode_soc: int = 100) -> None:
if operation_mode == OperationMode.GENERAL:
await self.write_setting('work_mode', 0)
await self._set_offline(False)
await self._clear_battery_mode_param()
elif operation_mode == OperationMode.OFF_GRID:
await self.write_setting('work_mode', 1)
await self._set_offline(True)
await self.write_setting('backup_supply', 1)
await self.write_setting('cold_start', 4)
elif operation_mode == OperationMode.BACKUP:
await self.write_setting('work_mode', 2)
await self._set_offline(False)
await self._clear_battery_mode_param()
elif operation_mode == OperationMode.ECO:
await self.write_setting('work_mode', 3)
await self._set_offline(False)
elif operation_mode == OperationMode.PEAK_SHAVING:
await self.write_setting('work_mode', 4)
await self._set_offline(False)
elif operation_mode in (OperationMode.ECO_CHARGE, OperationMode.ECO_DISCHARGE):
if eco_mode_power < 0 or eco_mode_power > 100:
raise ValueError()
if eco_mode_soc < 0 or eco_mode_soc > 100:
raise ValueError()
eco_mode: EcoMode = self._convert_eco_mode(EcoModeV2("", 0, ""))
if operation_mode == OperationMode.ECO_CHARGE:
await self.write_setting('eco_mode_1', eco_mode.encode_charge(eco_mode_power, eco_mode_soc))
else:
await self.write_setting('eco_mode_1', eco_mode.encode_discharge(eco_mode_power))
await self.write_setting('eco_mode_2_switch', 0)
await self.write_setting('eco_mode_3_switch', 0)
await self.write_setting('eco_mode_4_switch', 0)
await self.write_setting('work_mode', 3)
await self._set_offline(False)
async def get_ongrid_battery_dod(self) -> int:
return 100 - await self.read_setting('battery_discharge_depth')
async def set_ongrid_battery_dod(self, dod: int) -> None:
if 0 <= dod <= 90:
await self.write_setting('battery_discharge_depth', 100 - dod)
def sensors(self) -> Tuple[Sensor, ...]:
if self._has_battery:
return self._sensors + self._sensors_battery + self._sensors_meter
else:
return self._sensors + self._sensors_meter
def settings(self) -> Tuple[Sensor, ...]:
return tuple(self._settings.values())
async def _clear_battery_mode_param(self) -> None:
await self._read_from_socket(ModbusWriteCommand(self.comm_addr, 0xb9ad, 1))
async def _set_offline(self, mode: bool) -> None:
value = bytes.fromhex('00070000') if mode else bytes.fromhex('00010000')
await self._read_from_socket(ModbusWriteMultiCommand(self.comm_addr, 0xb997, value))
def _convert_eco_mode(self, sensor: Sensor) -> Sensor | EcoMode:
if EcoModeV1 == type(sensor) and self._supports_eco_mode_v2():
return cast(EcoModeV1, sensor).as_eco_mode_v2()
elif EcoModeV2 == type(sensor) and not self._supports_eco_mode_v2():
return cast(EcoModeV2, sensor).as_eco_mode_v1()
else:
return sensor
+37
View File
@@ -0,0 +1,37 @@
class InverterError(Exception):
"""Indicates error communicating with inverter"""
class RequestFailedException(InverterError):
"""
Indicates request sent to inverter has failed and did not yield in valid response,
even after several retries.
Attributes:
message -- explanation of the error
consecutive_failures_count -- number requests failed in a consecutive streak
"""
def __init__(self, message: str = '', consecutive_failures_count: int = 0):
self.message: str = message
self.consecutive_failures_count: int = consecutive_failures_count
class RequestRejectedException(InverterError):
"""
Indicates request sent to inverter was rejected and protocol exception response was received.
Attributes:
message -- rejection reason
"""
def __init__(self, message: str = ''):
self.message: str = message
class ProcessingException(InverterError):
"""Indicates an error occurred during processing of inverter data"""
class MaxRetriesException(InverterError):
"""Indicates the maximum number of retries has been reached"""
+22
View File
@@ -0,0 +1,22 @@
import logging
from typing import Tuple
from .exceptions import ProcessingException
from .processor import ProcessorResult, AbstractDataProcessor
from .xs import GoodWeXSProcessor
logger = logging.getLogger(__name__)
class GoodWeInverter:
def __init__(self, inverter_address: Tuple[str, int], processor: AbstractDataProcessor):
self.address = inverter_address
self.processor = processor
async def request_data(self) -> ProcessorResult:
try:
logger.debug('awaiting future')
data = await self.processor.get_runtime_data_command().execute(self.address[0], 1, 3)
return self.processor.process_data(data)
except (TypeError, ValueError) as e:
logger.debug(f'exception occurred during processing inverter data: {e}')
raise ProcessingException
+305
View File
@@ -0,0 +1,305 @@
from __future__ import annotations
import asyncio
import io
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum, IntEnum
from typing import Any, Callable, Dict, Tuple, Optional
from .exceptions import MaxRetriesException, RequestFailedException
from .protocol import ProtocolCommand
logger = logging.getLogger(__name__)
class SensorKind(Enum):
"""
Enumeration of sensor kinds.
Possible values are:
PV - inverter photo-voltaic (e.g. dc voltage of pv panels)
AC - inverter grid output (e.g. ac voltage of grid connected output)
UPS - inverter ups/eps/backup output (e.g. ac voltage of backup/off-grid connected output)
BAT - battery (e.g. dc voltage of connected battery pack)
GRID - power grid/smart meter (e.g. active power exported to grid)
"""
PV = 1
AC = 2
UPS = 3
BAT = 4
GRID = 5
@dataclass
class Sensor:
"""Definition of inverter sensor and its attributes"""
id_: str
offset: int
name: str
size_: int
unit: str
kind: Optional[SensorKind]
def read_value(self, data: io.BytesIO) -> Any:
"""Read the sensor value from data at current position"""
raise NotImplementedError()
def read(self, data: io.BytesIO) -> Any:
"""Read the sensor value from data (at sensor offset)"""
data.seek(self.offset)
return self.read_value(data)
def encode_value(self, value: Any) -> bytes:
"""Encode the (setting mostly) value to (usually) 2 byte raw register value"""
raise NotImplementedError()
class OperationMode(IntEnum):
"""
Enumeration of sensor kinds.
Possible values are:
GENERAL - General mode
OFF_GRID - Off grid mode
BACKUP - Backup mode
ECO - Eco mode
PEAK_SHAVING - Peak shaving mode
ECO_CHARGE - Eco mode with a single "Charge" group valid all the time (from 00:00-23:59, Mon-Sun)
ECO_DISCHARGE - Eco mode with a single "Discharge" group valid all the time (from 00:00-23:59, Mon-Sun)
"""
GENERAL = 0
OFF_GRID = 1
BACKUP = 2
ECO = 3
PEAK_SHAVING = 4
ECO_CHARGE = 5
ECO_DISCHARGE = 6
class Inverter(ABC):
"""
Common superclass for various inverter models implementations.
Represents the inverter state and its basic behavior
"""
def __init__(self, host: str, comm_addr: int = 0, timeout: int = 1, retries: int = 3):
self.host: str = host
self.comm_addr: int = comm_addr
self.timeout: int = timeout
self.retries: int = retries
self._running_loop: asyncio.AbstractEventLoop | None = None
self._lock: asyncio.Lock | None = None
self._consecutive_failures_count: int = 0
self.model_name: str | None = None
self.serial_number: str | None = None
self.rated_power: int | None = None
self.ac_output_type: int | None = None
self.firmware: str | None = None
self.arm_firmware: str | None = None
self.modbus_version: int | None = None
self.dsp1_version: int = 0
self.dsp2_version: int = 0
self.dsp_svn_version: int | None = None
self.arm_version: int = 0
self.arm_svn_version: int | None = None
def _ensure_lock(self) -> asyncio.Lock:
"""Validate (or create) asyncio Lock.
The asyncio.Lock must always be created from within's asyncio loop,
so it cannot be eagerly created in constructor.
Additionally, since asyncio.run() creates and closes its own loop,
the lock's scope (its creating loop) mus be verified to support proper
behavior in subsequent asyncio.run() invocations.
"""
if self._lock and self._running_loop == asyncio.get_event_loop():
return self._lock
else:
logger.debug("Creating lock instance for current event loop.")
self._lock = asyncio.Lock()
self._running_loop = asyncio.get_event_loop()
return self._lock
async def _read_from_socket(self, command: ProtocolCommand) -> bytes:
async with self._ensure_lock():
try:
result = await command.execute(self.host, self.timeout, self.retries)
self._consecutive_failures_count = 0
return result
except MaxRetriesException:
self._consecutive_failures_count += 1
raise RequestFailedException(f'No valid response received even after {self.retries} retries',
self._consecutive_failures_count)
except RequestFailedException as ex:
self._consecutive_failures_count += 1
raise RequestFailedException(ex.message, self._consecutive_failures_count)
@abstractmethod
async def read_device_info(self):
"""
Request the device information from the inverter.
The inverter instance variables will be loaded with relevant data.
"""
raise NotImplementedError()
@abstractmethod
async def read_runtime_data(self, include_unknown_sensors: bool = False) -> Dict[str, Any]:
"""
Request the runtime data from the inverter.
Answer dictionary of individual sensors and their values.
List of supported sensors (and their definitions) is provided by sensors() method.
If include_unknown_sensors parameter is set to True, return all runtime values,
including those "xx*" sensors whose meaning is not yet identified.
"""
raise NotImplementedError()
@abstractmethod
async def read_setting(self, setting_id: str) -> Any:
"""
Read the value of specific inverter setting/configuration parameter.
Setting must be in list provided by settings() method, otherwise ValueError is raised.
"""
raise NotImplementedError()
@abstractmethod
async def write_setting(self, setting_id: str, value: Any):
"""
Set the value of specific inverter settings/configuration parameter.
Setting must be in list provided by settings() method, otherwise ValueError is raised.
BEWARE !!!
This method modifies inverter operational parameter (usually accessible to installers only).
Use with caution and at your own risk !
"""
raise NotImplementedError()
@abstractmethod
async def read_settings_data(self) -> Dict[str, Any]:
"""
Request the settings data from the inverter.
Answer dictionary of individual settings and their values.
List of supported settings (and their definitions) is provided by settings() method.
"""
raise NotImplementedError()
async def send_command(
self, command: bytes, validator: Callable[[bytes], bool] = lambda x: True
) -> bytes:
"""
Send low level udp command (as bytes).
Answer command's raw response data.
"""
return await self._read_from_socket(ProtocolCommand(command, validator))
@abstractmethod
async def get_grid_export_limit(self) -> int:
"""
Get the current grid export limit in W
"""
raise NotImplementedError()
@abstractmethod
async def set_grid_export_limit(self, export_limit: int) -> None:
"""
BEWARE !!!
This method modifies inverter operational parameter accessible to installers only.
Use with caution and at your own risk !
Set the grid export limit in W
"""
raise NotImplementedError()
@abstractmethod
async def get_operation_modes(self, include_emulated: bool) -> Tuple[OperationMode, ...]:
"""
Answer list of supported inverter operation modes
"""
return ()
@abstractmethod
async def get_operation_mode(self) -> OperationMode:
"""
Get the inverter operation mode
"""
raise NotImplementedError()
@abstractmethod
async def set_operation_mode(self, operation_mode: OperationMode, eco_mode_power: int = 100,
eco_mode_soc: int = 100) -> None:
"""
BEWARE !!!
This method modifies inverter operational parameter accessible to installers only.
Use with caution and at your own risk !
Set the inverter operation mode
The modes ECO_CHARGE and ECO_DISCHARGE are not real inverter operation modes, but a convenience
shortcuts to enter Eco Mode with a single group valid all the time (from 00:00-23:59, Mon-Sun)
charging or discharging with optional charging power and SoC (%) parameters.
"""
raise NotImplementedError()
@abstractmethod
async def get_ongrid_battery_dod(self) -> int:
"""
Get the On-Grid Battery DoD
0% - 89%
"""
raise NotImplementedError()
@abstractmethod
async def set_ongrid_battery_dod(self, dod: int) -> None:
"""
BEWARE !!!
This method modifies On-Grid Battery DoD parameter accessible to installers only.
Use with caution and at your own risk !
Set the On-Grid Battery DoD
0% - 89%
"""
raise NotImplementedError()
@abstractmethod
def sensors(self) -> Tuple[Sensor, ...]:
"""
Return tuple of sensor definitions
"""
raise NotImplementedError()
@abstractmethod
def settings(self) -> Tuple[Sensor, ...]:
"""
Return tuple of settings definitions
"""
raise NotImplementedError()
@staticmethod
def _map_response(resp_data: bytes, sensors: Tuple[Sensor, ...], incl_xx: bool = True) -> Dict[str, Any]:
"""Process the response data and return dictionary with runtime values"""
with io.BytesIO(resp_data) as buffer:
result = {}
for sensor in sensors:
if incl_xx or not sensor.id_.startswith("xx"):
try:
result[sensor.id_] = sensor.read(buffer)
except ValueError:
logger.exception("Error reading sensor %s.", sensor.id_)
result[sensor.id_] = None
return result
@staticmethod
def _decode(data: bytes) -> str:
"""Decode the bytes to ascii string"""
try:
if any(x < 32 for x in data):
return data.hex()
return data.decode("ascii")
except ValueError:
return data.hex()
+149
View File
@@ -0,0 +1,149 @@
import logging
from typing import Union
from .exceptions import RequestRejectedException
logger = logging.getLogger(__name__)
MODBUS_READ_CMD: int = 0x3
MODBUS_WRITE_CMD: int = 0x6
MODBUS_WRITE_MULTI_CMD: int = 0x10
FAILURE_CODES = {
1: "ILLEGAL FUNCTION",
2: "ILLEGAL DATA ADDRESS",
3: "ILLEGAL DATA VALUE",
4: "SLAVE DEVICE FAILURE",
5: "ACKNOWLEDGE",
6: "SLAVE DEVICE BUSY",
7: "NEGATIVE ACKNOWLEDGEMENT",
8: "MEMORY PARITY ERROR",
10: "GATEWAY PATH UNAVAILABLE",
11: "GATEWAY TARGET DEVICE FAILED TO RESPOND",
}
def _create_crc16_table() -> tuple:
"""Construct (modbus) CRC-16 table"""
table = []
for i in range(256):
buffer = i << 1
crc = 0
for _ in range(8, 0, -1):
buffer >>= 1
if (buffer ^ crc) & 0x0001:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
table.append(crc)
return tuple(table)
_CRC_16_TABLE = _create_crc16_table()
def _modbus_checksum(data: Union[bytearray, bytes]) -> int:
"""
Calculate modbus crc-16 checksum
"""
crc = 0xFFFF
for ch in data:
crc = (crc >> 8) ^ _CRC_16_TABLE[(crc ^ ch) & 0xFF]
return crc
def create_modbus_request(comm_addr: int, cmd: int, offset: int, value: int) -> bytes:
"""
Create modbus request.
data[0] is inverter address
data[1] is modbus command
data[2:3] is command offset parameter
data[4:5] is command value parameter
data[6:7] is crc-16 checksum
"""
data: bytearray = bytearray(6)
data[0] = comm_addr
data[1] = cmd
data[2] = (offset >> 8) & 0xFF
data[3] = offset & 0xFF
data[4] = (value >> 8) & 0xFF
data[5] = value & 0xFF
checksum = _modbus_checksum(data)
data.append(checksum & 0xFF)
data.append((checksum >> 8) & 0xFF)
return bytes(data)
def create_modbus_multi_request(comm_addr: int, cmd: int, offset: int, values: bytes) -> bytes:
"""
Create modbus (multi value) request.
data[0] is inverter address
data[1] is modbus command
data[2:3] is command offset parameter
data[4:5] is number of registers
data[6] is number of bytes
data[7-n] is data payload
data[n+1:n+2] is crc-16 checksum
"""
data: bytearray = bytearray(7)
data[0] = comm_addr
data[1] = cmd
data[2] = (offset >> 8) & 0xFF
data[3] = offset & 0xFF
data[4] = 0
data[5] = len(values) // 2
data[6] = len(values)
data.extend(values)
checksum = _modbus_checksum(data)
data.append(checksum & 0xFF)
data.append((checksum >> 8) & 0xFF)
return bytes(data)
def validate_modbus_response(data: bytes, cmd: int, offset: int, value: int) -> bool:
"""
Validate the modbus response.
data[0:1] is header
data[2] is source address
data[3] is command return type
data[4] is response payload length (for read commands)
data[-2:] is crc-16 checksum
"""
if len(data) <= 4:
logger.debug("Response is too short.")
return False
if data[3] == MODBUS_READ_CMD:
if data[4] != value * 2:
logger.debug("Response has unexpected length: %d, expected %d.", data[4], value * 2)
return False
expected_length = data[4] + 7
if len(data) < expected_length:
logger.debug("Response is too short: %d, expected %d.", len(data), expected_length)
return False
elif data[3] in (MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD):
if len(data) < 10:
logger.debug("Response has unexpected length: %d, expected %d.", len(data), 10)
return False
expected_length = 10
response_offset = int.from_bytes(data[4:6], byteorder='big', signed=False)
if response_offset != offset:
logger.debug("Response has wrong offset: %X, expected %X.", response_offset, offset)
return False
response_value = int.from_bytes(data[6:8], byteorder='big', signed=True)
if response_value != value:
logger.debug("Response has wrong value: %X, expected %X.", response_value, value)
return False
else:
expected_length = len(data)
checksum_offset = expected_length - 2
if _modbus_checksum(data[2:checksum_offset]) != ((data[checksum_offset + 1] << 8) + data[checksum_offset]):
logger.debug("Response CRC-16 checksum does not match.")
return False
if data[3] != cmd:
failure_code = FAILURE_CODES.get(data[4], "UNKNOWN")
logger.debug("Response is command failure: %s.", FAILURE_CODES.get(data[4], "UNKNOWN"))
raise RequestRejectedException(failure_code)
return True
+26
View File
@@ -0,0 +1,26 @@
from .inverter import Inverter
# Serial number tags to identify inverter type
ET_MODEL_TAGS = ["ETU", "ETL", "ETR", "ETC", "EHU", "EHR", "EHB", "BTU", "BTN", "BTC", "BHU", "AES", "ABP", "HHI",
"HSB", "HUA", "CUA",
"ESN", "EMN", "ERN", "EBN", # ES Gen 2
"HLB", "HMB", "HBB", "SPN"] # Gen 2
ES_MODEL_TAGS = ["ESU", "EMU", "ESA", "BPS", "BPU", "EMJ", "IJL"]
DT_MODEL_TAGS = ["DTU", "DTS", "MSU", "MST", "DSN", "DTN", "DST", "NSU", "SSN", "SST", "SSX", "SSY", "PSB", "PSC"]
SINGLE_PHASE_MODELS = ["DSN", "DST", "NSU", "SSN", "SST", "SSX", "SSY", # DT
"MSU", "MST", "PSB", "PSC",
"EHU", "EHR", "HSB", # ET
"ESN", "EMN", "ERN", "EBN", "HLB", "HMB", "HBB", "SPN"] # ES Gen 2
def is_single_phase(inverter: Inverter) -> bool:
return any(model in inverter.serial_number for model in SINGLE_PHASE_MODELS)
def is_3_mptt(inverter: Inverter) -> bool:
return any(model in inverter.serial_number for model in ["MSU", "MST", "PSC"])
def is_4_mptt(inverter: Inverter) -> bool:
return any(model in inverter.serial_number for model in ["HSB"])
+37
View File
@@ -0,0 +1,37 @@
from abc import ABC
from dataclasses import dataclass, field
from datetime import datetime
from goodwe.protocol import ProtocolCommand
@dataclass(init=True, order=True)
class ProcessorResult:
sort_index: datetime = field(init=False)
date: datetime
volts_dc: float
current_dc: float
volts_ac: float
current_ac: float
frequency_ac: float
generation_today: float
generation_total: float
rssi: float
operational_hours: float
temperature: float
power: float
status: str
def __post_init__(self) -> None:
self.sort_index = self.date
def __str__(self) -> str:
return f'{self.date.strftime("%Y-%m-%d %H:%M:%S")}: (status: {self.status}, power: {self.power})'
class AbstractDataProcessor(ABC):
def process_data(self, data: bytes) -> ProcessorResult:
"""Process the data provided by the GoodWe inverter and return ProcessorResult"""
def get_runtime_data_command(self) -> ProtocolCommand:
"""Answer protocol command for reading runtime data"""
+270
View File
@@ -0,0 +1,270 @@
from __future__ import annotations
import asyncio
import logging
from asyncio.futures import Future
from typing import Tuple, Optional, Callable
from .const import GOODWE_UDP_PORT
from .exceptions import MaxRetriesException, RequestFailedException, RequestRejectedException
from .modbus import create_modbus_request, create_modbus_multi_request, validate_modbus_response, MODBUS_READ_CMD, \
MODBUS_WRITE_CMD, MODBUS_WRITE_MULTI_CMD
logger = logging.getLogger(__name__)
class UdpInverterProtocol(asyncio.DatagramProtocol):
def __init__(
self,
response_future: Future,
command: ProtocolCommand,
timeout: int,
retries: int
):
super().__init__()
self.response_future: Future = response_future
self.command: ProtocolCommand = command
self._transport: asyncio.transports.DatagramTransport | None = None
self._retry_timeout: int = timeout
self._max_retries: int = retries
self._retries: int = 0
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
"""On connection made"""
self._transport = transport
self._send_request()
def connection_lost(self, exc: Optional[Exception]) -> None:
"""On connection lost"""
if exc is not None:
logger.debug("Socket closed with error: %s.", exc)
# Cancel Future on connection lost
if not self.response_future.done():
self.response_future.cancel()
def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
"""On datagram received"""
try:
if self.command.validator(data):
logger.debug("Received: %s", data.hex())
self.response_future.set_result(data)
else:
logger.debug("Received invalid response: %s", data.hex())
self._retries += 1
self._send_request()
except RequestRejectedException as ex:
logger.debug("Received exception response: %s", data.hex())
self.response_future.set_exception(ex)
def error_received(self, exc: Exception) -> None:
"""On error received"""
logger.debug("Received error: %s", exc)
self.response_future.set_exception(exc)
def _send_request(self) -> None:
"""Send message via transport"""
logger.debug("Sending: %s%s", self.command,
f' - retry #{self._retries}/{self._max_retries}' if self._retries > 0 else '')
self._transport.sendto(self.command.request)
asyncio.get_event_loop().call_later(self._retry_timeout, self._retry_mechanism)
def _retry_mechanism(self) -> None:
"""Retry mechanism to prevent hanging transport"""
if self.response_future.done():
self._transport.close()
elif self._retries < self._max_retries:
logger.debug("Failed to receive response to %s in time (%ds).", self.command, self._retry_timeout)
self._retries += 1
self._send_request()
else:
logger.debug("Max number of retries (%d) reached, request %s failed.", self._max_retries, self.command)
self.response_future.set_exception(MaxRetriesException)
class ProtocolCommand:
"""Definition of inverter protocol command"""
def __init__(self, request: bytes, validator: Callable[[bytes], bool]):
self.request: bytes = request
self.validator: Callable[[bytes], bool] = validator
def __repr__(self):
return self.request.hex()
async def execute(self, host: str, timeout: int, retries: int) -> bytes:
"""
Execute the udp protocol command on the specified address/port.
Since the UDP communication is by definition unreliable, when no (valid) response is received by specified
timeout, the command will be re-tried up to retries times.
Return raw response data
"""
loop = asyncio.get_running_loop()
response_future = loop.create_future()
transport, _ = await loop.create_datagram_endpoint(
lambda: UdpInverterProtocol(response_future, self, timeout, retries),
remote_addr=(host, GOODWE_UDP_PORT),
)
try:
await response_future
result = response_future.result()
if result is not None:
return result
else:
raise RequestFailedException(
"No response received to '" + self.request.hex() + "' request."
)
except asyncio.CancelledError:
raise RequestFailedException(
"No valid response received to '" + self.request.hex() + "' request."
) from None
finally:
transport.close()
class Aa55ProtocolCommand(ProtocolCommand):
"""
Inverter communication protocol seen mostly on older generations of inverters.
Quite probably it is some variation of the protocol used on RS-485 serial link,
extended/adapted to UDP transport layer.
Each request starts with header of 0xAA, 0x55, then 0xC0, 0x7F (probably some sort of address/command)
followed by actual payload data.
It is suffixed with 2 bytes of plain checksum of header+payload.
Response starts again with 0xAA, 0x55, then 0x7F, 0xC0.
5-6th bytes are some response type, byte 7 is length of the response payload.
The last 2 bytes are again plain checksum of header+payload.
"""
def __init__(self, payload: str, response_type: str):
super().__init__(
bytes.fromhex(
"AA55C07F"
+ payload
+ self._checksum(bytes.fromhex("AA55C07F" + payload)).hex()
),
lambda x: self._validate_response(x, response_type),
)
@staticmethod
def _checksum(data: bytes) -> bytes:
checksum = 0
for each in data:
checksum += each
return checksum.to_bytes(2, byteorder="big", signed=False)
@staticmethod
def _validate_response(data: bytes, response_type: str) -> bool:
"""
Validate the response.
data[0:3] is header
data[4:5] is response type
data[6] is response payload length
data[-2:] is checksum (plain sum of response data incl. header)
"""
if len(data) <= 8 or len(data) != data[6] + 9:
logger.debug("Response has unexpected length: %d, expected %d.", len(data), data[6] + 9)
return False
elif response_type:
data_rt_int = int.from_bytes(data[4:6], byteorder="big", signed=True)
if int(response_type, 16) != data_rt_int:
logger.debug("Response type unexpected: %04x, expected %s.", data_rt_int, response_type)
return False
checksum = 0
for each in data[:-2]:
checksum += each
if checksum != int.from_bytes(data[-2:], byteorder="big", signed=True):
logger.debug("Response checksum does not match.")
return False
return True
class Aa55ReadCommand(Aa55ProtocolCommand):
"""
Inverter modbus READ command for retrieving <count> modbus registers starting at register # <offset>
"""
def __init__(self, offset: int, count: int):
super().__init__("011A03" + "{:04x}".format(offset) + "{:02x}".format(count), "019A")
class Aa55WriteCommand(Aa55ProtocolCommand):
"""
Inverter aa55 WRITE command setting single register # <register> value <value>
"""
def __init__(self, register: int, value: int):
super().__init__("023905" + "{:04x}".format(register) + "01" + "{:04x}".format(value), "02B9")
class Aa55WriteMultiCommand(Aa55ProtocolCommand):
"""
Inverter aa55 WRITE command setting multiple register # <register> value <value>
"""
def __init__(self, offset: int, values: bytes):
super().__init__("02390B" + "{:04x}".format(offset) + "{:02x}".format(len(values)) + values.hex(),
"02B9")
class ModbusProtocolCommand(ProtocolCommand):
"""
Inverter communication protocol seen on newer generation of inverters, based on Modbus
protocol over UDP transport layer.
The modbus communication is rather simple, there are "registers" at specified addresses/offsets,
each represented by 2 bytes. The protocol may query/update individual or range of these registers.
Each register represents some measured value or operational settings.
It's inverter implementation specific which register means what.
Some values may span more registers (i.e. 4bytes measurement value over 2 registers).
Every request usually starts with communication address (usually 0xF7, but can be changed).
Second byte is the modbus command - 0x03 read multiple, 0x06 write single, 0x10 write multiple.
Bytes 3-4 represent the register address (or start of range)
Bytes 5-6 represent the command parameter (range size or actual value for write).
Last 2 bytes of request is the CRC-16 (modbus flavor) of the request.
Responses seem to always start with 0xAA, 0x55, then the comm_addr and modbus command.
(If the command fails, the highest bit of command is set to 1 ?)
For read requests, next byte is response payload length, then the actual payload.
Last 2 bytes of response is again the CRC-16 of the response.
"""
def __init__(self, request: bytes, cmd: int, offset: int, value: int):
super().__init__(
request,
lambda x: validate_modbus_response(x, cmd, offset, value),
)
class ModbusReadCommand(ModbusProtocolCommand):
"""
Inverter modbus READ command for retrieving <count> modbus registers starting at register # <offset>
"""
def __init__(self, comm_addr: int, offset: int, count: int):
super().__init__(
create_modbus_request(comm_addr, MODBUS_READ_CMD, offset, count),
MODBUS_READ_CMD, offset, count)
class ModbusWriteCommand(ModbusProtocolCommand):
"""
Inverter modbus WRITE command setting single modbus register # <register> value <value>
"""
def __init__(self, comm_addr: int, register: int, value: int):
super().__init__(
create_modbus_request(comm_addr, MODBUS_WRITE_CMD, register, value),
MODBUS_WRITE_CMD, register, value)
class ModbusWriteMultiCommand(ModbusProtocolCommand):
"""
Inverter modbus WRITE command setting multiple modbus register # <register> value <value>
"""
def __init__(self, comm_addr: int, offset: int, values: bytes):
super().__init__(
create_modbus_multi_request(comm_addr, MODBUS_WRITE_MULTI_CMD, offset, values),
MODBUS_WRITE_MULTI_CMD, offset, len(values) // 2)
+761
View File
@@ -0,0 +1,761 @@
from __future__ import annotations
import io
from abc import ABC, abstractmethod
from datetime import datetime
from struct import unpack
from typing import Any, Callable, Optional
from .const import *
from .inverter import Sensor, SensorKind
DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
class Voltage(Sensor):
"""Sensor representing voltage [V] value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 2, "V", kind)
def read_value(self, data: io.BytesIO):
return read_voltage(data)
def encode_value(self, value: Any) -> bytes:
return encode_voltage(value)
class Current(Sensor):
"""Sensor representing current [A] value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 2, "A", kind)
def read_value(self, data: io.BytesIO):
return read_current(data)
def encode_value(self, value: Any) -> bytes:
return encode_current(value)
class Frequency(Sensor):
"""Sensor representing frequency [Hz] value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 2, "Hz", kind)
def read_value(self, data: io.BytesIO):
return read_freq(data)
class Power(Sensor):
"""Sensor representing power [W] value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 2, "W", kind)
def read_value(self, data: io.BytesIO):
return read_bytes2(data)
class Power4(Sensor):
"""Sensor representing power [W] value encoded in 4 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 4, "W", kind)
def read_value(self, data: io.BytesIO):
return read_bytes4(data)
class Energy(Sensor):
"""Sensor representing energy [kWh] value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 2, "kWh", kind)
def read_value(self, data: io.BytesIO):
value = read_bytes2(data)
if value == -1:
return None
else:
return float(value) / 10
class Energy4(Sensor):
"""Sensor representing energy [kWh] value encoded in 4 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 4, "kWh", kind)
def read_value(self, data: io.BytesIO):
value = read_bytes4(data)
if value == -1:
return None
else:
return float(value) / 10
class Apparent(Sensor):
"""Sensor representing apparent power [VA] value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 2, "VA", kind)
def read_value(self, data: io.BytesIO):
return read_bytes2(data)
class Apparent4(Sensor):
"""Sensor representing apparent power [VA] value encoded in 4 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 2, "VA", kind)
def read_value(self, data: io.BytesIO):
return read_bytes4(data)
class Reactive(Sensor):
"""Sensor representing reactive power [var] value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 2, "var", kind)
def read_value(self, data: io.BytesIO):
return read_bytes2(data)
class Reactive4(Sensor):
"""Sensor representing reactive power [var] value encoded in 4 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind]):
super().__init__(id_, offset, name, 2, "var", kind)
def read_value(self, data: io.BytesIO):
return read_bytes4(data)
class Temp(Sensor):
"""Sensor representing temperature [C] value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 2, "C", kind)
def read_value(self, data: io.BytesIO):
return read_temp(data)
class Byte(Sensor):
"""Sensor representing signed int value encoded in 1 byte"""
def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 1, unit, kind)
def read_value(self, data: io.BytesIO):
return read_byte(data)
def encode_value(self, value: Any) -> bytes:
raise NotImplementedError()
class ByteH(Byte):
"""Sensor representing signed int value encoded in 1 byte (high 8 bits of 16bit register)"""
def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, unit, kind)
def encode_value(self, value: Any, register_value: bytes) -> bytes:
word = bytearray(register_value)
word[0] = int.to_bytes(int(value), length=1, byteorder="big", signed=True)[0]
return bytes(word)
class ByteL(Byte):
"""Sensor representing signed int value encoded in 1 byte (low 8 bits of 16bit register)"""
def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, unit, kind)
def encode_value(self, value: Any, register_value: bytes) -> bytes:
word = bytearray(register_value)
word[1] = int.to_bytes(int(value), length=1, byteorder="big", signed=True)[0]
return bytes(word)
class Integer(Sensor):
"""Sensor representing signed int value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 2, unit, kind)
def read_value(self, data: io.BytesIO):
return read_bytes2(data)
def encode_value(self, value: Any) -> bytes:
return int.to_bytes(int(value), length=2, byteorder="big", signed=True)
class Long(Sensor):
"""Sensor representing signed int value encoded in 4 bytes"""
def __init__(self, id_: str, offset: int, name: str, unit: str = "", kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 4, unit, kind)
def read_value(self, data: io.BytesIO):
return read_bytes4(data)
def encode_value(self, value: Any) -> bytes:
return int.to_bytes(int(value), length=4, byteorder="big", signed=True)
class Decimal(Sensor):
"""Sensor representing signed decimal value encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, scale: int, name: str, unit: str = "", kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 2, unit, kind)
self.scale = scale
def read_value(self, data: io.BytesIO):
return read_decimal2(data, self.scale)
def encode_value(self, value: Any) -> bytes:
return int.to_bytes(int(value * self.scale), length=2, byteorder="big", signed=True)
class Float(Sensor):
"""Sensor representing signed int value encoded in 4 bytes"""
def __init__(self, id_: str, offset: int, scale: int, name: str, unit: str = "", kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 4, unit, kind)
self.scale = scale
def read_value(self, data: io.BytesIO):
return round(read_float4(data) / self.scale, 3)
class Timestamp(Sensor):
"""Sensor representing datetime value encoded in 6 bytes"""
def __init__(self, id_: str, offset: int, name: str, kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 6, "", kind)
def read_value(self, data: io.BytesIO):
return read_datetime(data)
def encode_value(self, value: Any) -> bytes:
return encode_datetime(value)
class Enum(Sensor):
"""Sensor representing label from enumeration encoded in 1 bytes"""
def __init__(self, id_: str, offset: int, labels: Dict, name: str, kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 1, "", kind)
self._labels: Dict = labels
def read_value(self, data: io.BytesIO):
return self._labels.get(read_byte(data))
class Enum2(Sensor):
"""Sensor representing label from enumeration encoded in 2 bytes"""
def __init__(self, id_: str, offset: int, labels: Dict, name: str, kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 2, "", kind)
self._labels: Dict = labels
def read_value(self, data: io.BytesIO):
return self._labels.get(read_bytes2(data))
class EnumBitmap4(Sensor):
"""Sensor representing label from bitmap encoded in 4 bytes"""
def __init__(self, id_: str, offset: int, labels: Dict, name: str, kind: Optional[SensorKind] = None):
super().__init__(id_, offset, name, 4, "", kind)
self._labels: Dict = labels
def read_value(self, data: io.BytesIO) -> Any:
raise NotImplementedError()
def read(self, data: io.BytesIO):
return decode_bitmap(read_bytes4(data, self.offset), self._labels)
class EnumBitmap22(Sensor):
"""Sensor representing label from bitmap encoded in 2+2 bytes"""
def __init__(self, id_: str, offsetH: int, offsetL: int, labels: Dict, name: str,
kind: Optional[SensorKind] = None):
super().__init__(id_, offsetH, name, 2, "", kind)
self._labels: Dict = labels
self._offsetL: int = offsetL
def read_value(self, data: io.BytesIO) -> Any:
raise NotImplementedError()
def read(self, data: io.BytesIO):
return decode_bitmap(read_bytes2(data, self.offset) << 16 + read_bytes2(data, self._offsetL), self._labels)
class EnumCalculated(Sensor):
"""Sensor representing label from enumeration of calculated value"""
def __init__(self, id_: str, getter: Callable[[io.BytesIO], Any], labels: Dict, name: str,
kind: Optional[SensorKind] = None):
super().__init__(id_, 0, name, 0, "", kind)
self._getter: Callable[[io.BytesIO], Any] = getter
self._labels: Dict = labels
def read_value(self, data: io.BytesIO) -> Any:
raise NotImplementedError()
def read(self, data: io.BytesIO):
return self._labels.get(self._getter(data))
class EcoMode(ABC):
"""Sensor representing Eco Mode Battery Power Group API"""
@abstractmethod
def encode_charge(self, eco_mode_power: int, eco_mode_soc: int = 100) -> bytes:
"""Answer bytes representing all the time enabled charging eco mode group"""
@abstractmethod
def encode_discharge(self, eco_mode_power: int) -> bytes:
"""Answer bytes representing all the time enabled discharging eco mode group"""
@abstractmethod
def encode_off(self) -> bytes:
"""Answer bytes representing empty and disabled eco mode group"""
@abstractmethod
def is_eco_charge_mode(self) -> bool:
"""Answer if it represents the emulated 24/7 fulltime discharge mode"""
@abstractmethod
def is_eco_discharge_mode(self) -> bool:
"""Answer if it represents the emulated 24/7 fulltime discharge mode"""
class EcoModeV1(Sensor, EcoMode):
"""Sensor representing Eco Mode Battery Power Group encoded in 8 bytes"""
def __init__(self, id_: str, offset: int, name: str):
super().__init__(id_, offset, name, 8, "", SensorKind.BAT)
self.start_h: int | None = None
self.start_m: int | None = None
self.end_h: int | None = None
self.end_m: int | None = None
self.power: int | None = None
self.on_off: int | None = None
self.day_bits: int | None = None
self.days: str | None = None
self.soc: int = 100 # just to keep same API with V2
def __str__(self):
return f"{self.start_h}:{self.start_m}-{self.end_h}:{self.end_m} {self.days} {self.power}% {'On' if self.on_off != 0 else 'Off'}"
def read_value(self, data: io.BytesIO):
self.start_h = read_byte(data)
if (self.start_h < 0 or self.start_h > 23) and self.start_h != 48:
raise ValueError(f"{self.id_}: start_h value {self.start_h} out of range.")
self.start_m = read_byte(data)
if self.start_m < 0 or self.start_m > 59:
raise ValueError(f"{self.id_}: start_m value {self.start_m} out of range.")
self.end_h = read_byte(data)
if (self.end_h < 0 or self.end_h > 23) and self.end_h != 48:
raise ValueError(f"{self.id_}: end_h value {self.end_h} out of range.")
self.end_m = read_byte(data)
if self.end_m < 0 or self.end_m > 59:
raise ValueError(f"{self.id_}: end_m value {self.end_m} out of range.")
self.power = read_bytes2(data) # negative=charge, positive=discharge
if self.power < -100 or self.power > 100:
raise ValueError(f"{self.id_}: power value {self.power} out of range.")
self.on_off = read_byte(data)
if self.on_off not in (0, -1):
raise ValueError(f"{self.id_}: on_off value {self.on_off} out of range.")
self.day_bits = read_byte(data)
self.days = decode_day_of_week(self.day_bits)
if self.day_bits < 0:
raise ValueError(f"{self.id_}: day_bits value {self.day_bits} out of range.")
return self
def encode_value(self, value: Any) -> bytes:
if isinstance(value, bytes) and len(value) == 8:
# try to read_value to check if values are valid
if self.read_value(io.BytesIO(value)):
return value
raise ValueError
def encode_charge(self, eco_mode_power: int, eco_mode_soc: int = 100) -> bytes:
"""Answer bytes representing all the time enabled charging eco mode group"""
return bytes.fromhex("0000173b{:04x}ff7f".format((-1 * abs(eco_mode_power)) & (2 ** 16 - 1)))
def encode_discharge(self, eco_mode_power: int) -> bytes:
"""Answer bytes representing all the time enabled discharging eco mode group"""
return bytes.fromhex("0000173b{:04x}ff7f".format(abs(eco_mode_power)))
def encode_off(self) -> bytes:
"""Answer bytes representing empty and disabled eco mode group"""
return bytes.fromhex("3000300000640000")
def is_eco_charge_mode(self) -> bool:
"""Answer if it represents the emulated 24/7 fulltime discharge mode"""
return self.start_h == 0 \
and self.start_m == 0 \
and self.end_h == 23 \
and self.end_m == 59 \
and self.on_off != 0 \
and self.day_bits == 127 \
and self.power < 0
def is_eco_discharge_mode(self) -> bool:
"""Answer if it represents the emulated 24/7 fulltime discharge mode"""
return self.start_h == 0 \
and self.start_m == 0 \
and self.end_h == 23 \
and self.end_m == 59 \
and self.on_off != 0 \
and self.day_bits == 127 \
and self.power > 0
def as_eco_mode_v2(self) -> EcoModeV2:
"""Convert V1 to V2 EcoMode"""
result = EcoModeV2(self.id_, self.offset, self.name)
result.start_h = self.start_h
result.start_m = self.start_m
result.end_h = self.end_h
result.end_m = self.end_m
result.power = self.power
result.on_off = self.on_off
result.day_bits = self.day_bits
result.days = decode_day_of_week(self.day_bits)
result.soc = 100
return result
class EcoModeV2(Sensor, EcoMode):
"""Sensor representing Eco Mode Battery Power Group encoded in 12 bytes"""
def __init__(self, id_: str, offset: int, name: str):
super().__init__(id_, offset, name, 12, "", SensorKind.BAT)
self.start_h: int | None = None
self.start_m: int | None = None
self.end_h: int | None = None
self.end_m: int | None = None
self.on_off: int | None = None
self.day_bits: int | None = None
self.days: str | None = None
self.power: int | None = None
self.soc: int | None = None
# 2 bytes padding 0000
def __str__(self):
return f"{self.start_h}:{self.start_m}-{self.end_h}:{self.end_m} {self.days} {self.power}% (SoC {self.soc}%) {'On' if self.on_off != 0 else 'Off'}"
def read_value(self, data: io.BytesIO):
self.start_h = read_byte(data)
if (self.start_h < 0 or self.start_h > 23) and self.start_h != 48:
raise ValueError(f"{self.id_}: start_h value {self.start_h} out of range.")
self.start_m = read_byte(data)
if self.start_m < 0 or self.start_m > 59:
raise ValueError(f"{self.id_}: start_m value {self.start_m} out of range.")
self.end_h = read_byte(data)
if (self.end_h < 0 or self.end_h > 23) and self.end_h != 48:
raise ValueError(f"{self.id_}: end_h value {self.end_h} out of range.")
self.end_m = read_byte(data)
if self.end_m < 0 or self.end_m > 59:
raise ValueError(f"{self.id_}: end_m value {self.end_m} out of range.")
self.on_off = read_byte(data)
if self.on_off not in (0, -1):
raise ValueError(f"{self.id_}: on_off value {self.on_off} out of range.")
self.day_bits = read_byte(data)
self.days = decode_day_of_week(self.day_bits)
if self.day_bits < 0:
raise ValueError(f"{self.id_}: day_bits value {self.day_bits} out of range.")
self.power = read_bytes2(data) # negative=charge, positive=discharge
if self.power < -100 or self.power > 100:
raise ValueError(f"{self.id_}: power value {self.power} out of range.")
self.soc = read_bytes2(data)
if self.soc < 0 or self.soc > 100:
raise ValueError(f"{self.id_}: SoC value {self.soc} out of range.")
return self
def encode_value(self, value: Any) -> bytes:
if isinstance(value, bytes) and len(value) == 12:
# try to read_value to check if values are valid
if self.read_value(io.BytesIO(value)):
return value
raise ValueError
def encode_charge(self, eco_mode_power: int, eco_mode_soc: int = 100) -> bytes:
"""Answer bytes representing all the time enabled charging eco mode group"""
return bytes.fromhex(
"0000173bff7f{:04x}{:04x}0000".format((-1 * abs(eco_mode_power)) & (2 ** 16 - 1), eco_mode_soc))
def encode_discharge(self, eco_mode_power: int) -> bytes:
"""Answer bytes representing all the time enabled discharging eco mode group"""
return bytes.fromhex("0000173bff7f{:04x}00640000".format(abs(eco_mode_power)))
def encode_off(self) -> bytes:
"""Answer bytes representing empty and disabled eco mode group"""
return bytes.fromhex("300030000000006400640000")
def is_eco_charge_mode(self) -> bool:
"""Answer if it represents the emulated 24/7 fulltime discharge mode"""
return self.start_h == 0 \
and self.start_m == 0 \
and self.end_h == 23 \
and self.end_m == 59 \
and self.on_off != 0 \
and self.day_bits == 127 \
and self.power < 0
def is_eco_discharge_mode(self) -> bool:
"""Answer if it represents the emulated 24/7 fulltime discharge mode"""
return self.start_h == 0 \
and self.start_m == 0 \
and self.end_h == 23 \
and self.end_m == 59 \
and self.on_off != 0 \
and self.day_bits == 127 \
and self.power > 0
def as_eco_mode_v1(self) -> EcoModeV1:
"""Convert V2 to V1 EcoMode"""
result = EcoModeV1(self.id_, self.offset, self.name)
result.start_h = self.start_h
result.start_m = self.start_m
result.end_h = self.end_h
result.end_m = self.end_m
result.power = self.power
result.on_off = self.on_off
result.day_bits = self.day_bits
result.days = self.days
return result
class PeakShavingMode(Sensor):
"""Sensor representing Peak Shaving Mode encoded in 12 bytes"""
def __init__(self, id_: str, offset: int, name: str):
super().__init__(id_, offset, name, 12, "", SensorKind.BAT)
self.start_h: int | None = None
self.start_m: int | None = None
self.end_h: int | None = None
self.end_m: int | None = None
self.on_off: int | None = None
self.day_bits: int | None = None
self.days: str | None = None
self.import_power: float | None = None
self.soc: int | None = None
# 2 bytes padding 0000
def __str__(self):
return f"{self.start_h}:{self.start_m}-{self.end_h}:{self.end_m} {self.days} {self.import_power}kW (SoC {self.soc}%) {'On' if self.on_off == -4 else 'Off'}"
def read_value(self, data: io.BytesIO):
self.start_h = read_byte(data)
if (self.start_h < 0 or self.start_h > 23) and self.start_h != 48:
raise ValueError(f"{self.id_}: start_h value {self.start_h} out of range.")
self.start_m = read_byte(data)
if self.start_m < 0 or self.start_m > 59:
raise ValueError(f"{self.id_}: start_m value {self.start_m} out of range.")
self.end_h = read_byte(data)
if (self.end_h < 0 or self.end_h > 23) and self.end_h != 48:
raise ValueError(f"{self.id_}: end_h value {self.end_h} out of range.")
self.end_m = read_byte(data)
if self.end_m < 0 or self.end_m > 59:
raise ValueError(f"{self.id_}: end_m value {self.end_m} out of range.")
self.on_off = read_byte(data)
if self.on_off not in (-4, 3):
raise ValueError(f"{self.id_}: on_off value {self.on_off} out of range.")
self.day_bits = read_byte(data)
self.days = decode_day_of_week(self.day_bits)
if self.day_bits < 0:
raise ValueError(f"{self.id_}: day_bits value {self.day_bits} out of range.")
self.import_power = read_decimal2(data, 100)
if self.import_power < 0 or self.import_power > 500:
raise ValueError(f"{self.id_}: import_power value {self.import_power} out of range.")
self.soc = read_bytes2(data)
if self.soc < 0 or self.soc > 100:
raise ValueError(f"{self.id_}: soc value {self.soc} out of range.")
return self
def encode_value(self, value: Any) -> bytes:
if isinstance(value, bytes) and len(value) == 12:
# try to read_value to check if values are valid
if self.read_value(io.BytesIO(value)):
return value
raise ValueError
def encode_off(self) -> bytes:
"""Answer bytes representing empty and disabled eco mode group"""
return bytes.fromhex("300030000000006400640000")
class Calculated(Sensor):
"""Sensor representing calculated value"""
def __init__(self, id_: str, getter: Callable[[io.BytesIO], Any], name: str, unit: str,
kind: Optional[SensorKind] = None):
super().__init__(id_, 0, name, 0, unit, kind)
self._getter: Callable[[io.BytesIO], Any] = getter
def read_value(self, data: io.BytesIO) -> Any:
raise NotImplementedError()
def read(self, data: io.BytesIO):
return self._getter(data)
def read_byte(buffer: io.BytesIO, offset: int = None) -> int:
"""Retrieve single byte (signed int) value from buffer"""
if offset is not None:
buffer.seek(offset)
return int.from_bytes(buffer.read(1), byteorder="big", signed=True)
def read_bytes2(buffer: io.BytesIO, offset: int = None) -> int:
"""Retrieve 2 byte (signed int) value from buffer"""
if offset is not None:
buffer.seek(offset)
return int.from_bytes(buffer.read(2), byteorder="big", signed=True)
def read_bytes4(buffer: io.BytesIO, offset: int = None) -> int:
"""Retrieve 4 byte (signed int) value from buffer"""
if offset is not None:
buffer.seek(offset)
return int.from_bytes(buffer.read(4), byteorder="big", signed=True)
def read_decimal2(buffer: io.BytesIO, scale: int, offset: int = None) -> float:
"""Retrieve 2 byte (signed float) value from buffer"""
if offset is not None:
buffer.seek(offset)
return float(int.from_bytes(buffer.read(2), byteorder="big", signed=True)) / scale
def read_float4(buffer: io.BytesIO, offset: int = None) -> float:
"""Retrieve 4 byte (signed float) value from buffer"""
if offset is not None:
buffer.seek(offset)
data = buffer.read(4)
if len(data) == 4:
return unpack('>f', data)[0]
else:
return float(0)
def read_voltage(buffer: io.BytesIO, offset: int = None) -> float:
"""Retrieve voltage [V] value (2 bytes) from buffer"""
if offset is not None:
buffer.seek(offset)
value = int.from_bytes(buffer.read(2), byteorder="big", signed=True)
return float(value) / 10
def encode_voltage(value: Any) -> bytes:
"""Encode voltage value to raw (2 bytes) payload"""
return int.to_bytes(int(value * 10), length=2, byteorder="big", signed=True)
def read_current(buffer: io.BytesIO, offset: int = None) -> float:
"""Retrieve current [A] value (2 bytes) from buffer"""
if offset is not None:
buffer.seek(offset)
value = int.from_bytes(buffer.read(2), byteorder="big", signed=True)
return float(value) / 10
def encode_current(value: Any) -> bytes:
"""Encode current value to raw (2 bytes) payload"""
return int.to_bytes(int(value * 10), length=2, byteorder="big", signed=True)
def read_freq(buffer: io.BytesIO, offset: int = None) -> float:
"""Retrieve frequency [Hz] value (2 bytes) from buffer"""
if offset is not None:
buffer.seek(offset)
value = int.from_bytes(buffer.read(2), byteorder="big", signed=True)
return float(value) / 100
def read_temp(buffer: io.BytesIO, offset: int = None) -> float:
"""Retrieve temperature [C] value (2 bytes) from buffer"""
if offset is not None:
buffer.seek(offset)
value = int.from_bytes(buffer.read(2), byteorder="big", signed=True)
return float(value) / 10
def read_datetime(buffer: io.BytesIO, offset: int = None) -> datetime:
"""Retrieve datetime value (6 bytes) from buffer"""
if offset is not None:
buffer.seek(offset)
year = 2000 + int.from_bytes(buffer.read(1), byteorder='big')
month = int.from_bytes(buffer.read(1), byteorder='big')
day = int.from_bytes(buffer.read(1), byteorder='big')
hour = int.from_bytes(buffer.read(1), byteorder='big')
minute = int.from_bytes(buffer.read(1), byteorder='big')
second = int.from_bytes(buffer.read(1), byteorder='big')
return datetime(year=year, month=month, day=day, hour=hour, minute=minute, second=second)
def encode_datetime(value: Any) -> bytes:
"""Encode datetime value to raw (6 bytes) payload"""
timestamp = value
if isinstance(value, str):
timestamp = datetime.fromisoformat(value)
result = bytes([
timestamp.year - 2000,
timestamp.month,
timestamp.day,
timestamp.hour,
timestamp.minute,
timestamp.second,
])
return result
def read_grid_mode(buffer: io.BytesIO, offset: int = None) -> int:
"""Retrieve 'grid mode' sign value from buffer"""
value = read_bytes2(buffer, offset)
if value < -90:
return 2
elif value >= 90:
return 1
else:
return 0
def read_unsigned_int(data: bytes, offset: int) -> int:
"""Retrieve 2 byte (unsigned int) value from bytes at specified offset"""
return int.from_bytes(data[offset:offset + 2], byteorder="big", signed=False)
def decode_bitmap(value: int, bitmap: Dict[int, str]) -> str:
bits = value
result = []
for i in range(32):
if bits & 0x1 == 1:
result.append(bitmap.get(i, f'err{i}'))
bits = bits >> 1
return ", ".join(result)
def decode_day_of_week(data: int) -> str:
bits = bin(data)[2:]
daynames = list(DAY_NAMES)
days = ""
for each in bits[::-1]:
if each == '1':
if len(days) > 0:
days += ","
days += daynames[0]
daynames.pop(0)
return days
+43
View File
@@ -0,0 +1,43 @@
import logging
from .dt import DT
from .processor import ProcessorResult, AbstractDataProcessor
from .protocol import ProtocolCommand
from .sensor import *
logger = logging.getLogger(__name__)
class GoodWeXSProcessor(AbstractDataProcessor):
def __init__(self):
self.dummy_inverter = DT("localhost")
def process_data(self, data: bytes) -> ProcessorResult:
"""Process the data provided by the GoodWe XS inverter and return ProcessorResult"""
sensors = self.dummy_inverter._map_response(data[5:-2], self.dummy_inverter.sensors())
return ProcessorResult(
date=sensors['timestamp'],
volts_dc=sensors['vpv1'],
current_dc=sensors['ipv1'],
volts_ac=sensors['vgrid1'],
current_ac=sensors['igrid1'],
frequency_ac=sensors['fgrid1'],
generation_today=sensors['e_day'],
generation_total=sensors['e_total'],
# this is just response checksum
rssi=self._get_rssi(data),
operational_hours=sensors['h_total'],
temperature=sensors['temperature'],
power=sensors['ppv'],
status=sensors['work_mode_label'])
def _get_rssi(self, data) -> float:
"""Retrieve rssi from GoodWe data"""
with io.BytesIO(data) as buffer:
return read_bytes2(buffer, 149)
def get_runtime_data_command(self) -> ProtocolCommand:
"""Answer protocol command for reading runtime data"""
return self.dummy_inverter._READ_DEVICE_RUNNING_DATA