Integrate EcoFlow Bluetooth devices into the server
Serial paths and collector interfaces remain compatible. Bluetooth polling uses a 60-second cache and backoff. Offline Bluetooth devices no longer retry forever inside a client request. Invalid device specifications do not echo account IDs.
This commit is contained in:
+107
-23
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 os
|
||||||
import socket
|
import socket
|
||||||
@@ -10,6 +11,7 @@ import time
|
|||||||
import atexit
|
import atexit
|
||||||
import signal
|
import signal
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import struct
|
import struct
|
||||||
import serial
|
import serial
|
||||||
from dataclasses import asdict as dataclass_asdict, dataclass
|
from dataclasses import asdict as dataclass_asdict, dataclass
|
||||||
@@ -18,13 +20,19 @@ from typing import Any, NoReturn
|
|||||||
from bmspy.utilities import debugger
|
from bmspy.utilities import debugger
|
||||||
from bmspy.classes import UPS
|
from bmspy.classes import UPS
|
||||||
from bmspy.jbd_bms import collect_data, initialise_serial
|
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
|
@dataclass
|
||||||
class DeviceState:
|
class DeviceState:
|
||||||
ser: serial.Serial
|
ser: serial.Serial | None = None # JBD BMS serial port
|
||||||
data: UPS | None = None
|
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:
|
# 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
|
# 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: New USB device strings: Mfr=1, Product=2, SerialNumber=3
|
||||||
# usb 1-1.4: Product: FT232R USB UART
|
# usb 1-1.4: Product: FT232R USB UART
|
||||||
# usb 1-1.4: Manufacturer: FTDI
|
# 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
|
# usbcore: registered new interface driver usbserial_generic
|
||||||
# usbserial: USB Serial support registered for generic
|
# usbserial: USB Serial support registered for generic
|
||||||
# usbcore: registered new interface driver ftdi_sio
|
# 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))
|
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]:
|
def parse_device(device_str: str) -> tuple[str, str]:
|
||||||
"""Parse 'name:/dev/path' or '/dev/path' into (name, path)."""
|
"""Parse a device spec into ``(name, path_or_btspec)``.
|
||||||
if not device_str.startswith("/") and ":" in device_str:
|
|
||||||
|
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)
|
name, path = device_str.split(":", 1)
|
||||||
return name, path
|
return name, path
|
||||||
name = device_str.split("/")[-1]
|
return os.path.basename(device_str), device_str
|
||||||
return name, 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():
|
def main():
|
||||||
@@ -125,7 +198,7 @@ def main():
|
|||||||
signal.signal(signal.SIGTERM, signalHandler)
|
signal.signal(signal.SIGTERM, signalHandler)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Query JBD BMS and report status",
|
description="Query BMS devices and report status",
|
||||||
add_help=True,
|
add_help=True,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -134,8 +207,12 @@ def main():
|
|||||||
dest="devices",
|
dest="devices",
|
||||||
action="append",
|
action="append",
|
||||||
default=None,
|
default=None,
|
||||||
metavar="[NAME:]/dev/PATH",
|
metavar="SPEC",
|
||||||
help="USB device to read (may be specified multiple times; optionally prefixed with name:)",
|
help=(
|
||||||
|
"Device to read (may be repeated). "
|
||||||
|
"JBD serial: [name:]/dev/PATH. "
|
||||||
|
"EcoFlow BT: [name@]ADDRESS/SERIAL/USERID"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--socket",
|
"--socket",
|
||||||
@@ -177,11 +254,23 @@ def main():
|
|||||||
for device_str in device_list:
|
for device_str in device_list:
|
||||||
name, path = parse_device(device_str)
|
name, path = parse_device(device_str)
|
||||||
if name in ups_devices:
|
if name in ups_devices:
|
||||||
debugger("server: duplicate UPS name '{}', skipping {}".format(name, path))
|
debugger("server: duplicate UPS name '{}', skipping device".format(name))
|
||||||
continue
|
continue
|
||||||
ups_devices[name] = DeviceState(ser=initialise_serial(path, debug))
|
if is_ecoflow_device(path):
|
||||||
if debug > 0:
|
try:
|
||||||
print("server: registered UPS '{}' on {}".format(name, path))
|
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:
|
if debug > 0:
|
||||||
debugger("Running BMS query daemon on socket {}".format(args.socket))
|
debugger("Running BMS query daemon on socket {}".format(args.socket))
|
||||||
@@ -308,14 +397,9 @@ def main():
|
|||||||
name, device.timestamp, time.time()
|
name, device.timestamp, time.time()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# only get new data five seconds after the last read
|
data = get_device_data(device, debug)
|
||||||
if device.timestamp <= time.time() - 5:
|
if data is not None:
|
||||||
device.data = None
|
result[name] = data
|
||||||
while not device.data:
|
|
||||||
device.data = collect_data(device.ser, debug)
|
|
||||||
time.sleep(1)
|
|
||||||
device.timestamp = time.time()
|
|
||||||
result[name] = device.data
|
|
||||||
|
|
||||||
send_response(connection, result, client, debug)
|
send_response(connection, result, client, debug)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user