Add a server option for the Bluetooth read interval
The server read each Bluetooth device every 60 seconds, and each read is one scan and one connection. An adapter that fails after many connections had no way to get fewer of them. --bluetooth-interval sets the wait after a good Bluetooth read, and the default stays at 60 seconds. Clients receive the last read during the wait. A failed read still tries again after 60 seconds, or after the interval for a shorter one.
This commit is contained in:
@@ -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.
|
||||
|
||||
+32
-13
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user