Add Universeller Modbus#3699
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new “Universeller Modbus” device module under packages/modules/devices/generic/modbus to support configurable Modbus TCP-based counter, inverter, and battery components (configurable register mapping, datatype and endianness), aligned with the referenced UI/ticket work.
Changes:
- Introduces a configurable Modbus TCP device (
device.py) that instantiates counter/bat/inverter components. - Adds component implementations for counter, inverter, and battery that read values via Modbus input registers and publish
*Stateto the stores. - Adds configuration models (
config.py) and a small validation helper (helper.py) for register configuration completeness.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/modules/devices/generic/modbus/device.py | New configurable device wiring for Modbus TCP client + component creation/update. |
| packages/modules/devices/generic/modbus/config.py | New device/component configuration classes for universal Modbus mapping. |
| packages/modules/devices/generic/modbus/helper.py | Adds check_data() validation for register config completeness. |
| packages/modules/devices/generic/modbus/counter.py | New generic Modbus counter component reading power/energy and optional phase metrics. |
| packages/modules/devices/generic/modbus/inverter.py | New generic Modbus inverter component reading power/energy and optional currents/DC power. |
| packages/modules/devices/generic/modbus/bat.py | New generic Modbus battery component reading power/energy and optional SOC/currents. |
Comments suppressed due to low confidence (3)
packages/modules/devices/generic/modbus/config.py:32
gerneric_modbusist als mutable Dataclass mit nicht-optional typisierten Feldern deklariert, wird aber überall mitNoneinitialisiert. Dadurch stimmen Typen/Defaults nicht, und als Default-Argument wiederverwendete Instanzen können unbeabsichtigt geteilt werden. Optional-Felder mit Defaults (und idealerweise Immutability) vermeiden das.
@dataclass
class gerneric_modbus:
reg_address: int
reg_type: str
byteorder: str
wordorder: str
packages/modules/devices/generic/modbus/bat.py:137
- Optionalfelder werden nachträglich per
locals()am BatState gesetzt. Dadurch werden Defaulting/Validierungen inBatState.__init__(z.B. Strom-Sign-Prüfung) umgangen. Besser: Optionalwerte direkt im Konstruktor übergeben.
bat_state = BatState(
imported=imported,
exported=exported,
power=power,
)
packages/modules/devices/generic/modbus/counter.py:226
- Beim Simulieren von importiert/exportiert werden die simulierten Zählerstände erneut durch den PeakFilter geschickt. Der PeakFilter kann dabei (z.B. beim Startup) bewusst
Nonezurückgeben und so die simulierten Werte verwerfen. In anderen Modulen wird bei SimCounter nur die Leistung geprüft und die Zähler nicht gefiltert.
if power is not None:
self.peak_filter.check_values(power)
if imported is None or exported is None:
imported, exported = self.sim_counter.sim_count(power)
imported, exported = self.peak_filter.check_values(power, imported, exported)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| class GenericModbusConfiguration: | ||
| def __init__(self, ip_address: str = "192.168.1.230", port: int = 502): | ||
| self.ip_address = ip_address | ||
| self.port = port |
| if power is not None: | ||
| self.peak_filter.check_values(power) | ||
| if imported is None or exported is None: | ||
| imported, exported = self.sim_counter.sim_count(power) | ||
| imported, exported = self.peak_filter.check_values(power, imported, exported) |
| inverter_state = InverterState( | ||
| power=power, | ||
| exported=exported, | ||
| imported=imported, | ||
| ) | ||
|
|
||
| if "dc_power" in locals(): | ||
| inverter_state.dc_power = dc_power | ||
| if "currents" in locals(): | ||
| inverter_state.currents = currents | ||
| if 'serial_number' in locals(): | ||
| inverter_state.serial_number = serial_number |
| if power is not None: | ||
| self.peak_filter.check_values(power) | ||
| if imported is None or exported is None: | ||
| imported, exported = self.sim_counter.sim_count(power) | ||
| imported, exported = self.peak_filter.check_values(power, imported, exported) |
| counter_state = CounterState( | ||
| imported=imported, | ||
| exported=exported, | ||
| power=power | ||
| ) | ||
|
|
||
| if "voltages" in locals(): | ||
| counter_state.voltages = voltages | ||
| if "currents" in locals(): | ||
| counter_state.currents = currents | ||
| if 'powers' in locals(): | ||
| counter_state.powers = powers | ||
| if "power_factors" in locals(): | ||
| counter_state.power_factors = power_factors | ||
| if 'frequency' in locals(): | ||
| counter_state.frequency = frequency | ||
| if 'serial_number' in locals(): | ||
| counter_state.serial_number = serial_number |
| byteorder=self.component_config.configuration.powers_L3.byteorder, | ||
| wordorder=self.component_config.configuration.powers_L3.wordorder, unit=unit) | ||
|
|
||
| # Power Factors‚ |
| self.__modbus_id: int = self.kwargs['device_id'] | ||
| self.client: ModbusTcpClient_ = self.kwargs['client'] | ||
| self.store = get_bat_value_store(self.component_config.id) | ||
| self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config)) | ||
| self.peak_filter = PeakFilter(ComponentType.BAT, self.component_config.id, self.fault_state) | ||
| self.sim_counter = SimCounter(self.__modbus_id, self.component_config.id, prefix="speicher") |
d43ce0c to
bdd9550
Compare
bdd9550 to
adc6fed
Compare
| def initialize(self) -> None: | ||
| self.__modbus_id: int = self.kwargs['device_id'] | ||
| self.client: ModbusTcpClient_ = self.kwargs['client'] | ||
| self.store = get_bat_value_store(self.component_config.id) |
There was a problem hiding this comment.
| self.store = get_bat_value_store(self.component_config.id) | |
| self.store = get_component_value_store(self.component_config.type, self.component_config.id) |
Der Aufruf sieht einheitlich für alle Komponenten so aus.
| if self.component_config.configuration.power.reg_address is not None: | ||
| check_data(self.component_config.configuration.power) | ||
| power = self.client.read_input_registers( | ||
| int(self.component_config.configuration.power.reg_address), ModbusDataType[ |
There was a problem hiding this comment.
| int(self.component_config.configuration.power.reg_address), ModbusDataType[ | |
| self.component_config.configuration.power.reg_address, ModbusDataType[ |
reg_address und die anderen Adressen sollten doch schon ein int sein.
| soc = self.client.read_input_registers( | ||
| int(self.component_config.configuration.soc.reg_address), ModbusDataType[ | ||
| self.component_config.configuration.soc.reg_type], | ||
| byteorder=self.component_config.configuration.soc.byteorder, | ||
| wordorder=self.component_config.configuration.soc.wordorder, unit=unit) |
There was a problem hiding this comment.
Man könnte den Aufruf in eine eigene Methode oder Nested Function packen und self.component_config.configuration.soc als Variable übergeben. Der Typ ist ja immer gerneric_modbus.
|
|
||
|
|
||
| @dataclass | ||
| class gerneric_modbus: |
There was a problem hiding this comment.
Bitte an die Naming Convention für Klassennamen halten. Hier würde zB RegisterConfig oä passen.
| serial_number: gerneric_modbus = gerneric_modbus( | ||
| reg_address=None, reg_type=None, byteorder=None, wordorder=None)): | ||
|
|
||
| self.modbus_id = modbus_id |
There was a problem hiding this comment.
GenericModbusCounterConfiguration kannst Du als dataclass anlegen, dann musst Du nach init nicht nochmal alle Parameter zuweisen.
|
|
||
| class GenericModbusCounterConfiguration: | ||
| def __init__(self, modbus_id: int = 105, | ||
| voltage_L1: gerneric_modbus = gerneric_modbus( |
There was a problem hiding this comment.
Wenn Du gerneric_modbus Default-Parameter spendierst, kannst Du sie hier weglassen, dann ist die Liste nicht so lang.
| if (self.component_config.configuration.voltage_L1.reg_address is not None or | ||
| self.component_config.configuration.voltage_L2.reg_address is not None or | ||
| self.component_config.configuration.voltage_L3.reg_address is not None): | ||
| # Kein register angegeben |
There was a problem hiding this comment.
Der Kommentar ist nicht mehr aktuell.
| if soc_value is not None: | ||
| soc = soc_value |
There was a problem hiding this comment.
| if soc_value is not None: | |
| soc = soc_value |
Unten setzt Du auch auf None, wenn es die Variable nicht in locals gibt. Das ist dann doppelt.
| f"Unvollständige Konfiguration für Universeller-Modbus: Register-Adresse {wert.reg_address}") | ||
|
|
||
|
|
||
| def read_value(client: ModbusTcpClient_, unit: int, register_config: Any) -> Any: |
There was a problem hiding this comment.
| def read_value(client: ModbusTcpClient_, unit: int, register_config: Any) -> Any: | |
| def read_value(client: ModbusTcpClient_, unit: int, register_config: RegisterConfig) -> Any: |
UI: openWB/openwb-ui-settings#1023
Ticket: https://github.com/openWB/internal-projects/issues/327