Files
bmspy/bmspy/client.py
T
tim 886991caf3 Add River 2 Bluetooth telemetry and diagnostics
Decode River 2 charge, temperature, power flow, and time estimates. Preserve precise charge readings across packet reordering and omit unavailable time estimates.

Require valid PD, EMS, and BMS core heartbeats for diagnostic completion. Report optional packets separately and support raw watch output with reliable disconnect cleanup. Use synthetic regression tests and keep captures, credentials, and external keydata private.

Device details come from command-line arguments or discovery. This commit retains Poetry; the separate packaging commit migrates to uv.
2026-10-08 14:02:45 +02:00

144 lines
4.6 KiB
Python

#
# Library with socket client for use by consumers
#
import sys
import struct
import json
import socket
from typing import Any
from bmspy.utilities import debugger
from bmspy.classes import UPS
is_registered = False
def handle_registration(socket_path: str, client_name: str, debug: int = 0) -> dict[str, Any]:
"""Register or deregister a named client with the bmspy daemon over the socket."""
global is_registered
data: dict[str, Any] = dict()
if is_registered:
message: dict[str, Any] = {"command": "DEREGISTER", "client": client_name}
else:
# fork server if it's not already running
message = {"command": "REGISTER", "client": client_name}
try:
data = socket_comms(socket_path, message, debug)
if data["status"] == "REGISTERED":
is_registered = True
elif data["status"] == "DEREGISTERED":
is_registered = False
else:
raise OSError(
"{} registration: invalid response: {}".format(client_name, data)
)
except Exception as e:
if is_registered:
debugger("{}: failed to register with daemon: {}".format(client_name, e))
else:
debugger("{}: failed to deregister with daemon: {}".format(client_name, e))
return data
def _recv_exact(sock: socket.socket, length: int) -> bytes:
"""Read a complete frame, even when the stream splits it across receives."""
data = bytearray()
while len(data) < length:
chunk = sock.recv(length - len(data))
if not chunk:
raise OSError("Daemon closed the socket before the response was complete")
data.extend(chunk)
return bytes(data)
def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0) -> dict[str, Any]:
"""Send a JSON request to the daemon socket and return the decoded JSON response."""
response_data: dict[str, Any] = dict()
# Create a UDS socket
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
# Connect the socket to the port where the server is listening
if debug > 2:
debugger("socket client: connecting to {}".format(socket_path))
try:
sock.connect(socket_path)
except socket.error as msg:
if msg.errno == 2:
debugger("Failed to connect to bmspy daemon")
else:
debugger("socket client: {}".format(msg))
sock.close()
raise
# Send request
if debug > 2:
debugger("socket client: sending {!r}".format(request_data))
request = bytes()
try:
request = json.dumps(request_data).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(request))
if debug > 3:
debugger(
"socket client: outgoing request length: {}, encoded as {}".format(
len(request), length
)
)
request = length + request
if debug > 4:
debugger("socket client: outgoing request: {}".format(request))
except Exception:
sock.close()
debugger("socket client ERROR: unable to encode request")
sys.exit(1)
# get length of expected json string
try:
sock.sendall(request)
response = _recv_exact(sock, struct.calcsize("!I"))
length = struct.unpack("!I", response)[0]
if debug > 4:
debugger(
"socket client: incoming length: {}, encoded as {}".format(
length, response
)
)
# read length bytes
response = _recv_exact(sock, length)
if debug > 3:
debugger("socket client: incoming response: {}".format(response))
response_data = json.loads(response)
except Exception:
debugger("socket client ERROR: unable to decode response")
sys.exit(1)
finally:
sock.close()
if debug > 2:
debugger("socket client: received {!r}".format(response_data))
return response_data
def read_data(socket_path: str, client_name: str, ups: str | None = None, debug: int = 0) -> dict[str, UPS]:
"""Return {ups_name: UPS} for all devices, or just the named one."""
request: dict[str, Any] = {"command": "GET", "client": client_name}
if ups is not None:
request["ups"] = ups
data = socket_comms(socket_path, request, debug)
if data is None:
raise RuntimeError("No data received from daemon")
return {name: UPS.from_dict(fields) for name, fields in data.items()}
if __name__ == "__main__":
sys.exit(0)