goodwe-modbus 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.3
2
+ Name: goodwe-modbus
3
+ Version: 0.1.0
4
+ Summary: Python library to communicate to Goodwe inverter over modbus.
5
+ Author: theneweinstein
6
+ Author-email: theneweinstein <theneweinstein@mail.equust.nl>
7
+ Requires-Dist: modbus-connection>=4.12.3
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
File without changes
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "goodwe-modbus"
3
+ version = "0.1.0"
4
+ description = "Python library to communicate to Goodwe inverter over modbus."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = ["modbus-connection>=4.12.3"]
8
+
9
+ [[project.authors]]
10
+ name = "theneweinstein"
11
+ email = "theneweinstein@mail.equust.nl"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.12.23,<0.13.0"]
15
+ build-backend = "uv_build"
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "goodwe-modbus"
3
+ version = "0.1.0"
4
+ description = "Python library to communicate to Goodwe inverter over modbus."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "theneweinstein", email = "theneweinstein@mail.equust.nl" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "modbus-connection>=4.12.3",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["uv_build>=0.12.23,<0.13.0"]
16
+ build-backend = "uv_build"
@@ -0,0 +1,15 @@
1
+ from .device import GoodweInverter
2
+ from .pv import PvStrings
3
+ from .inverter import InverterState, Identity, GridOutput
4
+ from .energy import EnergyTotals
5
+ from .settings import ActivePowerControl
6
+
7
+ __all__ = [
8
+ "GoodweInverter",
9
+ "InverterState",
10
+ "Identity",
11
+ "GridOutput",
12
+ "PvStrings",
13
+ "EnergyTotals",
14
+ "ActivePowerControl"
15
+ ]
@@ -0,0 +1,72 @@
1
+ """The top-level object for a current-generation Sofar inverter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import TYPE_CHECKING
7
+
8
+ from modbus_connection.model import (
9
+ Device,
10
+ Raw,
11
+ UpdateReport,
12
+ )
13
+
14
+ from .energy import EnergyTotals
15
+ from .inverter import InverterState, Identity, GridOutput
16
+ from .pv import PvStrings
17
+ from .settings import ActivePowerControl
18
+
19
+
20
+ if TYPE_CHECKING:
21
+ from modbus_connection import ModbusUnit
22
+
23
+ # Attribute names of the sub-systems each update method reads.
24
+ READINGS = ("pv","state","grid","energy")
25
+ SETTINGS = ("powercontrol",)
26
+ ALL = ("identity", *READINGS, *SETTINGS)
27
+
28
+ class GoodweInverter(Device):
29
+ """A current-generation Goodwe inverter reached through a ``ModbusUnit``."""
30
+
31
+
32
+ def __init__(
33
+ self,
34
+ unit: ModbusUnit,
35
+ ) -> None:
36
+ """Set up the sub-systems.
37
+
38
+ ``read_pm`` reads parallel-system registers an inverter refuses.
39
+ """
40
+ super().__init__(unit)
41
+
42
+ self.energy = EnergyTotals(unit)
43
+ self.grid = GridOutput(unit)
44
+ self.identity = Identity(unit)
45
+ self.state = InverterState(unit)
46
+ self.pv = PvStrings(unit)
47
+ self.powercontrol = ActivePowerControl(unit)
48
+
49
+ async def _async_setup(self) -> None:
50
+ """Set up the sub-systems."""
51
+ await self.identity.async_update()
52
+
53
+ async def async_update_readings(self) -> UpdateReport:
54
+ """Update the readings sub-systems."""
55
+ return await self.async_poll(READINGS)
56
+
57
+ async def async_update_settings(self) -> UpdateReport:
58
+ """Refresh what the controller has been configured to do."""
59
+ return await self.async_poll(SETTINGS)
60
+
61
+ async def async_update(self) -> UpdateReport:
62
+ """Refresh all components."""
63
+ report = await self.async_poll(READINGS)
64
+ return await self.async_poll(SETTINGS, report)
65
+
66
+ async def async_read_raw(self, names: Iterable[str] = ALL) -> Raw:
67
+ """Every register this device reads, undecoded, for diagnostics."""
68
+ return await super().async_read_raw(names)
69
+
70
+
71
+
72
+
@@ -0,0 +1,14 @@
1
+ """Daily and lifetime energy counters"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from modbus_connection.model import Component, uint32, gauge
6
+
7
+ class EnergyTotals(Component):
8
+ """Daily and lifetime energy counters"""
9
+
10
+ daily_energy = gauge(566, 0.1, signed=False, unit="kWh")
11
+ """Daily energy in kWh."""
12
+
13
+ lifetime_energy = uint32(546, scale = 0.1, unit="kWh")
14
+ """Lifetime energy in kWh."""
@@ -0,0 +1,48 @@
1
+ """Option maps the plugin declares, as enums."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum, IntFlag
6
+
7
+ class SystemState(IntEnum):
8
+ """System state"""
9
+
10
+ WAIT = 0
11
+ NORMAL = 1
12
+ FAULT = 2
13
+
14
+ class Fault1(IntFlag):
15
+ """Fault flags"""
16
+
17
+ ID001_GFCI_CHECK = 0x00000001
18
+ ID002_AC_HCT_CHECK = 0x00000002
19
+ ID003_REF_V = 0x00000004
20
+ ID004_DC_SWITCH = 0x00000008
21
+ ID005_DC_SPD = 0x00000010
22
+ ID006_SALES1 = 0x00000020
23
+ ID007_GFCI = 0x00000040
24
+ ID008_RELAY = 0x00000080
25
+ ID009_AC_HCT = 0x00000100
26
+ ID010_UTILITY_LOSS = 0x00000200
27
+ ID011_GROUND = 0x00000400
28
+ ID012_DC_BUS = 0x00000800
29
+ ID013_INTERNAL_FAN = 0x00001000
30
+ ID014_TEMPERATURE = 0x00002000
31
+ ID015_SALES2 = 0x00004000
32
+ ID016_PV_OVER_VOLTAGE = 0x00008000
33
+ ID017_EXTERNAL_FAN = 0x00010000
34
+ ID018_VAC = 0x00020000
35
+ ID019_ISOLATION = 0x00040000
36
+ ID020_HIGH_DC_COMPONENT = 0x00080000
37
+ ID021_LCD_COMMUNICATION = 0x00100000
38
+ ID022_STRING_OVERCURRENT = 0x00200000
39
+ ID023_PV_REVERSE = 0x00400000
40
+ ID024_EXPORT_POWER_LIMIT = 0x00800000
41
+ ID025_NPE = 0x01000000
42
+ ID026_RELAY_CHECK = 0x02000000
43
+ ID027_LPE_SHORT_CIRCUIT = 0x04000000
44
+ ID028_NIGHT_SPS = 0x08000000
45
+ ID029_AFCI = 0x10000000
46
+ ID030_GRID_FREQUENCY = 0x20000000
47
+ ID031_EEPROM = 0x40000000
48
+ ID032_SPI = 0x80000000
@@ -0,0 +1,66 @@
1
+ """Fault definitions, keyed by vendor ID rather than register."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+ class FaultCategory(StrEnum):
9
+ """The subsystem a fault belongs to."""
10
+
11
+ AC_OUTPUT = "ac_output"
12
+ COMMUNICATION = "communication"
13
+ DC_BUS = "dc_bus"
14
+ FAN = "fan"
15
+ GRID = "grid"
16
+ INSULATION = "insulation"
17
+ INTERNAL = "internal"
18
+ PV = "pv"
19
+ SALES = "sales"
20
+ THERMAL = "thermal"
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Fault:
24
+ """One fault the inverter can report."""
25
+
26
+ id: int
27
+ key: str
28
+ category: FaultCategory
29
+
30
+ FAULTS: tuple[Fault, ...] = (
31
+ Fault(1, "gfci_check_fail", FaultCategory.INTERNAL),
32
+ Fault(2, "ac_hct_check_fail", FaultCategory.INTERNAL),
33
+ Fault(3, "ref_v", FaultCategory.INTERNAL),
34
+ Fault(4, "dc_switch_fault", FaultCategory.DC_BUS),
35
+ Fault(5, "dc_spd_fault", FaultCategory.DC_BUS),
36
+ Fault(6, "sales1", FaultCategory.SALES),
37
+ Fault(7, "gfci_fault", FaultCategory.INTERNAL),
38
+ Fault(8, "relay_fault", FaultCategory.INTERNAL),
39
+ Fault(9, "ac_hct", FaultCategory.INTERNAL),
40
+ Fault(10, "utility_loss", FaultCategory.GRID),
41
+ Fault(11, "ground_fault", FaultCategory.INSULATION),
42
+ Fault(12, "dc_bus_high", FaultCategory.DC_BUS),
43
+ Fault(13, "internal_fan_fault", FaultCategory.FAN),
44
+ Fault(14, "over_temperature", FaultCategory.THERMAL),
45
+ Fault(15, "sales2", FaultCategory.SALES),
46
+ Fault(16, "pv_overvoltage", FaultCategory.PV),
47
+ Fault(17, "external_fan_fault", FaultCategory.FAN),
48
+ Fault(18, "vac_fault", FaultCategory.GRID),
49
+ Fault(19, "isolation_fault", FaultCategory.INSULATION),
50
+ Fault(20, "high_dc_component", FaultCategory.GRID),
51
+ Fault(21, "lcd_communication_fault", FaultCategory.COMMUNICATION),
52
+ Fault(22, "string_over_current", FaultCategory.PV),
53
+ Fault(23, "pv_reverse_fault", FaultCategory.PV),
54
+ Fault(24, "export_power_limit_fault", FaultCategory.GRID),
55
+ Fault(25, "npe", FaultCategory.GRID),
56
+ Fault(26, "relay_check_fault", FaultCategory.INTERNAL),
57
+ Fault(27, "lpe", FaultCategory.GRID),
58
+ Fault(28, "night_sps_fault", FaultCategory.INTERNAL),
59
+ Fault(29, "afci_fault", FaultCategory.INTERNAL),
60
+ Fault(30, "grid_frequency_overrun", FaultCategory.GRID),
61
+ Fault(31, "eeprom", FaultCategory.COMMUNICATION),
62
+ Fault(32, "spi_fault", FaultCategory.COMMUNICATION)
63
+ )
64
+
65
+
66
+ FAULTS_BY_ID: dict[int, Fault] = {fault.id: fault for fault in FAULTS}
@@ -0,0 +1,66 @@
1
+ """Inverter state, identity and on-grid output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from modbus_connection.model import Component, enum, flags, gauge, integer, string, uint32
6
+
7
+ from .enums import (
8
+ Fault1,
9
+ SystemState,
10
+ )
11
+ from .faults import FAULTS_BY_ID, Fault
12
+
13
+
14
+ class InverterState(Component):
15
+ """Inverter state, identity and on-grid output."""
16
+
17
+ system_state = enum(564, SystemState, signed = False)
18
+ fault = flags(544, Fault1, count=2, signed = False)
19
+ generation_time_total = uint32(548, unit = "h")
20
+ inverter_temperature = gauge(565, 0.1, signed = False, unit = "°C")
21
+
22
+ @property
23
+ def active_faults(self) -> frozenset[Fault]:
24
+ """Every fault the inverter currently reports."""
25
+ return frozenset(
26
+ fault
27
+ for member in self.fault
28
+ if (fault := FAULTS_BY_ID(member.value)) is not None
29
+ )
30
+
31
+ class Identity(Component):
32
+ """Inverter identity."""
33
+
34
+ type = string(528, 5)
35
+ """Inverter type """
36
+ serial = string(512,8)
37
+ """Inverter serial number."""
38
+
39
+ class GridOutput(Component):
40
+ """Inverter grid output."""
41
+
42
+ voltage_l1 = gauge(554, 0.1, unit="V")
43
+ voltage_l2 = gauge(555, 0.1, unit="V")
44
+ voltage_l3 = gauge(556, 0.1, unit="V")
45
+ current_l1 = gauge(557, 0.1, unit="A")
46
+ current_l2 = gauge(558, 0.1, unit="A")
47
+ current_l3 = gauge(559, 0.1, unit="A")
48
+ frequency_l1 = gauge(560, 0.01, unit="Hz")
49
+ frequency_l2 = gauge(561, 0.01, unit="Hz")
50
+ frequency_l3 = gauge(562, 0.01, unit="Hz")
51
+ power = integer(563, unit="W")
52
+
53
+ @property
54
+ def power_l1(self) -> float:
55
+ """Grid output power on L1 in W."""
56
+ return self.voltage_l1 * self.current_l1
57
+
58
+ @property
59
+ def power_l2(self) -> float:
60
+ """Grid output power on L2 in W."""
61
+ return self.voltage_l2 * self.current_l2
62
+
63
+ @property
64
+ def power_l3(self) -> float:
65
+ """Grid output power on L3 in W."""
66
+ return self.voltage_l3 * self.current_l3
@@ -0,0 +1,25 @@
1
+ """PV strings, one component per MPPT tier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from modbus_connection.model import Component, gauge, repeating_group
6
+
7
+
8
+
9
+ class PvString(Component):
10
+ """A PV string, one component per MPPT tier."""
11
+
12
+ pv_voltage = gauge(550,0.1, stride = 1, unit="V")
13
+ """PV voltage in V."""
14
+ pv_current = gauge(552,0.1, stride = 1, unit="A")
15
+ """PV current in A."""
16
+
17
+ @property
18
+ def pv_power(self) -> float:
19
+ """PV power in W."""
20
+ return self.pv_voltage * self.pv_current
21
+
22
+ class PvStrings(Component):
23
+ """A PV string, one component per MPPT tier."""
24
+
25
+ strings = repeating_group(2, PvString, stride = 1)
File without changes
@@ -0,0 +1,17 @@
1
+ """Settings and commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from modbus_connection.model import Component, integer
6
+
7
+ class ActivePowerControl(Component):
8
+ """Active power control settings."""
9
+
10
+ active_power_export_limit = integer(256, signed = False, unit = "%")
11
+ """Active power export limit in W."""
12
+
13
+ async def async_set_active_power_export_limit(self, limit: float) -> None:
14
+ """Set the active power export limit in W."""
15
+ if not 0 <= limit <= 100:
16
+ raise ValueError(f"active power limit {limit}% is outside 0-100")
17
+ await self._unit.write_register(256, round(limit))