If a client disconnected before its reply, send_response raised OSError and the server ended. The request loop now logs the error and waits for the next client. An error from accept() still ends the server.
499 lines
17 KiB
Python
Executable File
499 lines
17 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 threading
|
|
import serial
|
|
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
|
from types import FrameType
|
|
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
|
|
# 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
|
|
FIRST_POLL_TIMEOUT = 120
|
|
|
|
# One adapter: collect from one Bluetooth device at a time.
|
|
bluetooth_lock = threading.Lock()
|
|
|
|
# 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(signum: int, frame: FrameType | None) -> 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:
|
|
with bluetooth_lock:
|
|
return ecoflow_collect_data(device.ecoflow, debug) or None
|
|
return None
|
|
|
|
|
|
def poll_device(device: DeviceState, stop: threading.Event, debug: int = 0) -> None:
|
|
"""Keep *device.data* current until *stop* is set.
|
|
|
|
Each device has its own thread, so a slow or offline device delays neither
|
|
the clients nor the other devices. A collection replaces the snapshot and
|
|
never changes one that clients can already read, so no lock is needed.
|
|
"""
|
|
last_success = 0.0
|
|
while not stop.is_set():
|
|
try:
|
|
data = collect_device_data(device, debug)
|
|
except Exception as e:
|
|
# A failed read must not end polling for this device.
|
|
debugger("server: collection error: {}".format(e))
|
|
data = None
|
|
device.timestamp = time.time()
|
|
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
|
|
elif device.ecoflow is not None:
|
|
# BLE already retries within collect_data. Do not serve stale data.
|
|
device.data = None
|
|
delay = BLUETOOTH_POLL_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.
|
|
if device.timestamp - last_success > SERIAL_STALE_SECONDS:
|
|
device.data = None
|
|
delay = SERIAL_RETRY_SECONDS
|
|
device.polled.set()
|
|
stop.wait(delay)
|
|
|
|
|
|
def start_polling(
|
|
ups_devices: dict[str, DeviceState], stop: threading.Event, debug: int = 0,
|
|
) -> list[threading.Thread]:
|
|
"""Start one polling thread per device."""
|
|
threads = []
|
|
for name, device in ups_devices.items():
|
|
thread = threading.Thread(
|
|
target=poll_device, args=(device, stop, debug), name="poll-" + name, daemon=True,
|
|
)
|
|
thread.start()
|
|
threads.append(thread)
|
|
return threads
|
|
|
|
|
|
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)
|
|
|
|
stop_polling = threading.Event()
|
|
atexit.register(stop_polling.set)
|
|
start_polling(ups_devices, stop_polling, debug)
|
|
|
|
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
|
|
|
|
if not isinstance(request_data, dict):
|
|
request_data = {}
|
|
client = request_data.get("client") or "unknown"
|
|
command = request_data.get("command")
|
|
|
|
match 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 "DEVICES":
|
|
# Configured names, including devices that have no data yet.
|
|
send_response(connection, {"devices": list(ups_devices)}, 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()
|
|
)
|
|
)
|
|
# Wait for the first collection attempt only, so that a
|
|
# client at startup sees every device that can answer.
|
|
device.polled.wait(FIRST_POLL_TIMEOUT)
|
|
data = device.data
|
|
if data is not None:
|
|
result[name] = data
|
|
|
|
send_response(connection, result, client, debug)
|
|
|
|
case _:
|
|
# Answer and continue: one bad request must not end the
|
|
# server for every other client.
|
|
debugger("socket: invalid request from {}".format(client))
|
|
send_response(
|
|
connection,
|
|
{"status": "ERROR", "message": "unknown command: {!r}".format(command)},
|
|
client,
|
|
debug,
|
|
)
|
|
|
|
except OSError as e:
|
|
if connection is None:
|
|
# accept() failed, so the listening socket is not usable.
|
|
raise
|
|
# A client that disconnects before its reply must not end the
|
|
# server for every other client.
|
|
debugger("socket ERROR: {}".format(e))
|
|
|
|
except KeyboardInterrupt:
|
|
if connection:
|
|
connection.close()
|
|
sys.exit(1)
|
|
|
|
finally:
|
|
if connection:
|
|
connection.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|