diff --git a/pyproject.toml b/pyproject.toml index ba8dced..fb01253 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,11 +17,11 @@ classifiers = [ ] # Protocol + the device-modelling framework (modbus_connection.model). The caller # injects a ModbusUnit built from whichever backend they choose. -dependencies = ["modbus-connection>=3.6"] +dependencies = ["modbus-connection>=4.2"] [project.optional-dependencies] # Concrete backend for the script/query.py CLI (pip install trovis-modbus[cli]). -cli = ["modbus-connection[pymodbus]>=3.6"] +cli = ["modbus-connection[pymodbus]>=4.2"] [project.urls] Homepage = "https://github.com/Tom-Bom-badil/trovis-modbus" diff --git a/script/query.py b/script/query.py index d90eada..7a9bd2b 100755 --- a/script/query.py +++ b/script/query.py @@ -17,7 +17,12 @@ import sys import time -from modbus_connection import ModbusConnection, ModbusError +from modbus_connection import ( + ModbusConnection, + ModbusError, + ModbusSerialParams, + ModbusTcpParams, +) from modbus_connection.cli_helper import CountingUnit, print_component from trovis_modbus import Trovis557x @@ -82,19 +87,24 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: return parser.parse_args(argv) -async def _open(args: argparse.Namespace) -> ModbusConnection: +def _connection(args: argparse.Namespace) -> ModbusConnection: + """Build the connection described by the arguments. Performs no I/O.""" # Imported here so the module loads (and --help works) without a backend. - from modbus_connection.pymodbus import connect_serial, connect_tcp + from modbus_connection.pymodbus import PymodbusConnection if args.transport == "serial": - return await connect_serial( - args.device, - baudrate=args.baudrate, - parity=args.parity, - stopbits=args.stopbits, - bytesize=args.bytesize, + return PymodbusConnection( + ModbusSerialParams( + device=args.device, + baudrate=args.baudrate, + parity=args.parity, + stopbits=args.stopbits, + bytesize=args.bytesize, + ) ) - return await connect_tcp(args.host, port=args.port, framer=args.framer) + return PymodbusConnection( + ModbusTcpParams(host=args.host, port=args.port, framer=args.framer) + ) def _format_evidence( @@ -148,8 +158,9 @@ def _print(device: Trovis557x) -> None: async def _run(args: argparse.Namespace) -> int: + connection = _connection(args) try: - connection = await _open(args) + await connection.connect() except ModbusError as err: print(f"Could not connect: {err}", file=sys.stderr) return 1 diff --git a/src/trovis_modbus/data_model.py b/src/trovis_modbus/data_model.py index c9675c6..5735f97 100755 --- a/src/trovis_modbus/data_model.py +++ b/src/trovis_modbus/data_model.py @@ -746,13 +746,7 @@ class TrovisComponent(Component): def _ensure_read_layout_is_configurable(self) -> None: """Reject availability changes after the read layout was built.""" - cached_layout = { - "_read_items", - "register_items", - "bit_items", - "_register_blocks", - "_bit_blocks", - } & self.__dict__.keys() + cached_layout = {"_read_items"} & self.__dict__.keys() if self.__dict__.get("_plan") is not None: cached_layout.add("_plan") if cached_layout: @@ -826,14 +820,7 @@ def _register_field_is_readable( if descriptor.scale_register is None: return True - scale_address = ( - descriptor.scale_register - + descriptor.scale_register_stride * (self._index - 1) - + self._base_offset - ) - if getattr(self, "scale_in_block", False): - scale_address += self._instance_offset - return is_span_readable(scale_address, 1, register_ranges) + return is_span_readable(self._scale_address(descriptor), 1, register_ranges) @property def readable_field_names(self) -> frozenset[str]: @@ -846,9 +833,7 @@ def is_field_readable(self, field: str) -> bool: def metadata_for(self, field: str) -> DatapointMetadata | None: """Return neutral TROVIS metadata for a declared field.""" - descriptor = type(self)._register_fields.get(field) - if descriptor is None: - descriptor = type(self)._bit_fields.get(field) + descriptor = type(self).declared_fields.get(field) if descriptor is None: return None return getattr(descriptor, "trovis_metadata", None) diff --git a/tests/conftest.py b/tests/conftest.py index 1befae2..23d91db 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -116,3 +116,13 @@ def trovis(mock_modbus_unit: MockModbusUnit) -> Trovis557x: mock_modbus_unit.holding.update(HOLDING) mock_modbus_unit.coils.update(COILS) return Trovis557x(mock_modbus_unit) + + +@pytest.fixture +def unit(mock_modbus_unit: MockModbusUnit) -> MockModbusUnit: + """The mock unit the ``trovis`` fixture reads and writes through. + + Request it alongside ``trovis`` to assert on the register and coil stores a + write landed in, rather than reaching for the unit a component holds. + """ + return mock_modbus_unit diff --git a/tests/test_address_ranges_availability.py b/tests/test_address_ranges_availability.py index d89348d..ccaa837 100755 --- a/tests/test_address_ranges_availability.py +++ b/tests/test_address_ranges_availability.py @@ -33,10 +33,7 @@ def test_ranges_must_be_configured_before_read_layout( from trovis_modbus import Functions functions = Functions(mock_modbus_unit) - if hasattr(type(functions), "register_items"): - _ = functions.register_items - else: - _ = functions._read_items + _ = functions._read_items # build and cache the read layout register_ranges, coil_ranges = ranges_for_model(5578) try: diff --git a/tests/test_canonical_parity.py b/tests/test_canonical_parity.py index 04ac268..f7d5c08 100755 --- a/tests/test_canonical_parity.py +++ b/tests/test_canonical_parity.py @@ -115,7 +115,7 @@ def _fields() -> list[tuple[str, int, RegisterField | CoilField]]: label = type(component).__name__ + ( f"[{component._index}]" if component._index != 1 else "" ) - for field in {**component._register_fields, **component._bit_fields}.values(): + for field in component.declared_fields.values(): out.append((label, component._address(field), field)) return out diff --git a/tests/test_device.py b/tests/test_device.py index a69fff0..fb4c1b9 100755 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -5,6 +5,7 @@ from datetime import date, time import pytest +from modbus_connection import ClientClosedError, GatewayTargetError from modbus_connection.mock import MockModbusConnection, MockModbusUnit from trovis_modbus import ( @@ -14,41 +15,17 @@ SystemActivity, TemperatureRange, Trovis557x, + TrovisWriteAccessError, Weekday, ) -from trovis_modbus.configurations.address_ranges import REGISTER_RANGES +from trovis_modbus.configurations.address_ranges import ( + COIL_RANGES, + REGISTER_RANGES, +) from .conftest import COILS, HOLDING -class _CountingUnit: - """Wraps a ModbusUnit and records read calls; delegates everything else.""" - - def __init__(self, inner: MockModbusUnit) -> None: - self._inner = inner - self.register_blocks: list[tuple[int, int]] = [] - self.coil_blocks: list[tuple[int, int]] = [] - - @property - def register_reads(self) -> int: - return len(self.register_blocks) - - @property - def coil_reads(self) -> int: - return len(self.coil_blocks) - - async def read_holding_registers(self, address: int, count: int) -> list[int]: - self.register_blocks.append((address, count)) - return await self._inner.read_holding_registers(address, count) - - async def read_coils(self, address: int, count: int) -> list[bool]: - self.coil_blocks.append((address, count)) - return await self._inner.read_coils(address, count) - - def __getattr__(self, name: str) -> object: - return getattr(self._inner, name) - - async def test_device_info(trovis: Trovis557x) -> None: await trovis.async_update() info = trovis.info @@ -158,58 +135,55 @@ async def test_independent_component_update(trovis: Trovis557x) -> None: assert trovis.rk1.flow_setpoint is None # not updated yet -async def test_full_update_consolidates_reads() -> None: +async def test_full_update_consolidates_reads( + trovis: Trovis557x, unit: MockModbusUnit +) -> None: """A full device update pools all sub-systems into a few block reads.""" - inner = MockModbusConnection().for_unit(1) - inner.holding.update(HOLDING) - inner.coils.update(COILS) - unit = _CountingUnit(inner) - device = Trovis557x(unit) # type: ignore[arg-type] - - field_count = sum( - len(c._register_fields) + len(c._bit_fields) for c in device.components - ) - await device.async_update() + field_count = sum(len(c.readable_field_names) for c in trovis.components) + await trovis.async_update() # Many fields collapse into a small number of range-aware block reads — far # fewer than the field count, and well under a naive per-field strategy. - total_reads = unit.register_reads + unit.coil_reads - assert total_reads < field_count // 4 + assert len(unit.read_events) < field_count // 4 # The exact number of blocks may change when manufacturer ranges or the # planner improve. The stable safety guarantee is the configured max_span. - assert all(count <= 50 for _, count in unit.register_blocks) - assert all(count <= 50 for _, count in unit.coil_blocks) + assert all(event.count <= 50 for event in unit.read_events) -async def test_full_update_never_reads_across_an_unreadable_gap() -> None: +async def test_full_update_never_reads_across_an_unreadable_gap( + trovis: Trovis557x, unit: MockModbusUnit +) -> None: """Every block stays inside the controller's readable ranges (no NAK risk).""" - inner = MockModbusConnection().for_unit(1) - unit = _CountingUnit(inner) - device = Trovis557x(unit) # type: ignore[arg-type] - await device.async_update() - - def readable(address: int) -> bool: - return any(low <= address <= high for low, high in REGISTER_RANGES) + await trovis.async_update() - for start, count in unit.register_blocks: - assert all(readable(start + i) for i in range(count)), ( - f"block {start}..{start + count - 1} crosses an unreadable gap" + ranges = {"holding": REGISTER_RANGES, "coil": COIL_RANGES} + for event in unit.read_events: + declared = ranges[event.register_type] + covered = range(event.address, event.address + event.count) + assert all( + any(low <= address <= high for low, high in declared) for address in covered + ), ( + f"{event.register_type} block {covered.start}..{covered.stop - 1} " + "crosses an unreadable gap" ) # Addresses 7-8 (between ranges [0,6] and [9,40]) are never read — the low # registers split there instead of being merged into one 0..26 block. - read = {start + i for start, count in unit.register_blocks for i in range(count)} + read = { + event.address + i + for event in unit.read_events + if event.register_type == "holding" + for i in range(event.count) + } assert 7 not in read and 8 not in read -async def test_consolidated_reads_decode_correctly() -> None: +async def test_consolidated_reads_decode_correctly(unit: MockModbusUnit) -> None: """Adjacent physical sensor registers decode to the right sensor inputs.""" - inner = MockModbusConnection().for_unit(1) # Flow sensors VF1/VF2/VF3 are at adjacent addresses 12/13/14 — fetched in # one block, then decoded on the central Sensors component. - inner.holding.update({12: 300, 13: 310, 14: 320}) - unit = _CountingUnit(inner) - device = Trovis557x(unit) # type: ignore[arg-type] + unit.holding.update({12: 300, 13: 310, 14: 320}) + device = Trovis557x(unit) await device.async_update() @@ -218,6 +192,57 @@ async def test_consolidated_reads_decode_correctly() -> None: assert device.sensors.vf3 == pytest.approx(32.0) +async def test_update_survives_a_dropped_connection() -> None: + """A dropped link heals on the next update — the device is not rebuilt.""" + connection = MockModbusConnection() + unit = connection.for_unit(1) + unit.holding.update(HOLDING) + unit.coils.update(COILS) + device = Trovis557x(unit) + await device.async_update() + assert device.rk1.flow_setpoint == pytest.approx(55.0) + + lost: list[int] = [] + connection.on_connection_lost(lambda: lost.append(1)) + connection.simulate_connection_lost() + assert lost == [1] + + # The same device and unit handles keep working; the request reconnects. + await device.async_update() + assert device.rk1.flow_setpoint == pytest.approx(55.0) + + +async def test_update_survives_a_recycled_connection() -> None: + """disconnect() recycles a stuck link; the device keeps its handles.""" + connection = MockModbusConnection() + unit = connection.for_unit(1) + unit.holding.update(HOLDING) + unit.coils.update(COILS) + device = Trovis557x(unit) + await device.async_update() + + lost: list[int] = [] + connection.on_connection_lost(lambda: lost.append(1)) + await connection.disconnect() + + await device.async_update() + assert device.rk1.flow_setpoint == pytest.approx(55.0) + # The owner tore the link down; nothing was lost. + assert lost == [] + + +async def test_update_after_close_raises() -> None: + """Closing is the owner's permanent end of the connection, not a drop.""" + connection = MockModbusConnection() + unit = connection.for_unit(1) + unit.holding.update(HOLDING) + device = Trovis557x(unit) + await connection.close() + + with pytest.raises(ClientClosedError): + await device.async_update() + + async def test_update_listener(trovis: Trovis557x) -> None: calls: list[int] = [] unsubscribe = trovis.rk4.add_update_listener(lambda: calls.append(1)) @@ -245,10 +270,10 @@ async def test_write_rejects_readonly(trovis: Trovis557x) -> None: await trovis.rk1.write("flow_setpoint", 50.0) -async def test_mode_write_releases_override_coil(trovis: Trovis557x) -> None: +async def test_mode_write_releases_override_coil( + trovis: Trovis557x, unit: MockModbusUnit +) -> None: """Setting the mode first releases the Ebene coil (0 = remote control).""" - unit = trovis.rk1._unit - await trovis.async_enable_writing() await unit.write_coil(88, True) # start "autonomous" (controller-controlled) await trovis.rk1.set_mode(OperatingMode.DAY) @@ -258,18 +283,19 @@ async def test_mode_write_releases_override_coil(trovis: Trovis557x) -> None: assert (await unit.read_holding_registers(105, 1))[0] == int(OperatingMode.DAY) -async def test_circuit2_mode_uses_strided_override(trovis: Trovis557x) -> None: +async def test_circuit2_mode_uses_strided_override( + trovis: Trovis557x, unit: MockModbusUnit +) -> None: """Circuit 2's override coil follows the +2 stride (90, not 88).""" - unit = trovis.rk2._unit await unit.write_coil(90, True) await trovis.rk2.set_mode(OperatingMode.NIGHT) assert (await unit.read_coils(90, 1))[0] is False assert (await unit.read_holding_registers(107, 1))[0] == int(OperatingMode.NIGHT) -async def test_write_access_enable_disable(trovis: Trovis557x) -> None: - unit = trovis.controller._unit - +async def test_write_access_enable_disable( + trovis: Trovis557x, unit: MockModbusUnit +) -> None: assert await trovis.async_read_writing_enabled() is False await trovis.async_enable_writing() @@ -281,10 +307,10 @@ async def test_write_access_enable_disable(trovis: Trovis557x) -> None: assert (await unit.read_holding_registers(144, 1))[0] == 0 -async def test_write_refreshes_access_code(trovis: Trovis557x) -> None: +async def test_write_refreshes_access_code( + trovis: Trovis557x, unit: MockModbusUnit +) -> None: """The public write path refreshes HR40145 before writing the datapoint.""" - unit = trovis.controller._unit - assert await trovis.async_read_writing_enabled() is False await trovis.rk1.set_room_setpoint_day(21.5) @@ -293,6 +319,27 @@ async def test_write_refreshes_access_code(trovis: Trovis557x) -> None: assert (await unit.read_holding_registers(1002, 1))[0] == 215 +async def test_write_access_reports_a_device_that_answers_nothing( + trovis: Trovis557x, unit: MockModbusUnit +) -> None: + """Every HR40145 path reports a refusal as a TROVIS write-access failure.""" + # A gateway that answers for itself while the controller behind it does not + # — the usual failure of a TROVIS on an RTU-over-TCP bridge. + unit.fail_requests(GatewayTargetError()) + + with pytest.raises(TrovisWriteAccessError): + await trovis.async_read_writing_enabled() + with pytest.raises(TrovisWriteAccessError): + await trovis.async_enable_writing() + with pytest.raises(TrovisWriteAccessError): + await trovis.async_disable_writing() + # The public datapoint write refreshes the access code first, so it fails + # there rather than reaching the register it was asked to write. + with pytest.raises(TrovisWriteAccessError): + await trovis.rk1.set_room_setpoint_day(21.5) + assert trovis.writing_enabled is False + + async def test_5576_anlage_2_1_exposes_rk1_and_rk4( mock_modbus_unit: MockModbusUnit, ) -> None: diff --git a/tests/test_metadata_ranges.py b/tests/test_metadata_ranges.py index 7a6c457..b2ea0a8 100755 --- a/tests/test_metadata_ranges.py +++ b/tests/test_metadata_ranges.py @@ -57,7 +57,7 @@ def test_corrected_domestic_hot_water_setpoint_limits(trovis: Trovis557x) -> Non def test_outdoor_temperature_delay_uses_hardware_verified_scale( trovis: Trovis557x, ) -> None: - field = trovis.controller._register_fields["outdoor_temperature_delay"] + field = trovis.controller.declared_fields["outdoor_temperature_delay"] assert field.decode([30]) == pytest.approx(3.0) assert field.encode(3) == [30] @@ -65,7 +65,7 @@ def test_outdoor_temperature_delay_uses_hardware_verified_scale( def test_all_writable_numbers_have_complete_ranges(trovis: Trovis557x) -> None: for component in trovis.components: - for field in component._register_fields: + for field in component.declared_fields: metadata = component.require_metadata_for(field) if metadata.value_kind != "number" or not metadata.writable: continue @@ -88,7 +88,7 @@ def test_all_writable_temporal_values_have_complete_ranges( } for component in trovis.components: - for field in component._register_fields: + for field in component.declared_fields: metadata = component.require_metadata_for(field) expected_type = expected_types.get(metadata.value_kind) if expected_type is None or not metadata.writable: diff --git a/tests/test_operating_mode_write.py b/tests/test_operating_mode_write.py index 1b0c397..c75bf0e 100755 --- a/tests/test_operating_mode_write.py +++ b/tests/test_operating_mode_write.py @@ -2,6 +2,8 @@ from __future__ import annotations +from modbus_connection.mock import MockModbusUnit + from trovis_modbus import OperatingMode, Trovis557x from trovis_modbus.enums import OPERATING_MODE_OPTIONS @@ -18,10 +20,9 @@ def test_circuits_share_operating_mode_metadata(trovis: Trovis557x) -> None: async def test_heating_automatic_restores_autark_without_register_write( - trovis: Trovis557x, + trovis: Trovis557x, unit: MockModbusUnit ) -> None: """Automatic mode is selected through the Ebene coil, not the mode HR.""" - unit = trovis.rk1._unit await unit.write_register(105, int(OperatingMode.DAY)) await unit.write_coil(88, False) @@ -32,10 +33,9 @@ async def test_heating_automatic_restores_autark_without_register_write( async def test_domestic_hot_water_automatic_uses_its_own_ebene_coil( - trovis: Trovis557x, + trovis: Trovis557x, unit: MockModbusUnit ) -> None: """Domestic hot water restores AUTARK through CL95 and leaves HR40112 intact.""" - unit = trovis.rk4._unit await unit.write_register(111, int(OperatingMode.NIGHT)) await unit.write_coil(94, False) diff --git a/tests/test_operational_datapoints.py b/tests/test_operational_datapoints.py index 8ed8ea0..d0dd4f1 100755 --- a/tests/test_operational_datapoints.py +++ b/tests/test_operational_datapoints.py @@ -25,25 +25,25 @@ def test_heating_circuit_stride_patterns() -> None: rk2 = device.rk2 rk3 = device.rk3 - assert rk1._address(rk1._bit_fields["valve_closing"]) == 61 - assert rk2._address(rk2._bit_fields["valve_closing"]) == 63 - assert rk3._address(rk3._bit_fields["valve_closing"]) == 65 + assert rk1._address(rk1.declared_fields["valve_closing"]) == 61 + assert rk2._address(rk2.declared_fields["valve_closing"]) == 63 + assert rk3._address(rk3.declared_fields["valve_closing"]) == 65 - assert rk1._address(rk1._register_fields["fixed_setpoint_day"]) == 1041 - assert rk2._address(rk2._register_fields["fixed_setpoint_day"]) == 1241 - assert rk3._address(rk3._register_fields["fixed_setpoint_day"]) == 1441 + assert rk1._address(rk1.declared_fields["fixed_setpoint_day"]) == 1041 + assert rk2._address(rk2.declared_fields["fixed_setpoint_day"]) == 1241 + assert rk3._address(rk3.declared_fields["fixed_setpoint_day"]) == 1441 - assert rk1._address(rk1._bit_fields["room_setpoint_control_autonomous"]) == 121 - assert rk2._address(rk2._bit_fields["room_setpoint_control_autonomous"]) == 122 - assert rk3._address(rk3._bit_fields["room_setpoint_control_autonomous"]) == 123 + assert rk1._address(rk1.declared_fields["room_setpoint_control_autonomous"]) == 121 + assert rk2._address(rk2.declared_fields["room_setpoint_control_autonomous"]) == 122 + assert rk3._address(rk3.declared_fields["room_setpoint_control_autonomous"]) == 123 def test_domestic_hot_water_special_setpoint_is_distinct_from_active_setpoint() -> None: device = Trovis557x(unit=None) # type: ignore[arg-type] rk4 = device.rk4 - assert rk4._address(rk4._register_fields["setpoint_active"]) == 1807 - assert rk4._address(rk4._register_fields["special_setpoint"]) == 1808 + assert rk4._address(rk4.declared_fields["setpoint_active"]) == 1807 + assert rk4._address(rk4.declared_fields["special_setpoint"]) == 1808 assert rk4.ebene_coils["special_setpoint"] == (112, 0) @@ -71,11 +71,11 @@ def test_legacy_gap_registers_and_intermediate_heating_points() -> None: controller = device.controller rk4 = device.rk4 - assert controller._address(controller._register_fields["special_functions"]) == 4 + assert controller._address(controller.declared_fields["special_functions"]) == 4 overrun = rk4.require_metadata_for("storage_tank_charging_pump_lag_factor") assert ( - rk4._address(rk4._register_fields["storage_tank_charging_pump_lag_factor"]) + rk4._address(rk4.declared_fields["storage_tank_charging_pump_lag_factor"]) == 1804 ) assert overrun.writable is True @@ -84,28 +84,31 @@ def test_legacy_gap_registers_and_intermediate_heating_points() -> None: assert overrun.number.max_value == pytest.approx(10.0) assert overrun.number.step == pytest.approx(0.1) - assert rk4._address(rk4._bit_fields["intermediate_heating_function_enabled"]) == 406 - assert rk4._address(rk4._bit_fields["intermediate_heating_operation"]) == 1830 + assert ( + rk4._address(rk4.declared_fields["intermediate_heating_function_enabled"]) + == 406 + ) + assert rk4._address(rk4.declared_fields["intermediate_heating_operation"]) == 1830 def test_additional_5578_sensor_addresses() -> None: sensors = Sensors(unit=None) # type: ignore[arg-type] - assert sensors._address(sensors._register_fields["af2"]) == 10 - assert sensors._address(sensors._register_fields["sf3"]) == 24 - assert sensors._address(sensors._register_fields["ae1"]) == 25 - assert sensors._address(sensors._register_fields["fg1"]) == 25 - assert sensors._address(sensors._register_fields["ae2"]) == 26 - assert sensors._address(sensors._register_fields["fg2"]) == 26 - assert sensors._address(sensors._register_fields["ae3"]) == 27 - assert sensors._address(sensors._register_fields["fg3"]) == 27 - assert sensors._address(sensors._register_fields["pulse_rate"]) == 28 - assert sensors._address(sensors._register_fields["analog_input_voltage"]) == 41 - assert sensors._address(sensors._register_fields["analog_input_current"]) == 41 - - assert "ae1_fg1" not in sensors._register_fields - assert "ae2_fg2" not in sensors._register_fields - assert "ae3_fg3" not in sensors._register_fields + assert sensors._address(sensors.declared_fields["af2"]) == 10 + assert sensors._address(sensors.declared_fields["sf3"]) == 24 + assert sensors._address(sensors.declared_fields["ae1"]) == 25 + assert sensors._address(sensors.declared_fields["fg1"]) == 25 + assert sensors._address(sensors.declared_fields["ae2"]) == 26 + assert sensors._address(sensors.declared_fields["fg2"]) == 26 + assert sensors._address(sensors.declared_fields["ae3"]) == 27 + assert sensors._address(sensors.declared_fields["fg3"]) == 27 + assert sensors._address(sensors.declared_fields["pulse_rate"]) == 28 + assert sensors._address(sensors.declared_fields["analog_input_voltage"]) == 41 + assert sensors._address(sensors.declared_fields["analog_input_current"]) == 41 + + assert "ae1_fg1" not in sensors.declared_fields + assert "ae2_fg2" not in sensors.declared_fields + assert "ae3_fg3" not in sensors.declared_fields for field in ("ae1", "fg1", "ae2", "fg2", "ae3", "fg3"): metadata = sensors.require_metadata_for(field) @@ -144,11 +147,11 @@ def test_controller_monitoring_metadata_and_timeout() -> None: timeout = controller.require_metadata_for("glt_timeout_active") assert timeout.writable is True - assert controller._address(controller._bit_fields["glt_timeout_active"]) == 158 + assert controller._address(controller.declared_fields["glt_timeout_active"]) == 158 assert ( controller._address( - controller._register_fields["summer_outdoor_temperature_average"] + controller.declared_fields["summer_outdoor_temperature_average"] ) == 42 ) @@ -158,15 +161,14 @@ def test_solar_circuit_uses_dedicated_register_and_coil_block() -> None: solar = SolarCircuit(unit=None) # type: ignore[arg-type] assert ( - solar._address(solar._register_fields["pump_on_temperature_difference"]) == 1809 + solar._address(solar.declared_fields["pump_on_temperature_difference"]) == 1809 ) assert ( - solar._address(solar._register_fields["pump_off_temperature_difference"]) - == 1810 + solar._address(solar.declared_fields["pump_off_temperature_difference"]) == 1810 ) - assert solar._address(solar._register_fields["maximum_storage_temperature"]) == 1811 - assert solar._address(solar._register_fields["operating_hours"]) == 1812 - assert solar._address(solar._bit_fields["pump_running"]) == 1807 + assert solar._address(solar.declared_fields["maximum_storage_temperature"]) == 1811 + assert solar._address(solar.declared_fields["operating_hours"]) == 1812 + assert solar._address(solar.declared_fields["pump_running"]) == 1807 pump_on = solar.require_metadata_for("pump_on_temperature_difference") assert pump_on.writable is True @@ -192,10 +194,10 @@ def test_solar_circuit_uses_dedicated_register_and_coil_block() -> None: def test_solar_datapoints_are_no_longer_owned_by_rk4() -> None: device = Trovis557x(unit=None) # type: ignore[arg-type] - assert "solar_operating_hours" not in device.rk4._register_fields - assert "solar_circuit_pump_running" not in device.rk4._bit_fields - assert "operating_hours" in device.solar._register_fields - assert "pump_running" in device.solar._bit_fields + assert "solar_operating_hours" not in device.rk4.declared_fields + assert "solar_circuit_pump_running" not in device.rk4.declared_fields + assert "operating_hours" in device.solar.declared_fields + assert "pump_running" in device.solar.declared_fields def test_buffer_tank_status_enum_matches_firmware_values() -> None: @@ -206,22 +208,22 @@ def test_buffer_tank_circuit_uses_rk1_extension_registers() -> None: buffer_tank = BufferTankCircuit(unit=None) # type: ignore[arg-type] assert ( - buffer_tank._address(buffer_tank._register_fields["minimum_charging_setpoint"]) + buffer_tank._address(buffer_tank.declared_fields["minimum_charging_setpoint"]) == 1099 ) assert ( - buffer_tank._address(buffer_tank._register_fields["charging_end_temperature"]) + buffer_tank._address(buffer_tank.declared_fields["charging_end_temperature"]) == 1100 ) assert ( - buffer_tank._address(buffer_tank._register_fields["charging_temperature_boost"]) + buffer_tank._address(buffer_tank.declared_fields["charging_temperature_boost"]) == 1101 ) assert ( - buffer_tank._address(buffer_tank._register_fields["charging_pump_lag_factor"]) + buffer_tank._address(buffer_tank.declared_fields["charging_pump_lag_factor"]) == 1102 ) - assert buffer_tank._address(buffer_tank._register_fields["status"]) == 1103 + assert buffer_tank._address(buffer_tank.declared_fields["status"]) == 1103 minimum = buffer_tank.require_metadata_for("minimum_charging_setpoint") assert minimum.writable is True @@ -254,6 +256,6 @@ def test_buffer_tank_circuit_uses_rk1_extension_registers() -> None: def test_buffer_tank_extension_does_not_duplicate_rk1_fields() -> None: device = Trovis557x(unit=None) # type: ignore[arg-type] - assert set(device.buffer_tank._register_fields).isdisjoint( - device.rk1._register_fields + assert set(device.buffer_tank.declared_fields).isdisjoint( + device.rk1.declared_fields ) diff --git a/tests/test_temporal_values.py b/tests/test_temporal_values.py index 52377a9..c08be4e 100755 --- a/tests/test_temporal_values.py +++ b/tests/test_temporal_values.py @@ -5,6 +5,7 @@ from datetime import date, datetime, time import pytest +from modbus_connection.mock import MockModbusUnit, WriteEvent from trovis_modbus import MonthDay, Trovis557x, TrovisValueValidationError @@ -50,25 +51,23 @@ def test_temporal_metadata(trovis: Trovis557x) -> None: def test_temporal_codecs(trovis: Trovis557x) -> None: - time_field = trovis.clock._register_fields["time"] + time_field = trovis.clock.declared_fields["time"] assert time_field.decode([1430]) == time(14, 30) assert time_field.decode([2360]) is None assert time_field.encode(time(8, 5)) == [805] - date_field = trovis.clock._register_fields["date"] + date_field = trovis.clock.declared_fields["date"] assert date_field.decode([2106, 2026]) == date(2026, 6, 21) assert date_field.decode([3102, 2025]) is None assert date_field.encode(date(2027, 1, 2)) == [201, 2027] - month_day_field = trovis.controller._register_fields["summer_start"] + month_day_field = trovis.controller.declared_fields["summer_start"] assert month_day_field.decode([1505]) == MonthDay(15, 5) assert month_day_field.decode([3102]) is None assert month_day_field.encode(MonthDay(29, 2)) == [2902] -async def test_clock_native_writes(trovis: Trovis557x) -> None: - unit = trovis.clock._unit - +async def test_clock_native_writes(trovis: Trovis557x, unit: MockModbusUnit) -> None: await trovis.clock.set_time(time(8, 15)) assert (await unit.read_holding_registers(99, 1))[0] == 815 @@ -81,6 +80,27 @@ async def test_clock_native_writes(trovis: Trovis557x) -> None: assert trovis.clock.date == date(2027, 1, 2) +async def test_clock_date_write_sends_the_year_first_as_two_single_writes( + trovis: Trovis557x, unit: MockModbusUnit +) -> None: + """The 55Pro sequence: HR40102 then HR40101, never one FC16 across both. + + Asserting the resulting register values cannot see the difference — a single + FC16 leaves the same two words behind — so this pins the function codes and + their order instead. + """ + writes: list[WriteEvent] = [] + unit.on_write(writes.append) + + await trovis.clock.set_date(date(2027, 1, 2)) + + assert [ + (event.address, event.values, event.function_code) + for event in writes + if event.address in (100, 101) + ] == [(101, [2027], 0x06), (100, [201], 0x06)] + + async def test_datetime_write(trovis: Trovis557x) -> None: await trovis.clock.set_datetime(datetime(2028, 12, 31, 23, 45)) await trovis.clock.async_update() @@ -88,10 +108,8 @@ async def test_datetime_write(trovis: Trovis557x) -> None: async def test_recurring_date_and_disinfection_writes( - trovis: Trovis557x, + trovis: Trovis557x, unit: MockModbusUnit ) -> None: - unit = trovis.controller._unit - await trovis.controller.set_summer_start(MonthDay(1, 4)) assert (await unit.read_holding_registers(112, 1))[0] == 104 @@ -109,7 +127,7 @@ def test_time_rejects_subminute_precision( trovis: Trovis557x, value: time, ) -> None: - field = trovis.clock._register_fields["time"] + field = trovis.clock.declared_fields["time"] with pytest.raises(TrovisValueValidationError): field.encode(value) @@ -117,13 +135,13 @@ def test_time_rejects_subminute_precision( def test_disinfection_time_rejects_out_of_range_value( trovis: Trovis557x, ) -> None: - field = trovis.rk4._register_fields["disinfection_start"] + field = trovis.rk4.declared_fields["disinfection_start"] assert field.decode([2350]) is None with pytest.raises(TrovisValueValidationError): field.encode(time(23, 50)) def test_date_rejects_unsupported_year(trovis: Trovis557x) -> None: - field = trovis.clock._register_fields["date"] + field = trovis.clock.declared_fields["date"] with pytest.raises(TrovisValueValidationError): field.encode(date(2099, 1, 1))