diff --git a/bmspy/server.py b/bmspy/server.py index d78d199..48e1b95 100755 --- a/bmspy/server.py +++ b/bmspy/server.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -# Daemon: listens on a Unix socket and serves JBD BMS data to clients +# Daemon: listens on a Unix socket and serves BMS data to clients. +# Supports JBD BMS (serial) and EcoFlow (Bluetooth LE) devices. # import os import socket @@ -10,6 +11,7 @@ import time import atexit import signal import json +import re import struct import serial from dataclasses import asdict as dataclass_asdict, dataclass @@ -18,13 +20,19 @@ from typing import Any, NoReturn from bmspy.utilities import debugger from bmspy.classes import UPS from bmspy.jbd_bms import collect_data, initialise_serial +from bmspy.ecoflow_bt import ( + EcoFlowBTConfig, + collect_data as ecoflow_collect_data, + initialise_device as ecoflow_initialise_device, +) @dataclass class DeviceState: - ser: serial.Serial + ser: serial.Serial | None = None # JBD BMS serial port data: UPS | None = None - timestamp: float = 0.0 + timestamp: float = 0.0 # Last collection attempt + ecoflow: EcoFlowBTConfig | None = None # EcoFlow BT config # Expected kernel log output when the USB-serial adapter is plugged in: # usb 1-1.4: new full-speed USB device number 4 using xhci_hcd @@ -32,7 +40,7 @@ class DeviceState: # usb 1-1.4: New USB device strings: Mfr=1, Product=2, SerialNumber=3 # usb 1-1.4: Product: FT232R USB UART # usb 1-1.4: Manufacturer: FTDI -# usb 1-1.4: SerialNumber: AQ00QFHZ +# usb 1-1.4: SerialNumber: SERIAL_NUMBER # usbcore: registered new interface driver usbserial_generic # usbserial: USB Serial support registered for generic # usbcore: registered new interface driver ftdi_sio @@ -108,13 +116,78 @@ def send_response(connection: socket.socket, response_data: Any, client: str, de raise OSError("unable to encode response: {}".format(e)) +def is_ecoflow_device(path: str) -> bool: + """Return True if *path* is an EcoFlow BT spec rather than a serial port. + + Match the address prefix, not an assumption about serial-device paths. + Absolute paths outside /dev and relative serial paths remain supported. + """ + address = path.partition("/")[0] + # Treat malformed MAC-like prefixes as Bluetooth too, so validation can + # reject them without sending the account-bearing spec to the serial code. + return len(address.split(":")) == 6 + + def parse_device(device_str: str) -> tuple[str, str]: - """Parse 'name:/dev/path' or '/dev/path' into (name, path).""" - if not device_str.startswith("/") and ":" in device_str: + """Parse a device spec into ``(name, path_or_btspec)``. + + Supported formats:: + + /dev/ttyUSB0 → (ttyUSB0, /dev/ttyUSB0) + myname:/dev/ttyUSB0 → (myname, /dev/ttyUSB0) + AA:BB:CC:DD:EE:FF/R331XXXX/userid → (R331XXXX, AA:BB:.../userid) + mypower@AA:BB:CC:DD:EE:FF/R331XXXX/userid → (mypower, AA:BB:.../userid) + + Use ``@`` (not ``:``) to supply an explicit name for EcoFlow BT devices, + since MAC addresses already contain colons. + """ + device_str = device_str.strip() + if device_str.startswith("/"): + return os.path.basename(device_str), device_str + if "@" in device_str: + name, spec = device_str.split("@", 1) + return name.strip(), spec.strip() + if is_ecoflow_device(device_str): + parts = device_str.split("/") + return (parts[1] if len(parts) > 1 and parts[1] else "ecoflow"), device_str + if ":" in device_str: name, path = device_str.split(":", 1) return name, path - name = device_str.split("/")[-1] - return name, device_str + return os.path.basename(device_str), device_str + + +def parse_ecoflow_spec(spec: str) -> tuple[str, str, str]: + """Parse ``ADDRESS/SERIAL/USERID`` into its three components.""" + parts = [part.strip() for part in spec.split("/")] + if (len(parts) != 3 or not all(parts) + or re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", parts[0]) is None): + # Do not echo the account ID from an invalid specification. + raise ValueError("EcoFlow BT device spec must be ADDRESS/SERIAL/USERID") + return parts[0].upper(), parts[1], parts[2] + + +def collect_device_data(device: DeviceState, debug: int = 0) -> UPS | None: + """Collect fresh data from whichever device type is in *device*.""" + if device.ser is not None: + return collect_data(device.ser, debug) or None + if device.ecoflow is not None: + return ecoflow_collect_data(device.ecoflow, debug) or None + return None + + +def get_device_data(device: DeviceState, debug: int = 0) -> UPS | None: + """Return cached data, refreshing it when the backend's interval expires.""" + min_age = 60 if device.ecoflow is not None else 5 + if device.timestamp <= time.time() - min_age: + device.data = collect_device_data(device, debug) + if device.ecoflow is None: + # Preserve serial retries. BLE already retries within collect_data; + # retrying it forever here would block all clients when it is offline. + while device.data is None: + time.sleep(1) + device.data = collect_device_data(device, debug) + device.timestamp = time.time() + return device.data def main(): @@ -125,7 +198,7 @@ def main(): signal.signal(signal.SIGTERM, signalHandler) parser = argparse.ArgumentParser( - description="Query JBD BMS and report status", + description="Query BMS devices and report status", add_help=True, ) parser.add_argument( @@ -134,8 +207,12 @@ def main(): dest="devices", action="append", default=None, - metavar="[NAME:]/dev/PATH", - help="USB device to read (may be specified multiple times; optionally prefixed with name:)", + metavar="SPEC", + help=( + "Device to read (may be repeated). " + "JBD serial: [name:]/dev/PATH. " + "EcoFlow BT: [name@]ADDRESS/SERIAL/USERID" + ), ) parser.add_argument( "--socket", @@ -177,11 +254,23 @@ def main(): for device_str in device_list: name, path = parse_device(device_str) if name in ups_devices: - debugger("server: duplicate UPS name '{}', skipping {}".format(name, path)) + debugger("server: duplicate UPS name '{}', skipping device".format(name)) continue - ups_devices[name] = DeviceState(ser=initialise_serial(path, debug)) - if debug > 0: - print("server: registered UPS '{}' on {}".format(name, path)) + if is_ecoflow_device(path): + try: + address, sn, user_id = parse_ecoflow_spec(path) + except ValueError as e: + debugger("server: {}".format(e)) + continue + ups_devices[name] = DeviceState( + ecoflow=ecoflow_initialise_device(address, sn, user_id) + ) + if debug > 0: + print("server: registered EcoFlow BT '{}' ({})".format(name, address)) + else: + ups_devices[name] = DeviceState(ser=initialise_serial(path, debug)) + if debug > 0: + print("server: registered JBD BMS '{}' on {}".format(name, path)) if debug > 0: debugger("Running BMS query daemon on socket {}".format(args.socket)) @@ -308,14 +397,9 @@ def main(): name, device.timestamp, time.time() ) ) - # only get new data five seconds after the last read - if device.timestamp <= time.time() - 5: - device.data = None - while not device.data: - device.data = collect_data(device.ser, debug) - time.sleep(1) - device.timestamp = time.time() - result[name] = device.data + data = get_device_data(device, debug) + if data is not None: + result[name] = data send_response(connection, result, client, debug) diff --git a/tests/test_server_ecoflow.py b/tests/test_server_ecoflow.py new file mode 100644 index 0000000..f2ab9bd --- /dev/null +++ b/tests/test_server_ecoflow.py @@ -0,0 +1,119 @@ +"""Server routing and caching tests with synthetic device identifiers.""" + +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 + + +@pytest.mark.parametrize("bluetooth,interval", [(False, 5), (True, 60)]) +def test_cache_interval_depends_on_backend(monkeypatch, bluetooth, interval): + old, fresh = JBDBMS(), JBDBMS() + config = initialise_device(ADDRESS, SERIAL, "test-user") if bluetooth else None + state = server.DeviceState(data=old, timestamp=100, ecoflow=config) + read = Mock(return_value=fresh) + monkeypatch.setattr(server, "collect_device_data", read) + monkeypatch.setattr(server.time, "time", lambda: 100 + interval - 1) + assert server.get_device_data(state) is old + read.assert_not_called() + monkeypatch.setattr(server.time, "time", lambda: 100 + interval) + assert server.get_device_data(state) is fresh + read.assert_called_once_with(state, 0) + + +def test_offline_bluetooth_does_not_retry_forever_or_serve_stale_data(monkeypatch): + state = server.DeviceState( + data=JBDBMS(), timestamp=1, ecoflow=initialise_device(ADDRESS, SERIAL, "test-user"), + ) + read = Mock(return_value=None) + monkeypatch.setattr(server, "collect_device_data", read) + monkeypatch.setattr(server.time, "time", lambda: 100) + assert server.get_device_data(state) is None + assert state.timestamp == 100 + # A failed poll also has a backoff, rather than reconnecting on every GET. + assert server.get_device_data(state) is None + read.assert_called_once_with(state, 0) + monkeypatch.setattr(server.time, "time", lambda: 160) + assert server.get_device_data(state) is None + assert read.call_count == 2 + + +def test_serial_collection_still_retries(monkeypatch): + data = JBDBMS() + state = server.DeviceState(ser=Mock()) + read = Mock(side_effect=[None, data]) + sleep = Mock() + monkeypatch.setattr(server, "collect_device_data", read) + monkeypatch.setattr(server.time, "time", lambda: 100) + monkeypatch.setattr(server.time, "sleep", sleep) + assert server.get_device_data(state) is data + assert read.call_count == 2 + sleep.assert_called_once_with(1)