Sync library with live findings: raw socket docs, wire reply parsing, pack-id BMS, empty-slot guard
This commit is contained in:
1 parent
3d59a75f14
commit
c11188dd0b
6 files changed
+83
-31
No files matched your search
+15
-3
@@ -86,9 +86,21 @@ instead: install the Frida hook (`blutter_out/blutter_frida.js` pattern —
|
|||||||
log `Socket Manager send object: ` / `receive object: `), tap *Refresh* in
|
log `Socket Manager send object: ` / `receive object: `), tap *Refresh* in
|
||||||
the app while joined to the AP, and compare its TX bytes against ours.
|
the app while joined to the AP, and compare its TX bytes against ours.
|
||||||
|
|
||||||
## Limits
|
## Status (2026-10-16) — live round-trip works
|
||||||
|
|
||||||
- No live round-trip has succeeded yet from an isolated container netns
|
Two-way comms verified against `10.100.1.1:9000` from a host on
|
||||||
(all probes `ECONNREFUSED`); first success needs a `10.100.1.x` source.
|
`10.100.1.0/24`: discovery → `1102` device-info reply (versions, `FHP_SN`,
|
||||||
|
`BMS_SN`), per-pack BMS reads (`{"id": 1}` / `{"id": 2}` → live `batSoc`),
|
||||||
|
EMS telemetry. `get_battery()` runs that exact sequence (discovery,
|
||||||
|
apower, BMS packs 1–2, EMS) and returns the first SoC found; an all-zero
|
||||||
|
BMS slot is skipped, not reported as 0 %.
|
||||||
|
|
||||||
|
`DEVICE_APOWER_INFO` answers `{"result": 1, "reason": -1}` on the tested
|
||||||
|
unit — SoC comes from the BMS packs instead.
|
||||||
|
|
||||||
|
Remaining limits:
|
||||||
|
|
||||||
|
- Needs L2 reachability to `10.100.1.1` (host on the device AP or bridged
|
||||||
|
container); the app's own socket port `18000` is closed on this unit.
|
||||||
- BLE field order and `X-Device-*` binding remain open
|
- BLE field order and `X-Device-*` binding remain open
|
||||||
(`RE_DART_FINDINGS.md` Open items).
|
(`RE_DART_FINDINGS.md` Open items).
|
||||||
+14
-12
@@ -494,23 +494,25 @@ keepAlive 10 s, LWT, QoS 0, event names), `MethodHandlerManager` handler list,
|
|||||||
|
|
||||||
Still open:
|
Still open:
|
||||||
|
|
||||||
1. Exact BLE frame field order & the 2-byte magic semantics (confirm with `btsnoop`).
|
1. Live verification of the BLE frame layout (settled from disassembly in
|
||||||
2. ~~Exact `encryptObject` radix + trailing-header handling~~ — **resolved
|
§9 — `magic ++ crc ++ totalLen ++ sn ++ content`, radix-32 CRC pairs —
|
||||||
from disassembly (see §5): radix 32, plaintext header first
|
but no live BLE exchange yet) and the GATT service/notify
|
||||||
(`S1 || transform(S2)`), `decryptData` marker model. Remaining: first
|
characteristics (write char matches `0000f002-…`).
|
||||||
confirmed live round-trip.**
|
2. ~~Exact `encryptObject` radix + framing~~ — **resolved live (2026-10-16,
|
||||||
|
see §5 and `tests/fixtures/*.bin`): radix 16, plaintext header first
|
||||||
|
(`S1 || transform(S2)`) both directions.** (An earlier radix-32 reading
|
||||||
|
of a nearby `0x20` literal is superseded.) The `decryptData`
|
||||||
|
`0x58`-marker model is kept for the unverified Bluetooth path only.
|
||||||
3. ~~Local HTTP port + TLS behavior for `https://IP:PORT`~~ — **revised (2026-10-13,
|
3. ~~Local HTTP port + TLS behavior for `https://IP:PORT`~~ — **revised (2026-10-13,
|
||||||
§8 empirical):** no HTTP/TLS port exists on the device at all (only 53 + 9000).
|
§8 empirical):** no HTTP/TLS port exists on the device at all (only 53 + 9000).
|
||||||
Either local mode is socket-only on this firmware, or the HTTP service is
|
Either local mode is socket-only on this firmware, or the HTTP service is
|
||||||
state-dependent and wasn't running. Revisit if a `/terminal` response is ever
|
state-dependent and wasn't running. Revisit if a `/terminal` response is ever
|
||||||
captured from the SoftAP.
|
captured from the SoftAP.
|
||||||
4. ~~Local socket port confirmation~~ — **partially resolved: `9000` open on
|
4. ~~Local socket round-trip~~ — **done (2026-10-16): full two-way RPC on
|
||||||
one unit** (§8 empirical); the `0x4650` constant is not listening there,
|
`10.100.1.1:9000`** (discovery → `1102` info, per-pack BMS `batSoc`,
|
||||||
so the port may be firmware-/device-dependent. Remaining: first confirmed
|
EMS telemetry). Remaining: the live endpoint's exact role for port 9000
|
||||||
RPC round-trip — the corrected request bytes (raw `S1 || transform(S2)`,
|
vs the app's `18000` constant (closed here), and the `1102 = req + 1`
|
||||||
radix 32, int `snno`, seconds `timeStamp`, true SN
|
reply-numbering rule across more commands.
|
||||||
`A03X2516013210060005`) are ready via
|
|
||||||
`battery --probe`; needs a host on `10.100.1.0/24`.
|
|
||||||
5. Which class in `classes5.dex` sets the `X-Device-*` headers and their exact values
|
5. Which class in `classes5.dex` sets the `X-Device-*` headers and their exact values
|
||||||
(string-pool location confirmed; class binding in progress — §13).
|
(string-pool location confirmed; class binding in progress — §13).
|
||||||
6. (Tooling, optional) Enable REA's DEX tools: obtain a **jadx-headless-mcp 0.7.1** JAR
|
6. (Tooling, optional) Enable REA's DEX tools: obtain a **jadx-headless-mcp 0.7.1** JAR
|
||||||
|
|||||||
@@ -125,6 +125,23 @@ def _soc_detail(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return detail
|
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(
|
def get_battery(
|
||||||
sn: str,
|
sn: str,
|
||||||
host: str = "10.100.1.1",
|
host: str = "10.100.1.1",
|
||||||
@@ -175,7 +192,8 @@ def get_battery(
|
|||||||
continue
|
continue
|
||||||
result = extract_soc(reply)
|
result = extract_soc(reply)
|
||||||
result.update({"cmd": str(cmd), "port": port, "reply": 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
|
return result
|
||||||
last_err = ValueError(f"reply on port {port} cmd {cmd} has no SoC field")
|
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
|
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
|
prefixes (LE16/BE16/LE32), an MQTT CONNECT, and a double-send over one
|
||||||
persistent connection. Each entry carries exact ``tx``/``rx`` bytes.
|
persistent connection. Each entry carries exact ``tx``/``rx`` bytes.
|
||||||
"""
|
"""
|
||||||
from .protocol import decrypt_data
|
from .protocol import decrypt_wire
|
||||||
|
|
||||||
results: List[Dict[str, Any]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
for port in ports:
|
for port in ports:
|
||||||
@@ -312,7 +330,10 @@ def probe_transport(
|
|||||||
except (ConnectionError, socket.error, TimeoutError) as exc:
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
entry["error"] = f"{type(exc).__name__}: {exc}"
|
entry["error"] = f"{type(exc).__name__}: {exc}"
|
||||||
if entry["rx"]:
|
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:
|
if decoded:
|
||||||
try:
|
try:
|
||||||
entry["reply"] = json.loads(decoded)
|
entry["reply"] = json.loads(decoded)
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
"""Local (direct-connect) device client.
|
"""Local (direct-connect) device client.
|
||||||
|
|
||||||
Talks to the aGate **on its own network** after joining the device hotspot —
|
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,
|
the direct-connect path of the app (see ``docs/RE_DART_FINDINGS.md`` §8,
|
||||||
§11). Two channels:
|
§11, §14). Two channels:
|
||||||
|
|
||||||
* **HTTP** to ``https://IP:PORT/terminal/...`` — the ``/terminal/*`` local
|
* **HTTP** to ``https://IP:PORT/terminal/...`` — the ``/terminal/*`` local
|
||||||
endpoint family (hotspot info, aGate info, overall info, MQTT relay, ...).
|
endpoint family (hotspot info, aGate info, overall info, MQTT relay, ...).
|
||||||
Scheme/port are *not* confirmed (Open item #3); defaults are candidates.
|
Scheme/port are *not* confirmed live (Open item #3); defaults follow the
|
||||||
* **TCP socket** to ``10.100.1.1:18000`` (candidate port — Open item #4)
|
app's ``RemoteService`` shape.
|
||||||
carrying the encrypted :class:`~franklinwh_agate_client.protocol.CommunicationObject`
|
* **TCP socket** to ``10.100.1.1:9000`` — verified live 2026-10-16. The
|
||||||
envelope with little-endian int16 length framing.
|
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
|
from __future__ import annotations
|
||||||
@@ -27,8 +32,6 @@ from .protocol import (
|
|||||||
CommunicationOperate,
|
CommunicationOperate,
|
||||||
SequenceNumber,
|
SequenceNumber,
|
||||||
SocketFrameDecoder,
|
SocketFrameDecoder,
|
||||||
decrypt_data,
|
|
||||||
decrypt_request,
|
|
||||||
decrypt_wire,
|
decrypt_wire,
|
||||||
direct_connect_request,
|
direct_connect_request,
|
||||||
encrypt_object,
|
encrypt_object,
|
||||||
@@ -182,7 +185,8 @@ class LocalDeviceClient:
|
|||||||
``increaseSN`` counter, serialised to compact JSON,
|
``increaseSN`` counter, serialised to compact JSON,
|
||||||
``encrypt_object``-ed, and sent **raw** (``_Socket.add``, no length
|
``encrypt_object``-ed, and sent **raw** (``_Socket.add``, no length
|
||||||
prefix). The reply is accumulated chunk by chunk and run through
|
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:
|
if envelope is None:
|
||||||
envelope = direct_connect_request(
|
envelope = direct_connect_request(
|
||||||
|
|||||||
@@ -212,10 +212,9 @@ def frame_socket_payload(payload: bytes) -> bytes:
|
|||||||
class SocketFrameDecoder:
|
class SocketFrameDecoder:
|
||||||
"""Accumulating socket receiver (``SocketManager._onSocketReceiveData``).
|
"""Accumulating socket receiver (``SocketManager._onSocketReceiveData``).
|
||||||
|
|
||||||
Each incoming chunk is appended to a growable buffer and ``decrypt_data``
|
Each incoming chunk is appended to a growable buffer and ``decrypt_wire``
|
||||||
is attempted over the whole buffer (inside try/catch in the app); a
|
is attempted over the whole buffer; a decoded string that parses as
|
||||||
non-empty decoded string that parses as JSON is one complete reply, at
|
JSON is one complete reply, at which point the buffer is cleared. Use :meth:`feed` for raw chunks and
|
||||||
which point the buffer is cleared. Use :meth:`feed` for raw chunks and
|
|
||||||
:meth:`feed_json` when you want parsed envelopes back.
|
:meth:`feed_json` when you want parsed envelopes back.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -59,3 +59,17 @@ class TestBmsSoc(unittest.TestCase):
|
|||||||
data={"id": 2})
|
data={"id": 2})
|
||||||
self.assertEqual(env.to_dict()["dataArea"], {"opt": 0, "id": 2})
|
self.assertEqual(env.to_dict()["dataArea"], {"opt": 0, "id": 2})
|
||||||
self.assertEqual(env.cmd_type, 1705)
|
self.assertEqual(env.cmd_type, 1705)
|
||||||
|
|
||||||
|
def test_empty_pack_slot_skipped(self):
|
||||||
|
from franklinwh_agate_client.battery import _is_empty_pack
|
||||||
|
reply = {"dataArea": {"opt": 0, "result": 0, "id": 0, "batSoc": 0,
|
||||||
|
"batSoh": 0, "batTotalVolt": 0}}
|
||||||
|
self.assertTrue(_is_empty_pack(
|
||||||
|
reply, {"value": 0.0, "source": "batSoc"}))
|
||||||
|
|
||||||
|
def test_genuine_zero_pack_kept(self):
|
||||||
|
from franklinwh_agate_client.battery import _is_empty_pack
|
||||||
|
reply = {"dataArea": {"opt": 0, "result": 0, "id": 1, "batSoc": 0,
|
||||||
|
"batSoh": 96, "batTotalVolt": 512}}
|
||||||
|
self.assertFalse(_is_empty_pack(
|
||||||
|
reply, {"value": 0.0, "source": "batSoc"}))
|
||||||
Reference in new issue
Block a user