read_request called recv once for the length and once for the body. A stream socket can deliver a request in several pieces, and the server then rejected that request. The server now uses the read loop of the client, which moves to utilities as recv_exact. The server refuses a request above 64 KiB.
512 lines
17 KiB
Python
Executable File
512 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, recv_exact
|
|
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
|
|
# Time limit for each read from a client and each write to it.
|
|
CLIENT_TIMEOUT_SECONDS = 5
|
|
# A request is a short JSON object. A larger length prefix is an error.
|
|
MAX_REQUEST_BYTES = 64 * 1024
|
|
|
|
# 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 = recv_exact(connection, 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))
|
|
|
|
if length > MAX_REQUEST_BYTES:
|
|
raise OSError(
|
|
"request of {} bytes exceeds the limit of {}".format(length, MAX_REQUEST_BYTES)
|
|
)
|
|
|
|
# read length bytes
|
|
try:
|
|
request = recv_exact(connection, 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 handle_client(
|
|
connection: socket.socket, ups_devices: dict[str, DeviceState], debug: int = 0,
|
|
) -> None:
|
|
"""Answer one request, then close the connection.
|
|
|
|
Each client has its own thread, so a slow or silent client does not delay
|
|
the other clients.
|
|
"""
|
|
try:
|
|
# A client that sends no request, or does not read its reply, must not
|
|
# keep its thread forever.
|
|
connection.settimeout(CLIENT_TIMEOUT_SECONDS)
|
|
try:
|
|
request_data = read_request(connection, debug)
|
|
except Exception as e:
|
|
print("socket ERROR: {}".format(e))
|
|
return
|
|
|
|
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:
|
|
# For example, the client disconnected before its reply.
|
|
debugger("socket ERROR: {}".format(e))
|
|
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
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:
|
|
if debug > 2:
|
|
debugger("socket: waiting for a connection")
|
|
try:
|
|
connection, _ = sock.accept()
|
|
except KeyboardInterrupt:
|
|
sys.exit(1)
|
|
threading.Thread(
|
|
target=handle_client, args=(connection, ups_devices, debug), name="client", daemon=True,
|
|
).start()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|