Files
bmspy/bmspy/server.py
T
tim 79f9ebf0c7 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.
2026-10-08 14:02:45 +02:00

424 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
#
# 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
import sys
import stat
import time
import atexit
import signal
import json
import re
import struct
import serial
from dataclasses import asdict as dataclass_asdict, dataclass
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 | None = None # JBD BMS serial port
data: UPS | None = None
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
# usb 1-1.4: New USB device found, idVendor=0403, idProduct=6001, bcdDevice= 6.00
# 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: SERIAL_NUMBER
# usbcore: registered new interface driver usbserial_generic
# usbserial: USB Serial support registered for generic
# usbcore: registered new interface driver ftdi_sio
# usbserial: USB Serial support registered for FTDI USB Serial Device
# ftdi_sio 1-1.4:1.0: FTDI USB Serial Device converter detected
# usb 1-1.4: Detected FT232RL
# usb 1-1.4: FTDI USB Serial Device converter now attached to ttyUSB0
connected_clients = list()
def signalHandler() -> NoReturn:
raise SystemExit("terminating")
def socket_cleanup(socket_path: str, debug: int = 0) -> None:
"""Remove the Unix socket file from the filesystem."""
os.unlink(socket_path)
def read_request(connection: socket.socket, debug: int = 0) -> dict[str, Any]:
"""Read a length-prefixed JSON request from a connected socket."""
# get length of expected json string
request = bytes()
try:
request = connection.recv(struct.calcsize("!I"))
except Exception as e:
raise OSError("unable to read request length from socket: {}".format(e))
try:
length = struct.unpack("!I", request)[0]
except Exception as e:
raise Exception("unable to determine request length: {}".format(e))
if debug > 4:
debugger("socket: incoming length: {}, encoded as {}".format(length, request))
# read length bytes
try:
request = connection.recv(length)
except Exception as e:
raise OSError("unable to read socket: {}".format(e))
if debug > 3:
debugger("socket: incoming request: {}".format(request))
try:
request_data = json.loads(request)
except Exception as e:
raise Exception("unable to read incoming request: {}".format(e))
if debug > 2:
debugger("socket: received {!r}".format(request_data))
return request_data
def send_response(connection: socket.socket, response_data: Any, client: str, debug: int = 0) -> None:
"""Serialise response_data as length-prefixed JSON and send it over the socket."""
if debug > 2:
debugger("socket: sending {!r}".format(response_data))
try:
response = json.dumps(
response_data,
default=lambda o: {k: dataclass_asdict(v) for k, v in o.items()}
if hasattr(o, "items") and not isinstance(o, dict)
else str(o),
).encode()
# add length to the start of the json string, so we know how much to read on the other end
length = struct.pack("!I", len(response))
if debug > 4:
print("socket: sending {} data of length: {}".format(client, length))
response = length + response
if debug > 3:
print("socket: outgoing response: {}".format(response))
return connection.sendall(response)
except Exception as 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]:
"""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
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():
import argparse
import pwd
import grp
signal.signal(signal.SIGTERM, signalHandler)
parser = argparse.ArgumentParser(
description="Query BMS devices and report status",
add_help=True,
)
parser.add_argument(
"--device",
"-d",
dest="devices",
action="append",
default=None,
metavar="SPEC",
help=(
"Device to read (may be repeated). "
"JBD serial: [name:]/dev/PATH. "
"EcoFlow BT: [name@]ADDRESS/SERIAL/USERID"
),
)
parser.add_argument(
"--socket",
"-s",
dest="socket",
action="store",
default="/run/bmspy/bms",
help="Socket to communicate with daemon",
)
parser.add_argument(
"--user",
"-u",
dest="uid_name",
action="store",
default="nobody",
help="Run daemon as user",
)
parser.add_argument(
"--group",
"-g",
dest="gid_name",
action="store",
default="dialout",
help="Run daemon as group",
)
parser.add_argument(
"--verbose",
"-v",
action="count",
default=0,
help="Print more verbose information (can be specified multiple times)",
)
args = parser.parse_args()
debug = args.verbose
device_list = args.devices or ["/dev/ttyUSB0"]
ups_devices: dict[str, DeviceState] = {}
for device_str in device_list:
name, path = parse_device(device_str)
if name in ups_devices:
debugger("server: duplicate UPS name '{}', skipping device".format(name))
continue
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))
socket_dir = os.path.dirname(args.socket)
socket_dir_created = False
if not os.path.isdir(socket_dir):
os.makedirs(socket_dir, exist_ok=True)
socket_dir_created = True
starting_uid = os.getuid()
starting_gid = os.getgid()
if starting_uid == 0:
running_uid = pwd.getpwnam(args.uid_name)[2]
running_gid = grp.getgrnam(args.gid_name)[2]
if socket_dir_created:
os.chown(socket_dir, running_uid, running_gid)
os.chmod(
socket_dir,
stat.S_IRUSR
| stat.S_IWUSR
| stat.S_IXUSR
| stat.S_IRGRP
| stat.S_IWGRP
| stat.S_IXGRP
| stat.S_IXGRP
| stat.S_IROTH
| stat.S_IXOTH,
)
new_umask = 0o003
old_umask = os.umask(new_umask)
if debug > 1:
debugger(
"socket: old umask: %s, new umask: %s"
% (oct(old_umask), oct(new_umask))
)
try:
os.setgid(running_gid)
except OSError as e:
debugger("could not set effective group id: {}".format(e))
try:
os.setuid(running_uid)
except OSError as e:
debugger("could not set effective user id: {}".format(e))
final_uid = os.getuid()
final_gid = os.getgid()
if debug > 0:
debugger(
"socket: running as {}:{}".format(
pwd.getpwuid(final_uid)[0], grp.getgrgid(final_gid)[0]
)
)
if os.path.exists(args.socket):
raise OSError("socket {} already exists; exiting...".format(args.socket))
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
if debug > 2:
debugger("starting up on {}".format(args.socket))
sock.bind(args.socket)
atexit.register(socket_cleanup, args.socket, debug)
sock.listen(1)
while True:
connection = None
client_address = None
try:
if debug > 2:
debugger("socket: waiting for a connection")
connection, client_address = sock.accept()
request_data = dict()
try:
request_data = read_request(connection, debug)
except Exception as e:
print("socket ERROR: {}".format(e))
continue
client = request_data["client"] or "unknown"
match request_data["command"]:
case "REGISTER":
connected_clients.append(client)
send_response(
connection,
{"status": "REGISTERED", "client": client},
client,
debug,
)
case "DEREGISTER":
try:
connected_clients.remove(client)
except Exception:
pass
send_response(
connection,
{"status": "DEREGISTERED", "client": client},
client,
debug,
)
case "GET":
ups_filter = request_data.get("ups")
targets = (
{ups_filter: ups_devices[ups_filter]}
if ups_filter and ups_filter in ups_devices
else ups_devices
)
result = {}
for name, device in targets.items():
if debug > 0:
debugger(
"reading data for '{}', timestamp={}, time={}".format(
name, device.timestamp, time.time()
)
)
data = get_device_data(device, debug)
if data is not None:
result[name] = data
send_response(connection, result, client, debug)
case _:
debugger(
"socket: invalid request from {}".format(request_data["client"])
)
break
except KeyboardInterrupt:
if connection:
connection.close()
sys.exit(1)
finally:
if connection:
connection.close()
if __name__ == "__main__":
main()