diff --git a/README.md b/README.md index 8db0380..700f0ed 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,16 @@ device named `all`, not all devices. To include a serial BMS, add a server argum such as `-d network:/dev/ttyUSB0`. The server reads each device in its own background thread, so a slow or offline -device does not delay the other devices or the clients. It reads one Bluetooth -device at a time and waits 60 seconds after each Bluetooth read, including a -failed one. It reads a serial BMS every 5 seconds and retries a failed serial -read after 1 second. Unavailable devices are omitted from the response. Only the -first request after startup waits, until each device completes one read attempt. +device does not delay the other devices or the clients. It reads a serial BMS +every 5 seconds and retries a failed serial read after 1 second. Unavailable +devices are omitted from the response. Only the first request after startup +waits, until each device completes one read attempt. + +The server reads one Bluetooth device at a time and waits 60 seconds after each +good Bluetooth read. To change that wait, add `--bluetooth-interval SECONDS` to +the server command. During the wait, clients receive the last read. After a +failed Bluetooth read, the server tries again after 60 seconds. An interval +below 60 seconds also applies after a failed read. The server answers each client in its own thread, so a slow client does not delay the other clients. It closes a connection that stays silent for 5 seconds. diff --git a/bmspy/server.py b/bmspy/server.py index a91102d..ff25d8b 100755 --- a/bmspy/server.py +++ b/bmspy/server.py @@ -29,20 +29,11 @@ from bmspy.ecoflow_bt import ( ) -@dataclass -class DeviceState: - ser: serial.Serial | None = None # JBD BMS serial port - data: UPS | None = None - timestamp: float = 0.0 # Last collection attempt - ecoflow: EcoFlowBTConfig | None = None # EcoFlow BT config - # Set after the first collection attempt, successful or not. - polled: threading.Event = field(default_factory=threading.Event, repr=False, compare=False) - - SERIAL_POLL_SECONDS = 5 SERIAL_RETRY_SECONDS = 1 SERIAL_STALE_SECONDS = 30 BLUETOOTH_POLL_SECONDS = 60 +BLUETOOTH_RETRY_SECONDS = 60 FIRST_POLL_TIMEOUT = 120 # Time limit at exit for the reads in progress to end and to disconnect. POLL_STOP_TIMEOUT = 20 @@ -51,6 +42,19 @@ CLIENT_TIMEOUT_SECONDS = 5 # A request is a short JSON object. A larger length prefix is an error. MAX_REQUEST_BYTES = 64 * 1024 + +@dataclass +class DeviceState: + ser: serial.Serial | None = None # JBD BMS serial port + data: UPS | None = None + timestamp: float = 0.0 # Last collection attempt + ecoflow: EcoFlowBTConfig | None = None # EcoFlow BT config + # Wait after a good Bluetooth read. + poll_seconds: float = BLUETOOTH_POLL_SECONDS + # Set after the first collection attempt, successful or not. + polled: threading.Event = field(default_factory=threading.Event, repr=False, compare=False) + + # One adapter: collect from one Bluetooth device at a time. bluetooth_lock = threading.Lock() @@ -225,11 +229,12 @@ def poll_device(device: DeviceState, stop: threading.Event, debug: int = 0) -> N if data is not None: device.data = data last_success = device.timestamp - delay = BLUETOOTH_POLL_SECONDS if device.ecoflow is not None else SERIAL_POLL_SECONDS + delay = device.poll_seconds if device.ecoflow is not None else SERIAL_POLL_SECONDS elif device.ecoflow is not None: # BLE already retries within collect_data. Do not serve stale data. + # A long interval must not also delay the next attempt. device.data = None - delay = BLUETOOTH_POLL_SECONDS + delay = min(device.poll_seconds, BLUETOOTH_RETRY_SECONDS) else: # Serial reads fail briefly at times. Retry soon, and keep the last # snapshot for a short time so that one failed read stays invisible. @@ -413,6 +418,17 @@ def main(): default="dialout", help="Run daemon as group", ) + parser.add_argument( + "--bluetooth-interval", + dest="bluetooth_interval", + type=float, + default=BLUETOOTH_POLL_SECONDS, + metavar="SECONDS", + help=( + "Wait between the reads of a Bluetooth device (default: %(default)s). " + "Clients receive the last read during the wait" + ), + ) parser.add_argument( "--verbose", "-v", @@ -421,6 +437,8 @@ def main(): help="Print more verbose information (can be specified multiple times)", ) args = parser.parse_args() + if args.bluetooth_interval <= 0: + parser.error("--bluetooth-interval must be above zero") debug = args.verbose @@ -438,7 +456,8 @@ def main(): debugger("server: {}".format(e)) continue ups_devices[name] = DeviceState( - ecoflow=ecoflow_initialise_device(address, sn, user_id) + ecoflow=ecoflow_initialise_device(address, sn, user_id), + poll_seconds=args.bluetooth_interval, ) if debug > 0: print("server: registered EcoFlow BT '{}' ({})".format(name, address)) diff --git a/tests/test_server_ecoflow.py b/tests/test_server_ecoflow.py index 2b43a1e..6856c13 100644 --- a/tests/test_server_ecoflow.py +++ b/tests/test_server_ecoflow.py @@ -123,6 +123,60 @@ def test_offline_bluetooth_does_not_retry_at_once_or_serve_stale_data(monkeypatc assert stop.waits == [60] +@pytest.mark.parametrize("interval,read,wait", [ + (300, JBDBMS(), 300), + # A failed read retries after 60 seconds, or sooner with a shorter interval. + (300, None, 60), + (30, None, 30), +]) +def test_bluetooth_interval_sets_the_wait_after_a_good_read(monkeypatch, interval, read, wait): + state = server.DeviceState( + ecoflow=initialise_device(ADDRESS, SERIAL, "test-user"), poll_seconds=interval, + ) + monkeypatch.setattr(server, "collect_device_data", Mock(return_value=read)) + stop = StopAfter(1) + server.poll_device(state, stop) + assert state.data is read + assert stop.waits == [wait] + + +def _main_devices(monkeypatch, tmp_path, *arguments): + """Run main() until it starts to poll, and return the devices it registered.""" + start = Mock(side_effect=KeyboardInterrupt) + monkeypatch.setattr(server, "start_polling", start) + monkeypatch.setattr(server.signal, "signal", Mock()) + monkeypatch.setattr(server.atexit, "register", Mock()) + monkeypatch.setattr(server, "initialise_serial", Mock()) + monkeypatch.setattr(server.os.path, "isdir", lambda path: True) + monkeypatch.setattr("sys.argv", [ + "bmspy-server", "--socket", str(tmp_path / "interval.sock"), + "--device", f"river@{SPEC}", "--device", "pack:/dev/ttyUSB0", *arguments, + ]) + with pytest.raises((SystemExit, KeyboardInterrupt)): + server.main() + return start.call_args.args[0] + + +@pytest.mark.parametrize("arguments,interval", [((), 60), (("--bluetooth-interval", "300"), 300)]) +def test_bluetooth_interval_option_applies_to_bluetooth_devices( + monkeypatch, tmp_path, arguments, interval, +): + devices = _main_devices(monkeypatch, tmp_path, *arguments) + assert devices["river"].poll_seconds == interval + assert devices["river"].ecoflow is not None + assert devices["pack"].ecoflow is None + + +@pytest.mark.parametrize("value", ["0", "-5", "soon"]) +def test_bluetooth_interval_option_rejects_an_unusable_value(monkeypatch, capsys, value): + monkeypatch.setattr(server.signal, "signal", Mock()) + monkeypatch.setattr("sys.argv", ["bmspy-server", "--bluetooth-interval", value]) + with pytest.raises(SystemExit) as exit_info: + server.main() + assert exit_info.value.code == 2 + assert "--bluetooth-interval" in capsys.readouterr().err + + def test_failed_serial_read_retries_soon_and_hides_a_short_failure(monkeypatch): data = JBDBMS() state = server.DeviceState(ser=Mock())