Sync library with live findings: raw socket docs, wire reply parsing, pack-id BMS, empty-slot guard

This commit is contained in:
kfj001 committed 2026-10-16 04:18:22 +00:00
1 parent 3d59a75f14
commit c11188dd0b
6 files changed
+83 -31

No files matched your search

+24 -3
View File
@@ -125,6 +125,23 @@ def _soc_detail(data: Dict[str, Any]) -> Dict[str, Any]:
return detail
def _is_empty_pack(reply: Dict[str, Any], result: Dict[str, Any]) -> bool:
"""True when a BMS reply is the all-zero placeholder slot.
A genuine 0 % pack still reports health/voltage (``batSoh``,
``batTotalVolt``); the unaddressed ``id: 0`` slot reports literal
zeros with ``result == 0``. Returning ``0.0`` for that slot would
mask the real packs, so the caller keeps probing instead.
"""
if result.get("source") != "batSoc" or result.get("value") != 0:
return False
data = reply.get("dataArea", reply)
if not isinstance(data, dict):
return False
return (data.get("result") == 0 and data.get("batSoh") == 0
and data.get("batTotalVolt") == 0)
def get_battery(
sn: str,
host: str = "10.100.1.1",
@@ -175,7 +192,8 @@ def get_battery(
continue
result = extract_soc(reply)
result.update({"cmd": str(cmd), "port": port, "reply": reply})
if result["value"] is not None:
if result["value"] is not None and not _is_empty_pack(
reply, result):
return result
last_err = ValueError(f"reply on port {port} cmd {cmd} has no SoC field")
raise ConnectionError(f"no battery reply via {host}:{list(ports)}") from last_err
@@ -279,7 +297,7 @@ def probe_transport(
prefixes (LE16/BE16/LE32), an MQTT CONNECT, and a double-send over one
persistent connection. Each entry carries exact ``tx``/``rx`` bytes.
"""
from .protocol import decrypt_data
from .protocol import decrypt_wire
results: List[Dict[str, Any]] = []
for port in ports:
@@ -312,7 +330,10 @@ def probe_transport(
except (ConnectionError, socket.error, TimeoutError) as exc:
entry["error"] = f"{type(exc).__name__}: {exc}"
if entry["rx"]:
decoded = decrypt_data(entry["rx"], radix=key_radix)
try:
decoded = decrypt_wire(entry["rx"], radix=key_radix)
except ValueError:
decoded = ""
if decoded:
try:
entry["reply"] = json.loads(decoded)
+13 -9
View File
@@ -1,15 +1,20 @@
"""Local (direct-connect) device client.
Talks to the aGate **on its own network** after joining the device hotspot —
the ``mode == local`` path of the app (see ``docs/RE_DART_FINDINGS.md`` §8,
§11). Two channels:
the direct-connect path of the app (see ``docs/RE_DART_FINDINGS.md`` §8,
§11, §14). Two channels:
* **HTTP** to ``https://IP:PORT/terminal/...`` — the ``/terminal/*`` local
endpoint family (hotspot info, aGate info, overall info, MQTT relay, ...).
Scheme/port are *not* confirmed (Open item #3); defaults are candidates.
* **TCP socket** to ``10.100.1.1:18000`` (candidate port — Open item #4)
carrying the encrypted :class:`~franklinwh_agate_client.protocol.CommunicationObject`
envelope with little-endian int16 length framing.
Scheme/port are *not* confirmed live (Open item #3); defaults follow the
app's ``RemoteService`` shape.
* **TCP socket** to ``10.100.1.1:9000`` — verified live 2026-10-16. The
app dials the gateway IP port ``18000`` (``0x4650``), which is closed on
the tested unit; ``9000`` answers the same encrypted
:class:`~franklinwh_agate_client.protocol.CommunicationObject` envelope
sent **raw** (``dart:io _Socket.add``, no length prefix), with socket-id
``cmdType`` values (``SOCKET_CMD_TYPE``) and ``opt``-seeded payloads.
Replies use the identical framing with id ``request + 1``.
"""
from __future__ import annotations
@@ -27,8 +32,6 @@ from .protocol import (
CommunicationOperate,
SequenceNumber,
SocketFrameDecoder,
decrypt_data,
decrypt_request,
decrypt_wire,
direct_connect_request,
encrypt_object,
@@ -182,7 +185,8 @@ class LocalDeviceClient:
``increaseSN`` counter, serialised to compact JSON,
``encrypt_object``-ed, and sent **raw** (``_Socket.add``, no length
prefix). The reply is accumulated chunk by chunk and run through
``decrypt_data`` until it yields a JSON object.
``decrypt_wire`` (2nd-comma split, equipNo-derived key) until it
yields a JSON object.
"""
if envelope is None:
envelope = direct_connect_request(
+3 -4
View File
@@ -212,10 +212,9 @@ def frame_socket_payload(payload: bytes) -> bytes:
class SocketFrameDecoder:
"""Accumulating socket receiver (``SocketManager._onSocketReceiveData``).
Each incoming chunk is appended to a growable buffer and ``decrypt_data``
is attempted over the whole buffer (inside try/catch in the app); a
non-empty decoded string that parses as JSON is one complete reply, at
which point the buffer is cleared. Use :meth:`feed` for raw chunks and
Each incoming chunk is appended to a growable buffer and ``decrypt_wire``
is attempted over the whole buffer; a decoded string that parses as
JSON is one complete reply, at which point the buffer is cleared. Use :meth:`feed` for raw chunks and
:meth:`feed_json` when you want parsed envelopes back.
"""