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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: correct,2: reverse,3: incorrect,0: 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
|
||||
@@ -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"""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user