"""Server routing and caching tests with synthetic device identifiers.""" import threading from unittest.mock import Mock import pytest from bmspy import server from bmspy.classes import BMSScalarField from bmspy.jbd_bms import JBDBMS from bmspy.ecoflow_bt import initialise_device ADDRESS = "02:00:00:00:00:01" SERIAL = "R601TEST00000001" SPEC = f"{ADDRESS}/{SERIAL}/test-user" @pytest.mark.parametrize("spec,name,path", [ (SPEC, SERIAL, SPEC), (f"battery@{SPEC}", "battery", SPEC), ("/tmp/serial-device", "serial-device", "/tmp/serial-device"), ("ports/ttyUSB0", "ttyUSB0", "ports/ttyUSB0"), ("office:ports/ttyUSB0", "office", "ports/ttyUSB0"), ("office:/tmp/serial-device", "office", "/tmp/serial-device"), ("/tmp/serial@device", "serial@device", "/tmp/serial@device"), ]) def test_parse_preserves_serial_paths_and_bluetooth_names(spec, name, path): assert server.parse_device(spec) == (name, path) assert server.is_ecoflow_device(path) is (path == SPEC) def test_parse_bluetooth_spec(): assert server.parse_ecoflow_spec(SPEC) == (ADDRESS, SERIAL, "test-user") assert server.parse_ecoflow_spec("02:00:00:aa:bb:01 / SERIAL / user") == ( "02:00:00:AA:BB:01", "SERIAL", "user", ) @pytest.mark.parametrize("spec", [ ADDRESS, f"{ADDRESS}/{SERIAL}", f"{ADDRESS}//test-user", f"{ADDRESS}/{SERIAL}/", f"{SPEC}/extra", "GG:00:00:00:00:01/SERIAL/test-user", ]) def test_invalid_spec_does_not_echo_account_details(spec): assert server.is_ecoflow_device(spec) with pytest.raises(ValueError) as error: server.parse_ecoflow_spec(spec) assert str(error.value) == "EcoFlow BT device spec must be ADDRESS/SERIAL/USERID" def test_existing_positional_device_state_arguments_remain_valid(): port, data = Mock(), JBDBMS() state = server.DeviceState(port, data, 123.0) assert state.ser is port assert state.data is data assert state.timestamp == 123.0 assert state.ecoflow is None def test_collection_dispatches_to_the_selected_backend(monkeypatch): serial_data, bluetooth_data = JBDBMS(), JBDBMS() for data in (serial_data, bluetooth_data): data.bms_capacity_charge_ratio = BMSScalarField( help="Battery Charge", raw_value=0.5, value="0.5", ) serial_read = Mock(return_value=serial_data) bluetooth_read = Mock(return_value=bluetooth_data) monkeypatch.setattr(server, "collect_data", serial_read) monkeypatch.setattr(server, "ecoflow_collect_data", bluetooth_read) port = Mock() config = initialise_device(ADDRESS, SERIAL, "test-user") assert server.collect_device_data(server.DeviceState(ser=port), 2) is serial_data serial_read.assert_called_once_with(port, 2) assert server.collect_device_data(server.DeviceState(ecoflow=config), 3) is bluetooth_data bluetooth_read.assert_called_once_with(config, 3) assert server.collect_device_data(server.DeviceState()) is None class StopAfter: """Stand-in for the stop event: record each wait and stop after *loops* polls.""" def __init__(self, loops, observe=lambda: None): self.loops, self.observe = loops, observe self.waits, self.seen = [], [] def is_set(self): return len(self.waits) >= self.loops def wait(self, delay): self.waits.append(delay) self.seen.append(self.observe()) @pytest.mark.parametrize("bluetooth,interval", [(False, 5), (True, 60)]) def test_poll_interval_depends_on_backend(monkeypatch, bluetooth, interval): fresh = JBDBMS() config = initialise_device(ADDRESS, SERIAL, "test-user") if bluetooth else None state = server.DeviceState(ser=None if bluetooth else Mock(), ecoflow=config) read = Mock(return_value=fresh) monkeypatch.setattr(server, "collect_device_data", read) monkeypatch.setattr(server.time, "time", lambda: 100) stop = StopAfter(1) server.poll_device(state, stop, 2) assert state.data is fresh assert state.timestamp == 100 assert state.polled.is_set() assert stop.waits == [interval] read.assert_called_once_with(state, 2) def test_offline_bluetooth_does_not_retry_at_once_or_serve_stale_data(monkeypatch): state = server.DeviceState( data=JBDBMS(), timestamp=1, ecoflow=initialise_device(ADDRESS, SERIAL, "test-user"), ) monkeypatch.setattr(server, "collect_device_data", Mock(return_value=None)) monkeypatch.setattr(server.time, "time", lambda: 100) stop = StopAfter(1) server.poll_device(state, stop) assert state.data is None assert state.timestamp == 100 # A client must not wait for a device that is known to be offline. assert state.polled.is_set() assert stop.waits == [60] def test_failed_serial_read_retries_soon_and_hides_a_short_failure(monkeypatch): data = JBDBMS() state = server.DeviceState(ser=Mock()) monkeypatch.setattr(server, "collect_device_data", Mock(side_effect=[data, None, None])) clock = iter([100, 110, 131]) monkeypatch.setattr(server.time, "time", lambda: next(clock)) stop = StopAfter(3, observe=lambda: state.data) server.poll_device(state, stop) # The last snapshot stays for 30 seconds, then the device reads as absent. assert stop.seen == [data, data, None] assert stop.waits == [5, 1, 1] def test_collection_error_does_not_end_polling(monkeypatch, capsys): data = JBDBMS() state = server.DeviceState(ser=Mock()) monkeypatch.setattr(server, "collect_device_data", Mock(side_effect=[RuntimeError("boom"), data])) stop = StopAfter(2) server.poll_device(state, stop) assert state.data is data assert stop.waits == [1, 5] assert "collection error: boom" in capsys.readouterr().out def test_polling_threads_do_not_wait_for_each_other_and_stop(monkeypatch): data = JBDBMS() offline_started, release = threading.Event(), threading.Event() working, offline = server.DeviceState(ser=Mock()), server.DeviceState(ser=Mock()) def read(device, debug=0): if device is offline: offline_started.set() release.wait(5) return None return data monkeypatch.setattr(server, "collect_device_data", read) stop = threading.Event() threads = server.start_polling({"offline": offline, "working": working}, stop) try: assert offline_started.wait(5) assert working.polled.wait(5) assert working.data is data assert not offline.polled.is_set() finally: stop.set() release.set() for thread in threads: thread.join(5) assert not any(thread.is_alive() for thread in threads)