diff --git a/src/batcontrol/inverter/baseclass.py b/src/batcontrol/inverter/baseclass.py index 1dc1cd2d..7887f074 100644 --- a/src/batcontrol/inverter/baseclass.py +++ b/src/batcontrol/inverter/baseclass.py @@ -1,6 +1,10 @@ """ Parent Class for implementing common functions for all inverters """ from .inverter_interface import InverterInterface +DEFAULT_MIN_SOC = 5 +DEFAULT_MAX_SOC = 100 + + class InverterBaseclass(InverterInterface): def __init__(self, config): self.min_soc = -1 diff --git a/src/batcontrol/inverter/fronius.py b/src/batcontrol/inverter/fronius.py index 9a15e88d..2a225ec8 100644 --- a/src/batcontrol/inverter/fronius.py +++ b/src/batcontrol/inverter/fronius.py @@ -26,7 +26,7 @@ import requests from packaging import version from cachetools import TTLCache -from .baseclass import InverterBaseclass +from .baseclass import DEFAULT_MAX_SOC, DEFAULT_MIN_SOC, InverterBaseclass logger = logging.getLogger(__name__) logger.info('Loading module ') @@ -201,8 +201,8 @@ def __init__(self, config: dict) -> None: self.previous_battery_config = self.get_battery_config() self.previous_backup_power_config = None # default values - self.max_soc = 100 - self.min_soc = 5 + self.max_soc = DEFAULT_MAX_SOC + self.min_soc = DEFAULT_MIN_SOC # Energy Management (EM) # 0 - On (Automatic , Default) # 1 - Off (Adjustable) diff --git a/src/batcontrol/inverter/fronius_modbus/__init__.py b/src/batcontrol/inverter/fronius_modbus/__init__.py new file mode 100644 index 00000000..adb680aa --- /dev/null +++ b/src/batcontrol/inverter/fronius_modbus/__init__.py @@ -0,0 +1,4 @@ +from .inverter import FroniusModbusInverter +from .tcp_transport import FroniusModbusTcpTransport + +__all__ = ["FroniusModbusInverter", "FroniusModbusTcpTransport"] diff --git a/src/batcontrol/inverter/fronius_modbus/commands.py b/src/batcontrol/inverter/fronius_modbus/commands.py new file mode 100644 index 00000000..a4e0809d --- /dev/null +++ b/src/batcontrol/inverter/fronius_modbus/commands.py @@ -0,0 +1,112 @@ +"""Pure Fronius GEN24 Modbus command building helpers.""" + +from .types import RegisterWrite + +REG_STORCTL_MOD = 40348 +REG_OUTWRTE = 40355 +REG_INWRTE = 40356 +REG_RVRT_TMS = 40358 +REG_CHAGRISET = 40360 + +STORCTL_CHARGE_LIMIT = 1 +STORCTL_DISCHARGE_LIMIT = 2 +DEFAULT_RATE_SCALE_FACTOR = -2 +FULL_RATE_PERCENT = 10000 + + +def signed_to_unsigned_16(value: int) -> int: + """Convert any integer to unsigned 16-bit (two's complement).""" + return value & 0xFFFF + + +def watts_to_pct_register_value( + watts: float, + max_charge_rate: float, + scale_factor: int = DEFAULT_RATE_SCALE_FACTOR, +) -> int: + """Convert watts to a scaled percentage register value. + + Raises: + ValueError: If ``max_charge_rate`` is zero or negative. + """ + if max_charge_rate <= 0: + raise ValueError( + f"max_charge_rate must be greater than 0, got {max_charge_rate}" + ) + + pct = max(0.0, min(100.0, (watts / max_charge_rate) * 100.0)) + return int(pct * (10 ** (-scale_factor))) + + + +def validate_revert_seconds(revert_seconds: int) -> int: + """Validate that revert_seconds fits into an unsigned 16-bit register.""" + if not 0 <= revert_seconds <= 65535: + raise ValueError("revert_seconds must be between 0 and 65535") + return revert_seconds + + + +def build_force_charge_register_writes( + rate_watts: float, + max_charge_rate: float, + revert_seconds: int = 0, +) -> list[RegisterWrite]: + """Build register writes for force-charge mode.""" + rate_value = watts_to_pct_register_value(rate_watts, max_charge_rate) + revert_seconds = validate_revert_seconds(revert_seconds) + + return [ + RegisterWrite(REG_CHAGRISET, 1), + RegisterWrite(REG_RVRT_TMS, revert_seconds), + RegisterWrite(REG_OUTWRTE, signed_to_unsigned_16(-rate_value)), + RegisterWrite(REG_INWRTE, FULL_RATE_PERCENT), + RegisterWrite(REG_STORCTL_MOD, STORCTL_DISCHARGE_LIMIT), + ] + + + +def build_avoid_discharge_register_writes( + revert_seconds: int = 0, +) -> list[RegisterWrite]: + """Build register writes for hold/avoid-discharge mode.""" + revert_seconds = validate_revert_seconds(revert_seconds) + + return [ + RegisterWrite(REG_RVRT_TMS, revert_seconds), + RegisterWrite(REG_OUTWRTE, 0), + RegisterWrite(REG_INWRTE, FULL_RATE_PERCENT), + RegisterWrite(REG_STORCTL_MOD, STORCTL_DISCHARGE_LIMIT), + ] + + + +def build_allow_discharge_register_writes() -> list[RegisterWrite]: + """Build register writes for returning to automatic mode.""" + return [ + RegisterWrite(REG_STORCTL_MOD, 0), + RegisterWrite(REG_OUTWRTE, FULL_RATE_PERCENT), + RegisterWrite(REG_INWRTE, FULL_RATE_PERCENT), + RegisterWrite(REG_RVRT_TMS, 0), + ] + + + +def build_limit_battery_charge_register_writes( + limit_charge_rate_watts: float, + max_charge_rate: float, + revert_seconds: int = 0, +) -> list[RegisterWrite]: + """Build register writes for limiting battery charge while allowing discharge.""" + rate_value = watts_to_pct_register_value( + limit_charge_rate_watts, + max_charge_rate, + ) + revert_seconds = validate_revert_seconds(revert_seconds) + + return [ + RegisterWrite(REG_RVRT_TMS, revert_seconds), + RegisterWrite(REG_OUTWRTE, FULL_RATE_PERCENT), + RegisterWrite(REG_INWRTE, rate_value), + RegisterWrite(REG_STORCTL_MOD, STORCTL_CHARGE_LIMIT), + ] diff --git a/src/batcontrol/inverter/fronius_modbus/control.py b/src/batcontrol/inverter/fronius_modbus/control.py new file mode 100644 index 00000000..7822bc91 --- /dev/null +++ b/src/batcontrol/inverter/fronius_modbus/control.py @@ -0,0 +1,47 @@ +from .commands import ( + build_allow_discharge_register_writes, + build_avoid_discharge_register_writes, + build_force_charge_register_writes, + build_limit_battery_charge_register_writes, +) +from .types import FroniusModbusTransport + + +class FroniusModbusControl: + def __init__( + self, + transport: FroniusModbusTransport, + max_charge_rate: float, + revert_seconds: int = 0, + ): + self.transport = transport + self.max_charge_rate = max_charge_rate + self.revert_seconds = revert_seconds + + def set_mode_force_charge(self, rate_watts: float): + self.transport.write_registers( + build_force_charge_register_writes( + rate_watts, + self.max_charge_rate, + revert_seconds=self.revert_seconds, + ) + ) + + def set_mode_avoid_discharge(self): + self.transport.write_registers( + build_avoid_discharge_register_writes( + revert_seconds=self.revert_seconds, + ) + ) + + def set_mode_allow_discharge(self): + self.transport.write_registers(build_allow_discharge_register_writes()) + + def set_mode_limit_battery_charge(self, rate_watts: float): + self.transport.write_registers( + build_limit_battery_charge_register_writes( + rate_watts, + self.max_charge_rate, + revert_seconds=self.revert_seconds, + ) + ) diff --git a/src/batcontrol/inverter/fronius_modbus/inverter.py b/src/batcontrol/inverter/fronius_modbus/inverter.py new file mode 100644 index 00000000..43774a11 --- /dev/null +++ b/src/batcontrol/inverter/fronius_modbus/inverter.py @@ -0,0 +1,80 @@ +import logging + +from ..baseclass import DEFAULT_MAX_SOC, DEFAULT_MIN_SOC, InverterBaseclass +from .control import FroniusModbusControl +from .storage_reader import FroniusModbusStorageReader +from .types import FroniusModbusTransport + +logger = logging.getLogger(__name__) + + +class FroniusModbusInverter(InverterBaseclass): + def __init__( + self, + transport: FroniusModbusTransport, + max_charge_rate: float, + capacity: float = -1, + min_soc: float = DEFAULT_MIN_SOC, + max_soc: float = DEFAULT_MAX_SOC, + revert_seconds: int = 0, + ): + super().__init__({}) + self.transport = transport + self.capacity = capacity + self.min_soc = min_soc + self.max_soc = max_soc + self.control = FroniusModbusControl( + transport, + max_charge_rate=max_charge_rate, + revert_seconds=revert_seconds, + ) + self.storage_reader = FroniusModbusStorageReader(transport) + + def set_mode_force_charge(self, chargerate: float): + self.control.set_mode_force_charge(chargerate) + + def set_mode_avoid_discharge(self): + self.control.set_mode_avoid_discharge() + + def set_mode_allow_discharge(self): + self.control.set_mode_allow_discharge() + + def set_mode_limit_battery_charge(self, limit_charge_rate: int): + self.control.set_mode_limit_battery_charge(limit_charge_rate) + + def get_capacity(self) -> float: + return self.capacity + + def read_storage_status(self): + return self.storage_reader.read_storage_status() + + def get_SOC(self) -> float: + return self.read_storage_status().soc_pct + + def get_max_charge_rate(self) -> float: + return self.read_storage_status().max_charge_rate_w + + def is_grid_charging_enabled(self) -> bool: + return self.read_storage_status().grid_charging_enabled + + def get_min_reserve_soc(self) -> float: + return self.read_storage_status().minimum_reserve_pct + + def get_charge_status(self) -> int: + return self.read_storage_status().charge_status + + def shutdown(self): + try: + self.control.set_mode_allow_discharge() + except Exception as exc: + logger.warning( + "Failed to restore automatic mode during shutdown: %s", + exc, + ) + finally: + close = getattr(self.transport, "close", None) + if close is not None: + close() + + def activate_mqtt(self, api_mqtt_api: object): + pass diff --git a/src/batcontrol/inverter/fronius_modbus/reads.py b/src/batcontrol/inverter/fronius_modbus/reads.py new file mode 100644 index 00000000..11a91b41 --- /dev/null +++ b/src/batcontrol/inverter/fronius_modbus/reads.py @@ -0,0 +1,106 @@ +"""Pure Fronius GEN24 Modbus storage-register decoding helpers.""" + +from dataclasses import dataclass + + +REG_WCHAMAX = 40345 +REG_STORCTL_MOD = 40348 +REG_MIN_RSV_PCT = 40350 +REG_CHASTATE = 40351 +REG_CHAST = 40354 +REG_OUTWRTE = 40355 +REG_INWRTE = 40356 +REG_RVRT_TMS = 40358 +REG_CHAGRISET = 40360 +REG_CHASTATE_SF = 40365 +REG_INOUTWRTE_SF = 40368 + +CHAGRISET_ENABLED = 1 + + +@dataclass(frozen=True) +class FroniusStorageStatus: + max_charge_rate_w: int + storage_control_mode: int + minimum_reserve_pct: float + soc_pct: float + charge_status: int + discharge_rate_pct: float + charge_rate_pct: float + revert_seconds: int + grid_charging_enabled: bool + soc_scale_factor: int + rate_scale_factor: int + + +def unsigned_to_signed_16(value: int) -> int: + """Convert an unsigned 16-bit Modbus value to signed.""" + if value >= 32768: + return value - 65536 + return value + + +def decode_scaled_percent(raw_value: int, scale_factor: int) -> float: + """Decode a SunSpec-style scaled percent value.""" + return raw_value * (10 ** scale_factor) + + +def decode_storage_status(registers: dict[int, int]) -> FroniusStorageStatus: + """Decode the known Fronius storage control/status registers. + + The register set and scale handling here match the behavior observed in: + - local live read-only probing + - `fronius-modbus-control` + - `redpomodoro/fronius_modbus` + + Note: `ChaGriSet` semantics are somewhat inconsistently documented across + ecosystem code. This module currently follows the live probe and + `fronius-modbus-control` interpretation that `1` means grid charging is + enabled. + """ + required_registers = [ + REG_WCHAMAX, + REG_STORCTL_MOD, + REG_MIN_RSV_PCT, + REG_CHASTATE, + REG_CHAST, + REG_OUTWRTE, + REG_INWRTE, + REG_RVRT_TMS, + REG_CHAGRISET, + REG_CHASTATE_SF, + REG_INOUTWRTE_SF, + ] + + for register in required_registers: + if register not in registers: + raise KeyError(f"Missing required register {register}") + + soc_scale_factor = unsigned_to_signed_16(registers[REG_CHASTATE_SF]) + rate_scale_factor = unsigned_to_signed_16(registers[REG_INOUTWRTE_SF]) + + return FroniusStorageStatus( + max_charge_rate_w=registers[REG_WCHAMAX], + storage_control_mode=registers[REG_STORCTL_MOD], + minimum_reserve_pct=decode_scaled_percent( + registers[REG_MIN_RSV_PCT], + soc_scale_factor, + ), + soc_pct=decode_scaled_percent( + registers[REG_CHASTATE], + soc_scale_factor, + ), + charge_status=registers[REG_CHAST], + discharge_rate_pct=decode_scaled_percent( + unsigned_to_signed_16(registers[REG_OUTWRTE]), + rate_scale_factor, + ), + charge_rate_pct=decode_scaled_percent( + unsigned_to_signed_16(registers[REG_INWRTE]), + rate_scale_factor, + ), + revert_seconds=registers[REG_RVRT_TMS], + grid_charging_enabled=registers[REG_CHAGRISET] == CHAGRISET_ENABLED, + soc_scale_factor=soc_scale_factor, + rate_scale_factor=rate_scale_factor, + ) diff --git a/src/batcontrol/inverter/fronius_modbus/storage_reader.py b/src/batcontrol/inverter/fronius_modbus/storage_reader.py new file mode 100644 index 00000000..919449ee --- /dev/null +++ b/src/batcontrol/inverter/fronius_modbus/storage_reader.py @@ -0,0 +1,22 @@ +from .reads import decode_storage_status +from .types import FroniusModbusTransport + + +REG_STORAGE_START = 40345 +REG_STORAGE_COUNT = 24 + + +class FroniusModbusStorageReader: + def __init__(self, transport: FroniusModbusTransport): + self.transport = transport + + def read_storage_status(self): + register_read = self.transport.read_registers( + REG_STORAGE_START, + REG_STORAGE_COUNT, + ) + registers = { + register_read.start_register + offset: value + for offset, value in enumerate(register_read.values) + } + return decode_storage_status(registers) diff --git a/src/batcontrol/inverter/fronius_modbus/tcp_transport.py b/src/batcontrol/inverter/fronius_modbus/tcp_transport.py new file mode 100644 index 00000000..34e87e7e --- /dev/null +++ b/src/batcontrol/inverter/fronius_modbus/tcp_transport.py @@ -0,0 +1,157 @@ +import socket +import struct + +from .types import RegisterRead, RegisterWrite + + +class ModbusTCPClient: + def __init__(self, host, port=502, slave_id=1, timeout=5): + self.host = host + self.port = port + self.slave_id = slave_id + self.timeout = timeout + self._sock = None + self._transaction_id = 0 + + def connect(self): + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(self.timeout) + self._sock.connect((self.host, self.port)) + + def close(self): + if self._sock: + try: + self._sock.close() + finally: + self._sock = None + + def _next_transaction_id(self): + self._transaction_id = (self._transaction_id + 1) & 0xFFFF + return self._transaction_id + + def _build_mbap_header(self, length): + tid = self._next_transaction_id() + return struct.pack(">HHHB", tid, 0, length, self.slave_id), tid + + def _recv_exact(self, n): + data = b"" + while len(data) < n: + chunk = self._sock.recv(n - len(data)) + if not chunk: + raise RuntimeError("Connection closed by remote") + data += chunk + return data + + def _send_and_receive(self, pdu): + header, tid = self._build_mbap_header(len(pdu) + 1) + self._sock.sendall(header + pdu) + + resp_header = self._recv_exact(7) + resp_tid, _resp_proto, resp_len, _resp_unit = struct.unpack(">HHHB", resp_header) + if resp_tid != tid: + raise RuntimeError( + f"Transaction ID mismatch: sent {tid}, got {resp_tid}" + ) + if resp_len < 2: + raise RuntimeError( + f"Malformed Modbus response length: expected at least 2, got {resp_len}" + ) + + resp_pdu = self._recv_exact(resp_len - 1) + if not resp_pdu: + raise RuntimeError("Malformed Modbus response: empty PDU") + if resp_pdu[0] & 0x80: + if len(resp_pdu) < 2: + raise RuntimeError( + "Malformed Modbus exception response: missing exception code" + ) + exception_code = resp_pdu[1] + raise RuntimeError(f"Modbus exception: {exception_code}") + return resp_pdu + + def read_holding_registers(self, address, count): + pdu = struct.pack(">BHH", 0x03, address, count) + resp = self._send_and_receive(pdu) + + if len(resp) < 2: + raise RuntimeError( + f"Read response too short: expected at least 2 bytes, got {len(resp)}" + ) + if resp[0] != 0x03: + raise RuntimeError( + f"Unexpected function code in read response: expected 3, got {resp[0]}" + ) + + byte_count = resp[1] + if byte_count != count * 2: + raise RuntimeError( + f"Expected {count * 2} data bytes, got {byte_count}" + ) + if len(resp) != byte_count + 2: + raise RuntimeError( + f"Read response data length mismatch: expected {byte_count + 2} bytes, got {len(resp)}" + ) + + return [ + struct.unpack(">H", resp[2 + i * 2 : 4 + i * 2])[0] + for i in range(count) + ] + + def write_register(self, address, value): + write_value = value & 0xFFFF + pdu = struct.pack(">BHH", 0x06, address, write_value) + resp = self._send_and_receive(pdu) + + if len(resp) < 5: + raise RuntimeError( + f"Write response too short: expected at least 5 bytes, got {len(resp)}" + ) + if resp[0] != 0x06: + raise RuntimeError( + f"Unexpected function code in write response: expected 6, got {resp[0]}" + ) + + resp_addr, resp_val = struct.unpack(">HH", resp[1:5]) + if resp_addr != address: + raise RuntimeError( + f"Address mismatch in write echo: sent {address}, got {resp_addr}" + ) + if resp_val != write_value: + raise RuntimeError( + f"Value mismatch in write echo: sent {write_value}, got {resp_val}" + ) + + +class FroniusModbusTcpTransport: + def __init__(self, host: str, port: int = 502, unit_id: int = 1): + self.client = ModbusTCPClient(host, port=port, slave_id=unit_id) + self.client.connect() + + def _retry_after_reconnect(self, operation): + try: + return operation() + except (ConnectionError, OSError): + self.client.close() + self.client.connect() + return operation() + + def read_registers(self, register: int, count: int) -> RegisterRead: + values = self._retry_after_reconnect( + lambda: self.client.read_holding_registers(register, count) + ) + return RegisterRead(start_register=register, values=values) + + def write_registers(self, writes: list[RegisterWrite]): + if not writes: + raise ValueError("writes must not be empty") + + for write in writes: + self._retry_after_reconnect( + lambda write=write: self.client.write_register( + write.register, + write.value, + ) + ) + + def close(self): + self.client.close() diff --git a/src/batcontrol/inverter/fronius_modbus/types.py b/src/batcontrol/inverter/fronius_modbus/types.py new file mode 100644 index 00000000..2b0746fb --- /dev/null +++ b/src/batcontrol/inverter/fronius_modbus/types.py @@ -0,0 +1,30 @@ +"""Shared Fronius Modbus value objects and protocols.""" + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class RegisterWrite: + """Single holding-register write.""" + + register: int + value: int + + +@dataclass(frozen=True) +class RegisterRead: + """Register block read result.""" + + start_register: int + values: list[int] + + +class FroniusModbusTransport(Protocol): + """Minimal transport seam for Fronius Modbus register access.""" + + def read_registers(self, register: int, count: int) -> RegisterRead: + """Read a contiguous register block.""" + + def write_registers(self, writes: list[RegisterWrite]): + """Write one ordered batch of register values.""" diff --git a/src/batcontrol/inverter/inverter.py b/src/batcontrol/inverter/inverter.py index c12306c4..7c65de9d 100644 --- a/src/batcontrol/inverter/inverter.py +++ b/src/batcontrol/inverter/inverter.py @@ -1,6 +1,9 @@ """ Factory for inverter providers """ import logging +from .baseclass import DEFAULT_MAX_SOC, DEFAULT_MIN_SOC +from .fronius_modbus import FroniusModbusInverter +from .fronius_modbus import FroniusModbusTcpTransport from .inverter_interface import InverterInterface from .resilient_wrapper import ( ResilientInverterWrapper, @@ -53,11 +56,25 @@ def create_inverter(config: dict) -> InverterInterface: iv_config = { 'base_topic': config.get('base_topic', 'default'), 'capacity': config['capacity'], - 'min_soc': config.get('min_soc', 5), - 'max_soc': config.get('max_soc', 100), + 'min_soc': config.get('min_soc', DEFAULT_MIN_SOC), + 'max_soc': config.get('max_soc', DEFAULT_MAX_SOC), 'max_grid_charge_rate': config['max_grid_charge_rate'] } inverter=MqttInverter(iv_config) + elif config['type'].lower() == 'fronius-modbus': + transport = FroniusModbusTcpTransport( + config['address'], + port=config.get('port', 502), + unit_id=config.get('unit_id', 1), + ) + inverter = FroniusModbusInverter( + transport, + max_charge_rate=config['max_grid_charge_rate'], + capacity=config['capacity'], + min_soc=config.get('min_soc', DEFAULT_MIN_SOC), + max_soc=config.get('max_soc', DEFAULT_MAX_SOC), + revert_seconds=config.get('revert_seconds', 0), + ) else: raise RuntimeError(f'[Inverter] Unkown inverter type {config["type"]}') diff --git a/src/batcontrol/inverter/mqtt_inverter.py b/src/batcontrol/inverter/mqtt_inverter.py index 2768cfab..f73c6d2d 100644 --- a/src/batcontrol/inverter/mqtt_inverter.py +++ b/src/batcontrol/inverter/mqtt_inverter.py @@ -169,7 +169,7 @@ def on_message(client, userdata, message): import logging import time from cachetools import TTLCache -from .baseclass import InverterBaseclass +from .baseclass import DEFAULT_MAX_SOC, DEFAULT_MIN_SOC, InverterBaseclass from ..mqtt_api import MqttApi logger = logging.getLogger(__name__) @@ -207,8 +207,8 @@ def __init__(self, config): self.cache_ttl = config.get('cache_ttl', 120) # Battery parameters (from config or defaults) - self.min_soc = config.get('min_soc', 5) - self.max_soc = config.get('max_soc', 100) + self.min_soc = config.get('min_soc', DEFAULT_MIN_SOC) + self.max_soc = config.get('max_soc', DEFAULT_MAX_SOC) # These values should be set in the config, if not throw ValueError if 'capacity' not in config: diff --git a/tests/batcontrol/inverter/test_fronius_modbus_commands.py b/tests/batcontrol/inverter/test_fronius_modbus_commands.py new file mode 100644 index 00000000..a69f68a1 --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_modbus_commands.py @@ -0,0 +1,177 @@ +import pytest + +from batcontrol.inverter.fronius_modbus.commands import ( + FULL_RATE_PERCENT, + REG_CHAGRISET, + REG_INWRTE, + REG_OUTWRTE, + REG_RVRT_TMS, + REG_STORCTL_MOD, + STORCTL_CHARGE_LIMIT, + STORCTL_DISCHARGE_LIMIT, + build_allow_discharge_register_writes, + build_avoid_discharge_register_writes, + build_force_charge_register_writes, + build_limit_battery_charge_register_writes, + signed_to_unsigned_16, + watts_to_pct_register_value, +) + + +def as_write_map(writes): + write_map = {write.register: write.value for write in writes} + + assert len(write_map) == len(writes) + + return write_map + + +def test_watts_to_pct_register_value_scales_partial_rate(): + assert watts_to_pct_register_value(1250, 5000) == 2500 + + +def test_watts_to_pct_register_value_supports_custom_scale_factor(): + assert watts_to_pct_register_value(1250, 5000, scale_factor=-1) == 250 + + +def test_watts_to_pct_register_value_rejects_non_positive_max_charge_rate(): + try: + watts_to_pct_register_value(1250, 0) + except ValueError as exc: + assert str(exc) == "max_charge_rate must be greater than 0, got 0" + else: + raise AssertionError("Expected watts_to_pct_register_value to reject max_charge_rate <= 0") + + +def test_signed_to_unsigned_16_masks_large_negative_values(): + assert signed_to_unsigned_16(-70000) == (-70000 & 0xFFFF) + + +def test_force_charge_uses_negative_outwrte_value(): + write_map = as_write_map( + build_force_charge_register_writes(3000, 5000, revert_seconds=900) + ) + + assert list(write_map.keys()) == [ + REG_CHAGRISET, + REG_RVRT_TMS, + REG_OUTWRTE, + REG_INWRTE, + REG_STORCTL_MOD, + ] + assert write_map[REG_CHAGRISET] == 1 + assert write_map[REG_RVRT_TMS] == 900 + assert write_map[REG_OUTWRTE] == signed_to_unsigned_16(-6000) + assert write_map[REG_INWRTE] == FULL_RATE_PERCENT + assert write_map[REG_STORCTL_MOD] == STORCTL_DISCHARGE_LIMIT + + +def test_avoid_discharge_sets_zero_outwrte_and_preserves_revert_timer(): + write_map = as_write_map(build_avoid_discharge_register_writes(revert_seconds=900)) + + assert list(write_map.keys()) == [ + REG_RVRT_TMS, + REG_OUTWRTE, + REG_INWRTE, + REG_STORCTL_MOD, + ] + assert write_map[REG_RVRT_TMS] == 900 + assert write_map[REG_OUTWRTE] == 0 + assert write_map[REG_INWRTE] == FULL_RATE_PERCENT + assert write_map[REG_STORCTL_MOD] == STORCTL_DISCHARGE_LIMIT + + +def test_allow_discharge_restores_auto_defaults_and_clears_revert_timer(): + write_map = as_write_map(build_allow_discharge_register_writes()) + + assert list(write_map.keys()) == [ + REG_STORCTL_MOD, + REG_OUTWRTE, + REG_INWRTE, + REG_RVRT_TMS, + ] + assert write_map[REG_STORCTL_MOD] == 0 + assert write_map[REG_OUTWRTE] == FULL_RATE_PERCENT + assert write_map[REG_INWRTE] == FULL_RATE_PERCENT + assert write_map[REG_RVRT_TMS] == 0 + + +def test_limit_battery_charge_uses_positive_inwrte_and_preserves_discharge(): + write_map = as_write_map( + build_limit_battery_charge_register_writes( + 2000, + 5000, + revert_seconds=900, + ) + ) + + assert list(write_map.keys()) == [ + REG_RVRT_TMS, + REG_OUTWRTE, + REG_INWRTE, + REG_STORCTL_MOD, + ] + assert write_map[REG_RVRT_TMS] == 900 + assert write_map[REG_OUTWRTE] == FULL_RATE_PERCENT + assert write_map[REG_INWRTE] == 4000 + assert write_map[REG_STORCTL_MOD] == STORCTL_CHARGE_LIMIT + + +def test_force_charge_clamps_above_max_charge_rate(): + write_map = as_write_map(build_force_charge_register_writes(6000, 5000)) + + assert write_map[REG_OUTWRTE] == signed_to_unsigned_16(-10000) + + +def test_limit_battery_charge_clamps_above_max_charge_rate(): + write_map = as_write_map(build_limit_battery_charge_register_writes(6000, 5000)) + + assert write_map[REG_INWRTE] == FULL_RATE_PERCENT + + +def test_limit_battery_charge_handles_zero_rate(): + write_map = as_write_map(build_limit_battery_charge_register_writes(0, 5000)) + + assert write_map[REG_INWRTE] == 0 + + +def test_force_charge_allows_zero_revert_timer_when_requested(): + write_map = as_write_map( + build_force_charge_register_writes(3000, 5000, revert_seconds=0) + ) + + assert write_map[REG_RVRT_TMS] == 0 + + +def test_limit_battery_charge_allows_zero_revert_timer_when_requested(): + write_map = as_write_map( + build_limit_battery_charge_register_writes(2000, 5000, revert_seconds=0) + ) + + assert write_map[REG_RVRT_TMS] == 0 + + +@pytest.mark.parametrize( + "builder,args", + [ + (build_force_charge_register_writes, (3000, 5000)), + (build_avoid_discharge_register_writes, ()), + (build_limit_battery_charge_register_writes, (2000, 5000)), + ], +) +def test_builders_reject_negative_revert_seconds(builder, args): + with pytest.raises(ValueError, match="revert_seconds must be between 0 and 65535"): + builder(*args, revert_seconds=-1) + + +@pytest.mark.parametrize( + "builder,args", + [ + (build_force_charge_register_writes, (3000, 5000)), + (build_avoid_discharge_register_writes, ()), + (build_limit_battery_charge_register_writes, (2000, 5000)), + ], +) +def test_builders_reject_too_large_revert_seconds(builder, args): + with pytest.raises(ValueError, match="revert_seconds must be between 0 and 65535"): + builder(*args, revert_seconds=65536) diff --git a/tests/batcontrol/inverter/test_fronius_modbus_control.py b/tests/batcontrol/inverter/test_fronius_modbus_control.py new file mode 100644 index 00000000..5adb05b5 --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_modbus_control.py @@ -0,0 +1,83 @@ +from batcontrol.inverter.fronius_modbus.commands import ( + build_allow_discharge_register_writes, + build_avoid_discharge_register_writes, + build_force_charge_register_writes, + build_limit_battery_charge_register_writes, +) +from batcontrol.inverter.fronius_modbus.control import FroniusModbusControl +from batcontrol.inverter.fronius_modbus.types import RegisterWrite + + +class RecordingModbusTransport: + def __init__(self): + self.writes = [] + + def write_registers(self, writes: list[RegisterWrite]): + self.writes.append(writes) + + +def test_force_charge_writes_command_builder_output(): + transport = RecordingModbusTransport() + control = FroniusModbusControl( + transport, + max_charge_rate=5000, + revert_seconds=900, + ) + + control.set_mode_force_charge(3000) + + assert transport.writes == [ + build_force_charge_register_writes(3000, 5000, revert_seconds=900) + ] + + +def test_avoid_discharge_writes_command_builder_output(): + transport = RecordingModbusTransport() + control = FroniusModbusControl( + transport, + max_charge_rate=5000, + revert_seconds=900, + ) + + control.set_mode_avoid_discharge() + + assert transport.writes == [build_avoid_discharge_register_writes(revert_seconds=900)] + + +def test_allow_discharge_writes_command_builder_output(): + transport = RecordingModbusTransport() + control = FroniusModbusControl( + transport, + max_charge_rate=5000, + revert_seconds=900, + ) + + control.set_mode_allow_discharge() + + assert transport.writes == [build_allow_discharge_register_writes()] + + +def test_limit_battery_charge_writes_command_builder_output(): + transport = RecordingModbusTransport() + control = FroniusModbusControl( + transport, + max_charge_rate=5000, + revert_seconds=900, + ) + + control.set_mode_limit_battery_charge(2000) + + assert transport.writes == [ + build_limit_battery_charge_register_writes(2000, 5000, revert_seconds=900) + ] + + +def test_revert_seconds_defaults_to_zero(): + transport = RecordingModbusTransport() + control = FroniusModbusControl(transport, max_charge_rate=5000) + + control.set_mode_force_charge(3000) + + assert transport.writes == [ + build_force_charge_register_writes(3000, 5000, revert_seconds=0) + ] diff --git a/tests/batcontrol/inverter/test_fronius_modbus_factory.py b/tests/batcontrol/inverter/test_fronius_modbus_factory.py new file mode 100644 index 00000000..a34069bf --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_modbus_factory.py @@ -0,0 +1,135 @@ +import pytest + +from batcontrol.inverter.fronius_modbus.inverter import FroniusModbusInverter +from batcontrol.inverter.inverter import Inverter + + +@pytest.fixture(autouse=True) +def reset_inverter_counter(): + original_value = Inverter.num_inverters + Inverter.num_inverters = 0 + + yield + + Inverter.num_inverters = original_value + + +def test_factory_creates_fronius_modbus_inverter_with_expected_defaults(mocker): + mock_transport = mocker.MagicMock() + mock_transport_cls = mocker.patch( + "batcontrol.inverter.inverter.FroniusModbusTcpTransport", + autospec=True, + return_value=mock_transport, + ) + + config = { + "type": "fronius-modbus", + "address": "192.168.1.100", + "capacity": 10000, + "max_grid_charge_rate": 5000, + } + + inverter = Inverter.create_inverter(config) + + mock_transport_cls.assert_called_once_with("192.168.1.100", port=502, unit_id=1) + assert isinstance(inverter, FroniusModbusInverter) + assert inverter.transport is mock_transport + assert inverter.get_capacity() == 10000 + assert inverter.min_soc == 5 + assert inverter.max_soc == 100 + + +def test_factory_passes_explicit_fronius_modbus_config_values(mocker): + mock_transport = mocker.MagicMock() + mock_transport_cls = mocker.patch( + "batcontrol.inverter.inverter.FroniusModbusTcpTransport", + autospec=True, + return_value=mock_transport, + ) + + config = { + "type": "fronius-modbus", + "address": "192.168.1.100", + "port": 1502, + "unit_id": 3, + "capacity": 12000, + "min_soc": 10, + "max_soc": 95, + "max_grid_charge_rate": 6000, + "revert_seconds": 900, + } + + inverter = Inverter.create_inverter(config) + + mock_transport_cls.assert_called_once_with("192.168.1.100", port=1502, unit_id=3) + assert isinstance(inverter, FroniusModbusInverter) + assert inverter.transport is mock_transport + assert inverter.get_capacity() == 12000 + assert inverter.min_soc == 10 + assert inverter.max_soc == 95 + assert inverter.control.revert_seconds == 900 + + +def test_factory_accepts_fronius_modbus_type_case_insensitively(mocker): + mocker.patch( + "batcontrol.inverter.inverter.FroniusModbusTcpTransport", + autospec=True, + return_value=mocker.MagicMock(), + ) + + config = { + "type": "FRONIUS-MODBUS", + "address": "192.168.1.100", + "capacity": 10000, + "max_grid_charge_rate": 5000, + } + + inverter = Inverter.create_inverter(config) + + assert isinstance(inverter, FroniusModbusInverter) + + +@pytest.mark.parametrize( + "missing_key", + ["address", "capacity"], +) +def test_factory_requires_minimal_fronius_modbus_config(mocker, missing_key): + mocker.patch( + "batcontrol.inverter.inverter.FroniusModbusTcpTransport", + autospec=True, + return_value=mocker.MagicMock(), + ) + + config = { + "type": "fronius-modbus", + "address": "192.168.1.100", + "capacity": 10000, + "max_grid_charge_rate": 5000, + } + del config[missing_key] + + with pytest.raises(KeyError, match=missing_key): + Inverter.create_inverter(config) + + + +def test_factory_accepts_legacy_max_charge_rate_alias_for_fronius_modbus(mocker): + mock_transport = mocker.MagicMock() + mock_transport_cls = mocker.patch( + "batcontrol.inverter.inverter.FroniusModbusTcpTransport", + autospec=True, + return_value=mock_transport, + ) + + config = { + "type": "fronius-modbus", + "address": "192.168.1.100", + "capacity": 10000, + "max_charge_rate": 4200, + } + + inverter = Inverter.create_inverter(config) + + mock_transport_cls.assert_called_once_with("192.168.1.100", port=502, unit_id=1) + assert isinstance(inverter, FroniusModbusInverter) + assert inverter.control.max_charge_rate == 4200 diff --git a/tests/batcontrol/inverter/test_fronius_modbus_inverter.py b/tests/batcontrol/inverter/test_fronius_modbus_inverter.py new file mode 100644 index 00000000..7f095883 --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_modbus_inverter.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +from batcontrol.inverter.fronius_modbus.inverter import FroniusModbusInverter +from batcontrol.inverter.fronius_modbus.types import RegisterRead, RegisterWrite + + +class RecordingModbusTransport: + def __init__( + self, + reads: dict[tuple[int, int], RegisterRead] | None = None, + write_error: Exception | None = None, + ): + self.reads = reads or {} + self.write_error = write_error + self.read_requests = [] + self.writes = [] + self.events = [] + self.close_count = 0 + + def read_registers(self, register: int, count: int) -> RegisterRead: + self.read_requests.append((register, count)) + return self.reads[(register, count)] + + def write_registers(self, writes: list[RegisterWrite]): + self.events.append("write") + if self.write_error is not None: + raise self.write_error + self.writes.append(writes) + + def close(self): + self.events.append("close") + self.close_count += 1 + + +def test_inverter_reads_soc_via_storage_reader(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[ + 10240, + 0, + 0, + 2, + 0, + 1000, + 9700, + 0, + 0, + 3, + 10000, + 10000, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 65534, + 0, + 0, + 65534, + ], + ) + } + ) + inverter = FroniusModbusInverter( + transport, + max_charge_rate=5000, + min_soc=10, + max_soc=95, + ) + + assert inverter.get_SOC() == 97.0 + + +def test_inverter_delegates_force_charge_to_control_layer(): + transport = RecordingModbusTransport() + inverter = FroniusModbusInverter( + transport, + max_charge_rate=5000, + revert_seconds=900, + ) + + inverter.set_mode_force_charge(3000) + + assert transport.writes == [ + [ + RegisterWrite(40360, 1), + RegisterWrite(40358, 900), + RegisterWrite(40355, 59536), + RegisterWrite(40356, 10000), + RegisterWrite(40348, 2), + ] + ] + + +def test_inverter_exposes_configured_capacity_limits_for_baseclass_math(): + transport = RecordingModbusTransport() + inverter = FroniusModbusInverter( + transport, + max_charge_rate=5000, + capacity=10000, + min_soc=10, + max_soc=95, + ) + + assert inverter.get_capacity() == 10000 + assert inverter.min_soc == 10 + assert inverter.max_soc == 95 + + +def test_inverter_defaults_to_common_soc_limits(): + transport = RecordingModbusTransport() + inverter = FroniusModbusInverter( + transport, + max_charge_rate=5000, + capacity=10000, + ) + + assert inverter.min_soc == 5 + assert inverter.max_soc == 100 + + +def test_inverter_uses_default_soc_limits_in_baseclass_math(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[ + 10240, + 0, + 0, + 2, + 0, + 1000, + 6500, + 0, + 0, + 3, + 10000, + 10000, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 65534, + 0, + 0, + 65534, + ], + ) + } + ) + inverter = FroniusModbusInverter( + transport, + max_charge_rate=5000, + capacity=10000, + ) + + assert inverter.get_stored_energy() == 6500 + assert inverter.get_stored_usable_energy() == 6000 + assert inverter.get_free_capacity() == 3500 + + +def test_inverter_exposes_decoded_storage_status(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[ + 10240, + 0, + 0, + 2, + 0, + 1000, + 9700, + 0, + 0, + 3, + 10000, + 10000, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 65534, + 0, + 0, + 65534, + ], + ) + } + ) + inverter = FroniusModbusInverter(transport, max_charge_rate=5000) + + status = inverter.read_storage_status() + + assert status.soc_pct == 97.0 + assert status.max_charge_rate_w == 10240 + assert status.minimum_reserve_pct == 10.0 + + +def test_inverter_reads_max_charge_rate_from_storage_status(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[ + 10240, + 0, + 0, + 2, + 0, + 1000, + 9700, + 0, + 0, + 3, + 10000, + 10000, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 65534, + 0, + 0, + 65534, + ], + ) + } + ) + inverter = FroniusModbusInverter(transport, max_charge_rate=5000) + + assert inverter.get_max_charge_rate() == 10240 + + +def test_inverter_reports_grid_charging_enabled_from_storage_status(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[ + 10240, + 0, + 0, + 2, + 0, + 1000, + 9700, + 0, + 0, + 3, + 10000, + 10000, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 65534, + 0, + 0, + 65534, + ], + ) + } + ) + inverter = FroniusModbusInverter(transport, max_charge_rate=5000) + + assert inverter.is_grid_charging_enabled() is True + + +def test_inverter_reads_min_reserve_soc_from_storage_status(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[ + 10240, + 0, + 0, + 2, + 0, + 1000, + 9700, + 0, + 0, + 3, + 10000, + 10000, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 65534, + 0, + 0, + 65534, + ], + ) + } + ) + inverter = FroniusModbusInverter(transport, max_charge_rate=5000) + + assert inverter.get_min_reserve_soc() == 10.0 + + +def test_inverter_reads_charge_status_from_storage_status(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[ + 10240, + 0, + 0, + 2, + 0, + 1000, + 9700, + 0, + 0, + 3, + 10000, + 10000, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 65534, + 0, + 0, + 65534, + ], + ) + } + ) + inverter = FroniusModbusInverter(transport, max_charge_rate=5000) + + assert inverter.get_charge_status() == 3 + + + +def test_shutdown_restores_automatic_mode_before_closing_transport(): + transport = RecordingModbusTransport() + inverter = FroniusModbusInverter(transport, max_charge_rate=5000) + + inverter.shutdown() + + assert transport.writes == [[ + RegisterWrite(40348, 0), + RegisterWrite(40355, 10000), + RegisterWrite(40356, 10000), + RegisterWrite(40358, 0), + ]] + assert transport.events == ["write", "close"] + assert transport.close_count == 1 + + + +def test_shutdown_closes_transport_even_if_reset_to_auto_fails(): + transport = RecordingModbusTransport(write_error=RuntimeError("write failed")) + inverter = FroniusModbusInverter(transport, max_charge_rate=5000) + + inverter.shutdown() + + assert transport.events == ["write", "close"] + assert transport.close_count == 1 diff --git a/tests/batcontrol/inverter/test_fronius_modbus_reads.py b/tests/batcontrol/inverter/test_fronius_modbus_reads.py new file mode 100644 index 00000000..0510dc94 --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_modbus_reads.py @@ -0,0 +1,91 @@ +from batcontrol.inverter.fronius_modbus.reads import ( + FroniusStorageStatus, + decode_scaled_percent, + decode_storage_status, + unsigned_to_signed_16, +) + + +def test_unsigned_to_signed_16_preserves_positive_values(): + assert unsigned_to_signed_16(12345) == 12345 + + +def test_unsigned_to_signed_16_decodes_twos_complement_negative_values(): + assert unsigned_to_signed_16(65535) == -1 + + +def test_decode_scaled_percent_supports_negative_scale_factors(): + assert decode_scaled_percent(9700, -2) == 97.0 + + +def test_decode_scaled_percent_supports_zero_scale_factor(): + assert decode_scaled_percent(42, 0) == 42.0 + + +def test_decode_storage_status_decodes_known_storage_register_block(): + registers = { + 40345: 10240, + 40348: 2, + 40350: 1000, + 40351: 9700, + 40354: 3, + 40355: 10000, + 40356: 10000, + 40358: 0, + 40360: 1, + 40365: 65534, + 40368: 65534, + } + + status = decode_storage_status(registers) + + assert status == FroniusStorageStatus( + max_charge_rate_w=10240, + storage_control_mode=2, + minimum_reserve_pct=10.0, + soc_pct=97.0, + charge_status=3, + discharge_rate_pct=100.0, + charge_rate_pct=100.0, + revert_seconds=0, + grid_charging_enabled=True, + soc_scale_factor=-2, + rate_scale_factor=-2, + ) + + +def test_decode_storage_status_decodes_negative_discharge_rate(): + registers = { + 40345: 5000, + 40348: 2, + 40350: 500, + 40351: 5000, + 40354: 4, + 40355: 59536, + 40356: 10000, + 40358: 900, + 40360: 1, + 40365: 65534, + 40368: 65534, + } + + status = decode_storage_status(registers) + + assert status.discharge_rate_pct == -60.0 + assert status.charge_rate_pct == 100.0 + assert status.revert_seconds == 900 + assert status.grid_charging_enabled is True + + +def test_decode_storage_status_raises_for_missing_required_register(): + registers = { + 40345: 10240, + 40348: 2, + } + + try: + decode_storage_status(registers) + except KeyError as exc: + assert str(exc) == "'Missing required register 40350'" + else: + raise AssertionError('Expected decode_storage_status to raise for missing register') diff --git a/tests/batcontrol/inverter/test_fronius_modbus_storage_reader.py b/tests/batcontrol/inverter/test_fronius_modbus_storage_reader.py new file mode 100644 index 00000000..ebe0bb4f --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_modbus_storage_reader.py @@ -0,0 +1,129 @@ +from batcontrol.inverter.fronius_modbus.reads import FroniusStorageStatus +from batcontrol.inverter.fronius_modbus.storage_reader import FroniusModbusStorageReader +from batcontrol.inverter.fronius_modbus.types import RegisterRead + + +class RecordingModbusTransport: + def __init__(self, reads: dict[tuple[int, int], RegisterRead]): + self.reads = reads + self.read_requests = [] + + def read_registers(self, register: int, count: int) -> RegisterRead: + self.read_requests.append((register, count)) + return self.reads[(register, count)] + + +def test_reader_reads_storage_status_from_known_block(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[ + 10240, + 0, + 0, + 2, + 0, + 1000, + 9700, + 0, + 0, + 3, + 10000, + 10000, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 65534, + 0, + 0, + 65534, + ], + ) + } + ) + reader = FroniusModbusStorageReader(transport) + + status = reader.read_storage_status() + + assert status == FroniusStorageStatus( + max_charge_rate_w=10240, + storage_control_mode=2, + minimum_reserve_pct=10.0, + soc_pct=97.0, + charge_status=3, + discharge_rate_pct=100.0, + charge_rate_pct=100.0, + revert_seconds=0, + grid_charging_enabled=True, + soc_scale_factor=-2, + rate_scale_factor=-2, + ) + + +def test_reader_requests_expected_storage_register_block(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[0] * 24, + ) + } + ) + reader = FroniusModbusStorageReader(transport) + + reader.read_storage_status() + + assert transport.read_requests == [(40345, 24)] + + +def test_reader_maps_register_block_by_absolute_register_number(): + transport = RecordingModbusTransport( + reads={ + (40345, 24): RegisterRead( + start_register=40345, + values=[ + 5000, + 0, + 0, + 2, + 0, + 500, + 2500, + 0, + 0, + 4, + 59536, + 10000, + 0, + 900, + 0, + 1, + 0, + 0, + 0, + 0, + 65534, + 0, + 0, + 65534, + ], + ) + } + ) + reader = FroniusModbusStorageReader(transport) + + status = reader.read_storage_status() + + assert status.max_charge_rate_w == 5000 + assert status.minimum_reserve_pct == 5.0 + assert status.soc_pct == 25.0 + assert status.charge_status == 4 + assert status.discharge_rate_pct == -60.0 + assert status.charge_rate_pct == 100.0 + assert status.revert_seconds == 900 diff --git a/tests/batcontrol/inverter/test_fronius_modbus_tcp_transport.py b/tests/batcontrol/inverter/test_fronius_modbus_tcp_transport.py new file mode 100644 index 00000000..eacf377e --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_modbus_tcp_transport.py @@ -0,0 +1,190 @@ +from unittest.mock import MagicMock, call, patch + +import pytest + +import struct + +from batcontrol.inverter.fronius_modbus.tcp_transport import ( + FroniusModbusTcpTransport, + ModbusTCPClient, +) +from batcontrol.inverter.fronius_modbus.types import RegisterRead, RegisterWrite + + +def test_transport_reads_holding_registers_via_modbus_client(): + mock_client = MagicMock() + mock_client.read_holding_registers.return_value = [10240, 0, 0] + + with patch( + "batcontrol.inverter.fronius_modbus.tcp_transport.ModbusTCPClient", + return_value=mock_client, + ): + transport = FroniusModbusTcpTransport("192.168.1.100", port=502, unit_id=1) + + result = transport.read_registers(40345, 3) + + assert result == RegisterRead(start_register=40345, values=[10240, 0, 0]) + mock_client.read_holding_registers.assert_called_once_with(40345, 3) + + +def test_transport_writes_registers_in_order_via_modbus_client(): + mock_client = MagicMock() + + with patch( + "batcontrol.inverter.fronius_modbus.tcp_transport.ModbusTCPClient", + return_value=mock_client, + ): + transport = FroniusModbusTcpTransport("192.168.1.100", port=502, unit_id=1) + + transport.write_registers( + [ + RegisterWrite(40360, 1), + RegisterWrite(40358, 900), + RegisterWrite(40355, 59536), + ] + ) + + assert mock_client.write_register.call_args_list == [ + call(40360, 1), + call(40358, 900), + call(40355, 59536), + ] + + +def test_transport_connects_client_on_initialization(): + mock_client = MagicMock() + + with patch( + "batcontrol.inverter.fronius_modbus.tcp_transport.ModbusTCPClient", + return_value=mock_client, + ): + FroniusModbusTcpTransport("192.168.1.100", port=1502, unit_id=3) + + mock_client.connect.assert_called_once_with() + + +def test_transport_passes_host_port_and_unit_id_to_modbus_client(): + with patch("batcontrol.inverter.fronius_modbus.tcp_transport.ModbusTCPClient") as mock_cls: + FroniusModbusTcpTransport("192.168.1.100", port=1502, unit_id=3) + + mock_cls.assert_called_once_with("192.168.1.100", port=1502, slave_id=3) + + +def test_transport_close_closes_modbus_client(): + mock_client = MagicMock() + + with patch( + "batcontrol.inverter.fronius_modbus.tcp_transport.ModbusTCPClient", + return_value=mock_client, + ): + transport = FroniusModbusTcpTransport("192.168.1.100", port=502, unit_id=1) + + transport.close() + + mock_client.close.assert_called_once_with() + + +def test_transport_reconnects_once_and_retries_read_after_connection_loss(): + mock_client = MagicMock() + mock_client.read_holding_registers.side_effect = [ + ConnectionError("Connection closed by remote"), + [10240, 0, 0], + ] + + with patch( + "batcontrol.inverter.fronius_modbus.tcp_transport.ModbusTCPClient", + return_value=mock_client, + ): + transport = FroniusModbusTcpTransport("192.168.1.100", port=502, unit_id=1) + + result = transport.read_registers(40345, 3) + + assert result == RegisterRead(start_register=40345, values=[10240, 0, 0]) + assert mock_client.read_holding_registers.call_args_list == [call(40345, 3), call(40345, 3)] + assert mock_client.close.call_count == 1 + assert mock_client.connect.call_count == 2 + + +def test_transport_reconnects_once_and_retries_write_after_connection_loss(): + mock_client = MagicMock() + mock_client.write_register.side_effect = [ + ConnectionError("Connection closed by remote"), + None, + ] + + with patch( + "batcontrol.inverter.fronius_modbus.tcp_transport.ModbusTCPClient", + return_value=mock_client, + ): + transport = FroniusModbusTcpTransport("192.168.1.100", port=502, unit_id=1) + + transport.write_registers([RegisterWrite(40360, 1)]) + + assert mock_client.write_register.call_args_list == [call(40360, 1), call(40360, 1)] + assert mock_client.close.call_count == 1 + assert mock_client.connect.call_count == 2 + + +def test_transport_rejects_empty_write_batch(): + mock_client = MagicMock() + + with patch( + "batcontrol.inverter.fronius_modbus.tcp_transport.ModbusTCPClient", + return_value=mock_client, + ): + transport = FroniusModbusTcpTransport("192.168.1.100", port=502, unit_id=1) + + with pytest.raises(ValueError, match="writes must not be empty"): + transport.write_registers([]) + + +def test_client_rejects_malformed_response_length_before_decoding_pdu(): + client = ModbusTCPClient("192.168.1.100") + client._sock = MagicMock() + client._build_mbap_header = MagicMock(return_value=(b"header", 1)) + client._recv_exact = MagicMock( + side_effect=[ + struct.pack(">HHHB", 1, 0, 1, 1), + ] + ) + + with pytest.raises( + RuntimeError, + match="Malformed Modbus response length: expected at least 2, got 1", + ): + client._send_and_receive(b"\x03\x00\x00\x00\x01") + + +def test_client_rejects_unexpected_function_code_in_read_response(): + client = ModbusTCPClient("192.168.1.100") + client._send_and_receive = MagicMock(return_value=b"\x06\x02\x00\x01") + + with pytest.raises( + RuntimeError, + match="Unexpected function code in read response: expected 3, got 6", + ): + client.read_holding_registers(40345, 1) + + +def test_client_rejects_too_short_write_response_before_unpacking(): + client = ModbusTCPClient("192.168.1.100") + client._send_and_receive = MagicMock(return_value=b"\x06\x9d") + + with pytest.raises( + RuntimeError, + match="Write response too short: expected at least 5 bytes, got 2", + ): + client.write_register(40345, 1234) + + +def test_client_rejects_mismatched_value_in_write_echo(): + client = ModbusTCPClient("192.168.1.100") + client._send_and_receive = MagicMock( + return_value=struct.pack(">BHH", 0x06, 40345, 4321) + ) + + with pytest.raises( + RuntimeError, + match="Value mismatch in write echo: sent 1234, got 4321", + ): + client.write_register(40345, 1234) diff --git a/tests/batcontrol/inverter/test_fronius_modbus_transport.py b/tests/batcontrol/inverter/test_fronius_modbus_transport.py new file mode 100644 index 00000000..c9a7e5f4 --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_modbus_transport.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from batcontrol.inverter.fronius_modbus.types import ( + FroniusModbusTransport, + RegisterRead, + RegisterWrite, +) + + +class RecordingModbusTransport: + def __init__(self, reads: dict[tuple[int, int], RegisterRead] | None = None): + self.reads = reads or {} + self.read_requests = [] + self.writes = [] + + def read_registers(self, register: int, count: int) -> RegisterRead: + self.read_requests.append((register, count)) + + key = (register, count) + if key not in self.reads: + raise RuntimeError( + f"No configured read for register {register} count {count}" + ) + + return self.reads[key] + + def write_registers(self, writes: list[RegisterWrite]): + self.writes.append(writes) + + +def test_recording_transport_satisfies_modbus_transport_protocol(): + transport: FroniusModbusTransport = RecordingModbusTransport() + + assert isinstance(transport, RecordingModbusTransport) + + +def test_transport_records_single_register_write_batch(): + transport = RecordingModbusTransport() + + transport.write_registers([RegisterWrite(40348, 2)]) + + assert transport.writes == [[RegisterWrite(40348, 2)]] + + +def test_transport_records_multiple_register_writes_in_order(): + transport = RecordingModbusTransport() + + transport.write_registers( + [ + RegisterWrite(40358, 900), + RegisterWrite(40355, 0), + RegisterWrite(40356, 10000), + RegisterWrite(40348, 2), + ] + ) + + assert transport.writes == [ + [ + RegisterWrite(40358, 900), + RegisterWrite(40355, 0), + RegisterWrite(40356, 10000), + RegisterWrite(40348, 2), + ] + ] + + +def test_transport_returns_configured_register_read(): + transport = RecordingModbusTransport( + reads={ + (40345, 1): RegisterRead(start_register=40345, values=[10240]), + } + ) + + result = transport.read_registers(40345, 1) + + assert result == RegisterRead(start_register=40345, values=[10240]) + + +def test_transport_records_read_requests(): + transport = RecordingModbusTransport( + reads={ + (40345, 1): RegisterRead(start_register=40345, values=[10240]), + } + ) + + transport.read_registers(40345, 1) + + assert transport.read_requests == [(40345, 1)] + + +def test_transport_raises_for_unconfigured_read(): + transport = RecordingModbusTransport() + + try: + transport.read_registers(40345, 1) + except RuntimeError as exc: + assert str(exc) == "No configured read for register 40345 count 1" + else: + raise AssertionError("Expected read_registers to raise for unknown reads") diff --git a/tests/batcontrol/inverter/test_fronius_modbus_types.py b/tests/batcontrol/inverter/test_fronius_modbus_types.py new file mode 100644 index 00000000..dcae2f0b --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_modbus_types.py @@ -0,0 +1,29 @@ +from dataclasses import FrozenInstanceError, fields + +import pytest + +from batcontrol.inverter.fronius_modbus.types import RegisterRead, RegisterWrite + + +def test_register_write_is_a_frozen_dataclass_with_expected_fields(): + write = RegisterWrite(40348, 2) + + assert [field.name for field in fields(RegisterWrite)] == [ + "register", + "value", + ] + + with pytest.raises(FrozenInstanceError): + write.register = 40349 + + +def test_register_read_is_a_frozen_dataclass_with_expected_fields(): + read = RegisterRead(start_register=40345, values=[10240]) + + assert [field.name for field in fields(RegisterRead)] == [ + "start_register", + "values", + ] + + with pytest.raises(FrozenInstanceError): + read.start_register = 40346