EnvelopeCodec message layer; scripts are thin business-API wrappers

This commit is contained in:
kfj001 committed 2026-10-16 04:44:45 +00:00
1 parent f15ae42db7
commit 363c7cc6a2
14 files changed
+551 -900

No files matched your search

+6 -4
View File
@@ -69,10 +69,12 @@ python3 -m franklinwh_agate_client.battery \
--sn A03X2516013210060005 --host 10.100.1.1 --ports 9000,18000 --probe --sn A03X2516013210060005 --host 10.100.1.1 --ports 9000,18000 --probe
``` ```
> Prefer the standalone scripts in `scripts/` — they need no install and > Prefer the standalone scripts in `scripts/` — thin wrappers over the
> track the latest corrections: `host_probe4.py` is current (socket-space > library business API (run from the repo root, e.g.
> ids + `opt`-seeded payloads). Copy the file content and run > `python3 scripts/host_probe7.py --sn …`). Wire bytes always come from
> `python3 host_probe4.py --sn A03X2516013210060005 --ports 9000`. > `EnvelopeCodec` (`protocol/messages.py`); scripts never reimplement the
> cipher. Current: `host_probe6.py` (variants), `host_probe7.py` (BMS
> packs), `host_probe8.py` (wattage).
Expected: `secret key for ...: 1c (radix 32)`, then per-variant `tx` Expected: `secret key for ...: 1c (radix 32)`, then per-variant `tx`
(encrypted envelope, starts with the ASCII header (encrypted envelope, starts with the ASCII header
+8 -2
View File
@@ -188,8 +188,14 @@ marker model is kept for the unverified Bluetooth path.
failures keep accumulating ("receive incomplete data"). failures keep accumulating ("receive incomplete data").
So requests are `S1 || transform(S2)` (split at the computable header So requests are `S1 || transform(S2)` (split at the computable header
length) while responses are `s || 0x58 || transform(tail)` (split at the length); live socket replies use the identical framing and split
second `0x58` marker). Both halves share the one additive stream. (`decrypt_wire` locates the header at the 2nd comma — no ids needed up
front). Both halves share the one additive stream.
Library shape: `protocol/messages.py:EnvelopeCodec` owns the bytes
(`encode`/`decode` under one device key); `device_local`,
`battery` and `power` implement the business calls on top; `scripts/`
are thin CLI wrappers over those calls.
Live corroboration (2026-10-15, PCAPdroid capture of the working app): a Live corroboration (2026-10-15, PCAPdroid capture of the working app): a
request frame opens with the ASCII header request frame opens with the ASCII header
+7 -18
View File
@@ -258,12 +258,9 @@ def _recv_all(sock: socket.socket, idle: float = 4.0) -> bytes:
def _apower_tx(sn: str, key_radix: int) -> Tuple[bytes, Dict[str, Any]]: def _apower_tx(sn: str, key_radix: int) -> Tuple[bytes, Dict[str, Any]]:
"""Canonical ``queryAPowerData`` wire bytes (fresh snno/timeStamp).""" """Canonical ``queryAPowerData`` wire bytes (fresh snno/timeStamp)."""
from .protocol import ( from .protocol import direct_connect_request
direct_connect_request,
encrypt_object,
generate_secret_key,
)
from .protocol.envelope import SequenceNumber from .protocol.envelope import SequenceNumber
from .protocol.messages import EnvelopeCodec
_apower_tx_seq = getattr(_apower_tx, "_seq", None) _apower_tx_seq = getattr(_apower_tx, "_seq", None)
if _apower_tx_seq is None: if _apower_tx_seq is None:
@@ -272,9 +269,8 @@ def _apower_tx(sn: str, key_radix: int) -> Tuple[bytes, Dict[str, Any]]:
CommunicationCmd.DEVICE_APOWER_INFO, sn, CommunicationCmd.DEVICE_APOWER_INFO, sn,
data={"update": 2}, snno=_apower_tx_seq.next(), data={"update": 2}, snno=_apower_tx_seq.next(),
) )
wire = encrypt_object( codec = EnvelopeCodec.for_serial(sn, radix=key_radix)
env.to_json_string(), generate_secret_key(sn), radix=key_radix) return codec.encode(env), env.to_dict()
return wire, env.to_dict()
#: Minimal MQTT v3.1.1 CONNECT (client id ``probe01``) — rules out / confirms #: Minimal MQTT v3.1.1 CONNECT (client id ``probe01``) — rules out / confirms
@@ -297,7 +293,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_wire from .protocol.messages import EnvelopeCodec
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
for port in ports: for port in ports:
@@ -330,15 +326,8 @@ 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"]:
try: entry["reply"] = EnvelopeCodec.for_serial(
decoded = decrypt_wire(entry["rx"], radix=key_radix) sn, radix=key_radix).try_decode(entry["rx"])
except ValueError:
decoded = ""
if decoded:
try:
entry["reply"] = json.loads(decoded)
except ValueError:
entry["reply"] = {"_ undecodable": decoded}
results.append(entry) results.append(entry)
run("greet-first/read-then-raw", tx, pre_read=4.0) run("greet-first/read-then-raw", tx, pre_read=4.0)
+29 -27
View File
@@ -19,7 +19,6 @@ the direct-connect path of the app (see ``docs/RE_DART_FINDINGS.md`` §8,
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import socket import socket
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@@ -32,12 +31,11 @@ from .protocol import (
CommunicationOperate, CommunicationOperate,
SequenceNumber, SequenceNumber,
SocketFrameDecoder, SocketFrameDecoder,
decrypt_wire,
direct_connect_request, direct_connect_request,
encrypt_object,
frame_socket_payload, frame_socket_payload,
generate_secret_key, generate_secret_key,
) )
from .protocol.messages import EnvelopeCodec
from .wifi import DEVICE_GATEWAY_IP from .wifi import DEVICE_GATEWAY_IP
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -96,6 +94,7 @@ class LocalDeviceClient:
self.key_radix = int(key_radix) self.key_radix = int(key_radix)
self.timeout = timeout self.timeout = timeout
self.key = generate_secret_key(sn) self.key = generate_secret_key(sn)
self.codec = EnvelopeCodec(self.key, radix=self.key_radix)
self._sn_seq = SequenceNumber() self._sn_seq = SequenceNumber()
self._session = session or requests.Session() self._session = session or requests.Session()
self._session.headers.update( self._session.headers.update(
@@ -196,57 +195,60 @@ class LocalDeviceClient:
return self.send_envelope(envelope) return self.send_envelope(envelope)
def send_envelope(self, envelope: CommunicationObject) -> Dict[str, Any]: def send_envelope(self, envelope: CommunicationObject) -> Dict[str, Any]:
wire = encrypt_object(envelope.to_json_string(), self.key, raw = self._socket_roundtrip(
radix=self.key_radix) frame_socket_payload(self.codec.encode(envelope)))
raw = self._socket_roundtrip(frame_socket_payload(wire))
try: try:
reply = decrypt_wire(raw, radix=self.key_radix) reply = self.codec.decode(raw).to_dict()
except ValueError: except ValueError:
raise ConnectionError( raise ConnectionError(
f"undecodable reply ({len(raw)} bytes) from " f"undecodable reply ({len(raw)} bytes) from "
f"{self.socket_host}:{self.socket_port}" f"{self.socket_host}:{self.socket_port}"
) )
return json.loads(reply) return reply
def send_probe( def send_probe(
self, self,
cmd: CommunicationCmd, cmd: CommunicationCmd,
data: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None,
timeout: Optional[float] = None, timeout: Optional[float] = None,
equip_no: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Send one envelope, return exact TX/RX bytes plus any parsed reply. """Send one envelope, return exact TX/RX bytes plus any parsed reply.
Never raises on a device-level failure: ``reply`` is the parsed JSON Never raises on a device-level failure: ``reply`` is the parsed
(or ``None``), ``reply_raw`` the exact bytes received (``b""`` when envelope dict (or ``None``), ``reply_raw`` the exact bytes received
the device accepted the frame but answered with zero bytes). (``b""`` when the device accepted the frame but answered with zero
bytes). ``equip_no`` overrides the serial (``""`` reproduces the
app's empty-SN discovery frame).
""" """
envelope = direct_connect_request( envelope = direct_connect_request(
cmd, self.sn, data=data, snno=self._sn_seq.next(), cmd, self.sn if equip_no is None else equip_no, data=data,
snno=self._sn_seq.next(),
) )
wire = encrypt_object(envelope.to_json_string(), self.key, wire = self.codec.encode(envelope)
radix=self.key_radix)
reply_raw = self._socket_roundtrip( reply_raw = self._socket_roundtrip(
frame_socket_payload(wire), timeout=timeout, collect_only=True, frame_socket_payload(wire), timeout=timeout, collect_only=True,
) )
reply: Optional[Dict[str, Any]] = None
if reply_raw:
try:
decoded = decrypt_wire(reply_raw, radix=self.key_radix)
except ValueError:
decoded = ""
if decoded:
try:
reply = json.loads(decoded)
except ValueError:
reply = None
return { return {
"cmd": str(cmd), "cmd": str(cmd),
"envelope": envelope.to_dict(), "envelope": envelope.to_dict(),
"tx": wire, "tx": wire,
"reply_raw": reply_raw, "reply_raw": reply_raw,
"reply": reply, "reply": self.codec.try_decode(reply_raw),
} }
def send_raw(self, payload: bytes,
timeout: Optional[float] = None) -> bytes:
"""Low-level escape hatch: one connection, send *payload*, drain.
Used by transport-level probes (framing/handshake variants) that
are not valid envelopes. Returns the exact bytes received (``b""``
when the peer answers with nothing). Envelope traffic must use
:meth:`send_envelope` / :meth:`send_probe` instead.
"""
return self._socket_roundtrip(bytes(payload), timeout=timeout,
collect_only=True)
def _socket_roundtrip(self, framed: bytes, def _socket_roundtrip(self, framed: bytes,
timeout: Optional[float] = None, timeout: Optional[float] = None,
collect_only: bool = False) -> bytes: collect_only: bool = False) -> bytes:
@@ -278,7 +280,7 @@ class LocalDeviceClient:
if collect_only: if collect_only:
return bytes(received) return bytes(received)
try: try:
decrypt_wire(bytes(received), radix=self.key_radix) self.codec.decode(bytes(received))
except ValueError: except ValueError:
raise ConnectionError( raise ConnectionError(
f"no reply frame from {self.socket_host}:{self.socket_port}" f"no reply frame from {self.socket_host}:{self.socket_port}"
@@ -61,6 +61,7 @@ from .framing import (
verify_frame_crc, verify_frame_crc,
) )
from .framing import SocketFrameDecoder from .framing import SocketFrameDecoder
from .messages import EnvelopeCodec
__all__ = [ __all__ = [
"CommunicationCmd", "CommunicationCmd",
@@ -106,4 +107,5 @@ __all__ = [
"chunk_for_mtu", "chunk_for_mtu",
"frame_socket_payload", "frame_socket_payload",
"SocketFrameDecoder", "SocketFrameDecoder",
"EnvelopeCodec",
] ]
@@ -0,0 +1,64 @@
"""Low-level wire messages: envelopes to bytes and back.
:class:`EnvelopeCodec` owns everything byte-level about one device key:
serialising a :class:`CommunicationObject` to encrypted TX bytes and
parsing received RX bytes back into an envelope. Business logic (which
command/arguments to send, retries, SoC extraction) lives one layer up in
``device_local`` / ``battery`` / ``power``; standalone scripts must go
through those layers, never reimplement this.
"""
from __future__ import annotations
import json
from typing import Any, Dict
from .crypto import (
DEFAULT_KEY_RADIX,
decrypt_wire,
encrypt_object,
generate_secret_key,
)
from .envelope import CommunicationObject
class EnvelopeCodec:
"""Encrypt/decrypt envelopes under one device key.
:param key: ``generate_secret_key`` output for the device serial.
:param radix: key-parse radix (16 — verified against live captures).
"""
def __init__(self, key: str, radix: int = DEFAULT_KEY_RADIX) -> None:
self.key = str(key)
self.radix = int(radix)
@classmethod
def for_serial(cls, equip_no: str,
radix: int = DEFAULT_KEY_RADIX) -> "EnvelopeCodec":
"""Codec keyed by a device serial (derives ``generate_secret_key``)."""
return cls(generate_secret_key(equip_no), radix=radix)
# ---------------------------------------------------------------- encode
def encode(self, envelope: CommunicationObject) -> bytes:
"""``toJsonString`` → ``S1 || transform(S2)`` TX bytes."""
return encrypt_object(envelope.to_json_string(), self.key,
radix=self.radix)
# ---------------------------------------------------------------- decode
def decode(self, wire: bytes) -> CommunicationObject:
"""RX bytes → envelope (2nd-comma split, key from ``equipNo``).
Raises :class:`ValueError` on truncated/non-JSON/undecodable
input — callers accumulating a stream treat that as "keep
reading".
"""
return CommunicationObject.from_dict(
json.loads(decrypt_wire(bytes(wire), radix=self.radix)))
def try_decode(self, wire: bytes) -> Dict[str, Any] | None:
"""Like :meth:`decode` but returns the dict, or ``None``."""
try:
return self.decode(wire).to_dict()
except (ValueError, TypeError):
return None
+90 -97
View File
@@ -1,105 +1,64 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Standalone host probe — no package install, no git needed (stdlib only). """Host probe v2 — transport framing variants via the library.
Drop this file on the machine joined to the aGate SoftAP and run: Envelope bytes come from ``EnvelopeCodec``; only the on-the-wire framing
python3 host_probe2.py --sn A03X2516013210060005 varies (raw, length prefixes, split send, MQTT poke, greeting-first read).
Raw payloads go through ``LocalDeviceClient.send_raw``.
Replicates ``battery --probe2``: the canonical queryAPowerData envelope Run from the repo root on the SoftAP-joined host:
(verified byte-for-byte against the Dart AOT disassembly) sent with python3 scripts/host_probe2.py --sn A03X2516013210060005 --ports 9000
transport-level variants. Prints exact TX/RX hex for each.
""" """
import argparse import argparse
import json import json
import os
import socket import socket
import sys
import time import time
import zlib
SNNO = [0] # 1-based increaseSN counter (wraps at 9999) sys.path.insert(0, os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
from franklinwh_agate_client.device_local import ( # noqa: E402
LocalDeviceClient,
)
from franklinwh_agate_client.protocol import ( # noqa: E402
CommunicationCmd,
direct_connect_request,
generate_secret_key,
)
from franklinwh_agate_client.protocol.envelope import ( # noqa: E402
SequenceNumber,
)
from franklinwh_agate_client.protocol.messages import ( # noqa: E402
EnvelopeCodec,
)
#: Minimal MQTT v3.1.1 CONNECT (client id ``probe01``).
_MQTT_CONNECT = bytes.fromhex("101200044d5154540402003c000670726f62653031")
_seq = SequenceNumber()
def secret_key(sn): def canonical_tx(sn: str, codec: EnvelopeCodec) -> bytes:
return format(sum(sn.encode()) & 0xFF, "x") """App-shaped apower query bytes (fresh snno/timeStamp)."""
env = direct_connect_request(
CommunicationCmd.DEVICE_APOWER_INFO, sn,
data={"update": 2}, snno=_seq.next())
return codec.encode(env)
def build_envelope(sn, cmd=25, data=None, snno=None, ts=None): def recv_all_verbose(sock: socket.socket, idle: float = 4.0):
SNNO[0] = (SNNO[0] % 9999) + 1
env = {
"cmdType": cmd,
"equipNo": sn,
"type": 0,
"timeStamp": ts if ts is not None else int(time.time()),
"snno": snno if snno is not None else SNNO[0],
"len": 0,
"crc": "",
}
if data:
compact = json.dumps(data, separators=(",", ":"))
env["len"] = len(compact.encode())
env["crc"] = format(zlib.crc32(compact.encode()), "X")
env["dataArea"] = data
return json.dumps(env, separators=(",", ":"))
def encrypt(json_str, key):
parts = json_str.split(",")
a = (parts[0] + "," + parts[1] + ",").encode()
b = ",".join(parts[2:]).encode()
n = int(key, 32)
return a + bytes((b[i] + i + len(a) + n) & 0xFF
for i in range(len(b)))
def recv_all(sock, idle=4.0):
out = bytearray() out = bytearray()
sock.settimeout(idle) sock.settimeout(idle)
while True: while True:
try: try:
chunk = sock.recv(65536) chunk = sock.recv(65536)
except (socket.timeout, TimeoutError): except (socket.timeout, TimeoutError):
break return bytes(out), "idle-timeout"
if not chunk: if not chunk:
break return bytes(out), "closed-by-peer"
out.extend(chunk) out.extend(chunk)
return bytes(out)
MQTT_CONNECT = bytes.fromhex("101200044d5154540402003c000670726f62653031")
def run(label, host, port, tx, timeout, pre_read=0.0, post_idle=4.0,
split_send=False):
entry = (label, tx, b"", b"", None)
rx_pre, rx = b"", b""
err = None
try:
with socket.create_connection((host, port),
timeout=timeout) as sock:
if pre_read:
rx_pre = recv_all(sock, idle=pre_read)
if tx:
if split_send:
half = len(tx) // 2
sock.sendall(tx[:half])
try:
sock.settimeout(0.5)
rx_pre += sock.recv(65536)
except (socket.timeout, TimeoutError):
pass
sock.sendall(tx[half:])
else:
sock.sendall(tx)
rx = recv_all(sock, idle=post_idle)
except (ConnectionError, socket.error, TimeoutError) as exc:
err = "%s: %s" % (type(exc).__name__, exc)
print("--- %s -> %s:%d" % (label, host, port))
print(" tx (%d B): %s" % (len(tx), tx.hex() or "(none)"))
if rx_pre:
print(" rx_pre (%d B): %s" % (len(rx_pre), rx_pre.hex()))
print(" rx (%d B): %s" % (len(rx), rx.hex() or "(empty)"))
if err:
print(" error: %s" % err)
return rx
def main(): def main():
@@ -109,24 +68,58 @@ def main():
ap.add_argument("--ports", default="9000") ap.add_argument("--ports", default="9000")
ap.add_argument("--timeout", type=float, default=5.0) ap.add_argument("--timeout", type=float, default=5.0)
args = ap.parse_args() args = ap.parse_args()
codec = EnvelopeCodec.for_serial(args.sn)
key = secret_key(args.sn) print("secret key for %s: %s (radix 16)"
print("secret key for %s: %s (radix 32)" % (args.sn, key)) % (args.sn, generate_secret_key(args.sn)))
tx = encrypt(build_envelope(args.sn, data={"update": 2}), key)
for port in [int(p) for p in args.ports.split(",") if p.strip()]: for port in [int(p) for p in args.ports.split(",") if p.strip()]:
run("greet-first/read-then-raw", args.host, port, tx, client = LocalDeviceClient(args.sn, host=args.host,
args.timeout, pre_read=4.0) socket_port=port, timeout=args.timeout)
run("raw/long-read", args.host, port, tx, args.timeout, tx = canonical_tx(args.sn, codec)
post_idle=8.0)
run("raw/split-send", args.host, port, tx, args.timeout, def show(label, tx_bytes, rx, how=""):
split_send=True) print("--- %s tx=%dB rx=%dB %s"
run("le16-prefix", args.host, port, % (label, len(tx_bytes), len(rx), how))
len(tx).to_bytes(2, "little") + tx, args.timeout) if rx:
run("be16-prefix", args.host, port, print(" rx: %s" % rx.hex()[:400])
len(tx).to_bytes(2, "big") + tx, args.timeout)
run("le32-prefix", args.host, port, # greeting-first: connect, read before sending, then send
len(tx).to_bytes(4, "little") + tx, args.timeout) try:
run("mqtt-connect", args.host, port, MQTT_CONNECT, args.timeout) with socket.create_connection(
(args.host, port), timeout=args.timeout) as sock:
pre, _ = recv_all_verbose(sock, idle=4.0)
sock.sendall(tx)
rx, how = recv_all_verbose(sock, idle=8.0)
show("greet-first/read-then-raw", tx, rx,
"pre=%dB %s" % (len(pre), how))
except (ConnectionError, socket.error, TimeoutError) as exc:
print("--- greet-first: error: %s" % exc)
for label, payload in (
("raw/long-read", tx),
("le16-prefix", len(tx).to_bytes(2, "little") + tx),
("be16-prefix", len(tx).to_bytes(2, "big") + tx),
("le32-prefix", len(tx).to_bytes(4, "little") + tx),
("mqtt-connect", _MQTT_CONNECT),
):
try:
t0 = time.time()
rx = client.send_raw(payload, timeout=args.timeout)
show(label, payload, rx, "%.1fs" % (time.time() - t0))
except (ConnectionError, socket.error, TimeoutError) as exc:
print("--- %s: error: %s" % (label, exc))
# split send over one connection
try:
with socket.create_connection(
(args.host, port), timeout=args.timeout) as sock:
half = len(tx) // 2
sock.sendall(tx[:half])
time.sleep(0.5)
sock.sendall(tx[half:])
rx, how = recv_all_verbose(sock)
show("raw/split-send", tx, rx, how)
except (ConnectionError, socket.error, TimeoutError) as exc:
print("--- split-send: error: %s" % exc)
if __name__ == "__main__": if __name__ == "__main__":
+52 -119
View File
@@ -1,114 +1,45 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Host probe v3 — socket-transport command ids (stdlib only). """Host probe v3 — socket-id variants via the library business API.
A PCAPdroid capture of the working app showed ``{"cmdType":1101, ...}`` on Historical sweep that established the socket-transport command space
the wire: ``CommunicationCmd::cmd`` returns the *second* id field for (``1101/1307/1705/1707`` instead of the ``off_8`` ids). Superseded by
socket mode (QUERY_DEVICE_INFO 1101, APOWER 1307, BMS 1705, EMS 1707). ``host_probe6.py`` for routine use; kept as a variant-list driver over
Earlier probes used the small ids (25/0/29/30) and were dropped silently. ``LocalDeviceClient.send_probe``.
Run on the SoftAP-joined host: Run from the repo root on the SoftAP-joined host:
python3 host_probe3.py --sn A03X2516013210060005 --ports 9000 python3 scripts/host_probe3.py --sn A03X2516013210060005 --ports 9000
""" """
import argparse import argparse
import json import json
import os
import socket import socket
import time import sys
import zlib
# Wire cmdType per CommunicationCmd::cmd (socket mode). sys.path.insert(0, os.path.dirname(os.path.dirname(
APOWER, QUERY_INFO, BMS, EMS = 0x51B, 0x44D, 0x6A9, 0x6AB os.path.abspath(__file__))))
SNNO = [0]
from franklinwh_agate_client.battery import extract_soc # noqa: E402
from franklinwh_agate_client.device_local import ( # noqa: E402
LocalDeviceClient,
)
from franklinwh_agate_client.protocol import ( # noqa: E402
CommunicationCmd,
generate_secret_key,
)
def secret_key(sn): VARIANTS = (
return format(sum(sn.encode()) & 0xFF, "x") # (label, cmd, data, equip_no override or None for the client SN)
("queryInfo/1101+SN", CommunicationCmd.QUERY_DEVICE_INFO,
{"minProtocolVer": "V1.00.00"}, None),
def build_envelope(sn, cmd, data=None, snno=None, ts=None, equip=None): ("queryInfo/1101+empty", CommunicationCmd.QUERY_DEVICE_INFO,
SNNO[0] = (SNNO[0] % 9999) + 1 {"minProtocolVer": "V1.00.00"}, ""),
env = {"cmdType": cmd, "equipNo": equip if equip is not None else sn, ("apower/1307+update2", CommunicationCmd.DEVICE_APOWER_INFO,
"type": 0, {"update": 2}, None),
"timeStamp": ts if ts is not None else int(time.time()), ("apower/1307+none", CommunicationCmd.DEVICE_APOWER_INFO, None, None),
"snno": snno if snno is not None else SNNO[0], "len": 0, "crc": ""} ("bms/1705", CommunicationCmd.BMS, None, None),
if data: ("ems/1707", CommunicationCmd.EMS, None, None),
compact = json.dumps(data, separators=(",", ":")) )
env["len"] = len(compact.encode())
env["crc"] = format(zlib.crc32(compact.encode()), "X")
env["dataArea"] = data
return json.dumps(env, separators=(",", ":"))
def encrypt(json_str, key):
parts = json_str.split(",")
a = (parts[0] + "," + parts[1] + ",").encode()
b = ",".join(parts[2:]).encode()
n = int(key, 32)
return a + bytes((b[i] + i + len(a) + n) & 0xFF
for i in range(len(b)))
def decrypt_data(wire, radix=32):
try:
i = wire.find(b"\x58")
if i < 0:
return ""
j = wire.find(b"\x58", i + 1)
if j < 0:
return ""
s = wire[:j].decode()
probe = json.loads(s + "}")
if not isinstance(probe, dict):
return ""
n = int(secret_key(str(probe.get("equipNo", ""))), radix)
prefix = s + ","
tail = wire[j + 1:]
pl = len(prefix.encode())
plain = bytes((t - k - pl - n) & 0xFF for k, t in enumerate(tail))
return prefix + plain.decode()
except Exception:
return ""
def recv_all(sock, idle=6.0):
out = bytearray()
sock.settimeout(idle)
while True:
try:
chunk = sock.recv(65536)
except (socket.timeout, TimeoutError):
break
if not chunk:
break
out.extend(chunk)
return bytes(out)
def run(label, host, port, tx, timeout, pre_read=0.0):
rx_pre, rx, err, reply = b"", b"", None, None
try:
with socket.create_connection((host, port),
timeout=timeout) as sock:
if pre_read:
rx_pre = recv_all(sock, idle=pre_read)
if tx:
sock.sendall(tx)
rx = recv_all(sock)
if rx:
dec = decrypt_data(rx)
reply = json.loads(dec) if dec else {"_raw": rx.hex()}
except (ConnectionError, socket.error, TimeoutError) as exc:
err = "%s: %s" % (type(exc).__name__, exc)
print("--- %s -> %s:%d" % (label, host, port))
print(" tx (%d B): %s" % (len(tx), tx.hex() or "(none)"))
if rx_pre:
print(" rx_pre (%d B): %s" % (len(rx_pre), rx_pre.hex()))
print(" rx (%d B): %s" % (len(rx), rx.hex() or "(empty)"))
if reply is not None:
print(" reply: " + json.dumps(reply, default=str)[:2000])
if err:
print(" error: %s" % err)
return rx
def main(): def main():
@@ -118,26 +49,28 @@ def main():
ap.add_argument("--ports", default="9000") ap.add_argument("--ports", default="9000")
ap.add_argument("--timeout", type=float, default=5.0) ap.add_argument("--timeout", type=float, default=5.0)
args = ap.parse_args() args = ap.parse_args()
key = secret_key(args.sn) print("secret key for %s: %s (radix 16)"
print("secret key for %s: %s (radix 32)" % (args.sn, key)) % (args.sn, generate_secret_key(args.sn)))
variants = [
("queryInfo/1101+SN", QUERY_INFO, None, None),
("queryInfo/1101+empty-SN", QUERY_INFO, None, ""),
("apower/1307+update2", APOWER, {"update": 2}, None),
("apower/1307+none", APOWER, None, None),
("bms/1705", BMS, None, None),
("ems/1707", EMS, None, None),
]
for port in [int(p) for p in args.ports.split(",") if p.strip()]: for port in [int(p) for p in args.ports.split(",") if p.strip()]:
for label, cmd, data, equip in variants: client = LocalDeviceClient(args.sn, host=args.host,
tx = encrypt(build_envelope( socket_port=port, timeout=args.timeout)
args.sn, cmd, data, for label, cmd, data, equip in VARIANTS:
equip=args.sn if equip is None else equip or "00000000", try:
), key) out = client.send_probe(cmd, data=data, timeout=args.timeout,
if run(label, args.host, port, tx, args.timeout, equip_no=equip)
pre_read=4.0 if label.startswith("queryInfo/1101+SN") except (ConnectionError, socket.error, TimeoutError) as exc:
else 0.0): print("--- %s: error: %s: %s"
return % (label, type(exc).__name__, exc))
continue
rx = out.get("reply_raw") or b""
print("--- %s tx=%dB rx=%dB"
% (label, len(out["tx"]), len(rx)))
if out.get("reply") is not None:
print(" reply: "
+ json.dumps(out["reply"], default=str)[:1500])
soc = extract_soc(out["reply"])
print(" soc: %s (source=%s)" % (soc["value"],
soc["source"]))
if __name__ == "__main__": if __name__ == "__main__":
+50 -122
View File
@@ -1,117 +1,43 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Host probe v4 — socket ids + ``opt``-seeded payloads (stdlib only). """Host probe v4 — ``opt``-seeded payloads via the library business API.
Two corrections vs v3, both from disassembly + live capture: Historical sweep from the ``communicate``-factory finding (socket payloads
* wire ``cmdType`` is the socket id (1101/1307/1705/1707); always start with ``"opt": 0``). Now built into
* the ``communicate`` factory seeds every socket payload with ``direct_connect_request``; kept as a variant-list driver over
``"opt": 0`` first (operation type), then merges arguments, e.g. ``LocalDeviceClient.send_probe``.
apower → ``{"opt": 0, "update": 2}``; queryDeviceInfo →
``{"opt": 0, "minProtocolVer": "V1.00.00"}``.
Run on the SoftAP-joined host: Run from the repo root on the SoftAP-joined host:
python3 host_probe4.py --sn A03X2516013210060005 --ports 9000 python3 scripts/host_probe4.py --sn A03X2516013210060005 --ports 9000
""" """
import argparse import argparse
import json import json
import os
import socket import socket
import time import sys
import zlib
APOWER, QUERY_INFO, BMS, EMS = 0x51B, 0x44D, 0x6A9, 0x6AB sys.path.insert(0, os.path.dirname(os.path.dirname(
SNNO = [0] os.path.abspath(__file__))))
from franklinwh_agate_client.battery import extract_soc # noqa: E402
from franklinwh_agate_client.device_local import ( # noqa: E402
LocalDeviceClient,
)
from franklinwh_agate_client.protocol import ( # noqa: E402
CommunicationCmd,
generate_secret_key,
)
def secret_key(sn): VARIANTS = (
return format(sum(sn.encode()) & 0xFF, "x") ("queryInfo+minProto+SN", CommunicationCmd.QUERY_DEVICE_INFO,
{"minProtocolVer": "V1.00.00"}, None),
("queryInfo+minProto+empty", CommunicationCmd.QUERY_DEVICE_INFO,
def build_envelope(sn, cmd, args=None, snno=None, ts=None, equip=None): {"minProtocolVer": "V1.00.00"}, ""),
SNNO[0] = (SNNO[0] % 9999) + 1 ("apower+opt+update2", CommunicationCmd.DEVICE_APOWER_INFO,
data = {"opt": 0} {"update": 2}, None),
if args: ("bms+opt", CommunicationCmd.BMS, None, None),
data.update(args) ("ems+opt", CommunicationCmd.EMS, None, None),
compact = json.dumps(data, separators=(",", ":")) )
env = {"cmdType": cmd, "equipNo": equip if equip is not None else sn,
"type": 0,
"timeStamp": ts if ts is not None else int(time.time()),
"snno": snno if snno is not None else SNNO[0],
"len": len(compact.encode()),
"crc": format(zlib.crc32(compact.encode()), "X"),
"dataArea": data}
return json.dumps(env, separators=(",", ":"))
def encrypt(json_str, key):
parts = json_str.split(",")
a = (parts[0] + "," + parts[1] + ",").encode()
b = ",".join(parts[2:]).encode()
n = int(key, 32)
return a + bytes((b[i] + i + len(a) + n) & 0xFF
for i in range(len(b)))
def decrypt_data(wire, radix=32):
try:
i = wire.find(b"\x58")
if i < 0:
return ""
j = wire.find(b"\x58", i + 1)
if j < 0:
return ""
s = wire[:j].decode()
probe = json.loads(s + "}")
if not isinstance(probe, dict):
return ""
n = int(secret_key(str(probe.get("equipNo", ""))), radix)
prefix = s + ","
tail = wire[j + 1:]
pl = len(prefix.encode())
return prefix + bytes((t - k - pl - n) & 0xFF
for k, t in enumerate(tail)).decode()
except Exception:
return ""
def recv_all(sock, idle=8.0):
out = bytearray()
sock.settimeout(idle)
while True:
try:
chunk = sock.recv(65536)
except (socket.timeout, TimeoutError):
break
if not chunk:
break
out.extend(chunk)
return bytes(out)
def run(label, host, port, tx, timeout, pre_read=0.0):
rx_pre, rx, err, reply = b"", b"", None, None
try:
with socket.create_connection((host, port),
timeout=timeout) as sock:
if pre_read:
rx_pre = recv_all(sock, idle=pre_read)
if tx:
sock.sendall(tx)
rx = recv_all(sock)
if rx:
dec = decrypt_data(rx)
reply = json.loads(dec) if dec else {"_raw": rx.hex()}
except (ConnectionError, socket.error, TimeoutError) as exc:
err = "%s: %s" % (type(exc).__name__, exc)
print("--- %s -> %s:%d" % (label, host, port))
print(" tx (%d B): %s" % (len(tx), tx.hex() or "(none)"))
if rx_pre:
print(" rx_pre (%d B): %s" % (len(rx_pre), rx_pre.hex()))
print(" rx (%d B): %s" % (len(rx), rx.hex() or "(empty)"))
if reply is not None:
print(" reply: " + json.dumps(reply, default=str)[:3000])
if err:
print(" error: %s" % err)
return rx
def main(): def main():
@@ -121,26 +47,28 @@ def main():
ap.add_argument("--ports", default="9000") ap.add_argument("--ports", default="9000")
ap.add_argument("--timeout", type=float, default=5.0) ap.add_argument("--timeout", type=float, default=5.0)
args = ap.parse_args() args = ap.parse_args()
key = secret_key(args.sn) print("secret key for %s: %s (radix 16)"
print("secret key for %s: %s (radix 32)" % (args.sn, key)) % (args.sn, generate_secret_key(args.sn)))
variants = [
("queryInfo+minProto+SN", QUERY_INFO, {"minProtocolVer": "V1.00.00"},
None),
("queryInfo+minProto+empty", QUERY_INFO, {"minProtocolVer": "V1.00.00"},
""),
("apower+opt+update2", APOWER, {"update": 2}, None),
("bms+opt", BMS, None, None),
("ems+opt", EMS, None, None),
]
for port in [int(p) for p in args.ports.split(",") if p.strip()]: for port in [int(p) for p in args.ports.split(",") if p.strip()]:
for label, cmd, data, equip in variants: client = LocalDeviceClient(args.sn, host=args.host,
tx = encrypt(build_envelope( socket_port=port, timeout=args.timeout)
args.sn, cmd, data, for label, cmd, data, equip in VARIANTS:
equip=args.sn if equip is None else equip or "00000000", try:
), key) out = client.send_probe(cmd, data=data, timeout=args.timeout,
if run(label, args.host, port, tx, args.timeout, equip_no=equip)
pre_read=4.0 if "empty" not in label else 0.0): except (ConnectionError, socket.error, TimeoutError) as exc:
return print("--- %s: error: %s: %s"
% (label, type(exc).__name__, exc))
continue
rx = out.get("reply_raw") or b""
print("--- %s tx=%dB rx=%dB"
% (label, len(out["tx"]), len(rx)))
if out.get("reply") is not None:
print(" reply: "
+ json.dumps(out["reply"], default=str)[:1500])
soc = extract_soc(out["reply"])
print(" soc: %s (source=%s)" % (soc["value"],
soc["source"]))
if __name__ == "__main__": if __name__ == "__main__":
+91 -108
View File
@@ -1,59 +1,52 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Host probe v5 — session behaviour on 9000 (stdlib only). """Host probe v5 — session sequencing via the library.
v4 (correct ids + opt payloads) is still met with silence, so this probe One persistent connection carrying discovery → directed queries with
tests *session* hypotheses instead of bytes: app-like sequential ``snno``, plus close-vs-timeout reporting and
* persistent connection: discovery → directed queries on ONE socket, with high-snno opens. Payload bytes come from ``direct_connect_request`` +
app-like sequential snno (the app reuses its SocketManager connection; ``EnvelopeCodec``; only the connection orchestration lives here.
the field captures show a discovery frame minutes before directed ones);
* close-vs-timeout distinction for every read (immediate close = active Run from the repo root on the SoftAP-joined host:
reject; 8 s idle + open conn = device thinking/forwarding upstream); python3 scripts/host_probe5.py --sn A03X2516013210060005 --ports 9000
* high-snno opens (in case the device tracks sequence per sender).
""" """
import argparse import argparse
import json import json
import os
import socket import socket
import sys
import time import time
import zlib
APOWER, QUERY_INFO, BMS = 0x51B, 0x44D, 0x6A9 sys.path.insert(0, os.path.dirname(os.path.dirname(
SNNO = [100] os.path.abspath(__file__))))
from franklinwh_agate_client.device_local import ( # noqa: E402
LocalDeviceClient,
)
from franklinwh_agate_client.protocol import ( # noqa: E402
CommunicationCmd,
direct_connect_request,
generate_secret_key,
)
from franklinwh_agate_client.protocol.envelope import ( # noqa: E402
SequenceNumber,
)
from franklinwh_agate_client.protocol.messages import ( # noqa: E402
EnvelopeCodec,
)
_seq = SequenceNumber(start=100)
def secret_key(sn): def tx_for(codec: EnvelopeCodec, sn: str, cmd: CommunicationCmd,
return format(sum(sn.encode()) & 0xFF, "x") data, equip=None, snno=None) -> bytes:
env = direct_connect_request(
cmd, sn if equip is None else equip, data=data,
snno=_seq.next() if snno is None else snno)
return codec.encode(env)
def build_envelope(sn, cmd, args=None, snno=None, ts=None, equip=None): def recv_all_verbose(sock: socket.socket, idle: float = 8.0):
if snno is None:
SNNO[0] = (SNNO[0] % 9999) + 1
snno = SNNO[0]
data = {"opt": 0}
if args:
data.update(args)
compact = json.dumps(data, separators=(",", ":"))
env = {"cmdType": cmd, "equipNo": equip if equip is not None else sn,
"type": 0,
"timeStamp": ts if ts is not None else int(time.time()),
"snno": snno,
"len": len(compact.encode()),
"crc": format(zlib.crc32(compact.encode()), "X"),
"dataArea": data}
return json.dumps(env, separators=(",", ":"))
def encrypt(json_str, key):
parts = json_str.split(",")
a = (parts[0] + "," + parts[1] + ",").encode()
b = ",".join(parts[2:]).encode()
n = int(key, 32)
return a + bytes((b[i] + i + len(a) + n) & 0xFF
for i in range(len(b)))
def recv_all_verbose(sock, idle=8.0):
"""Return (bytes, how) where how is closed|idle-timeout."""
out = bytearray() out = bytearray()
sock.settimeout(idle) sock.settimeout(idle)
while True: while True:
@@ -66,47 +59,6 @@ def recv_all_verbose(sock, idle=8.0):
out.extend(chunk) out.extend(chunk)
def one_shot(label, host, port, tx, timeout, snno):
try:
with socket.create_connection((host, port),
timeout=timeout) as sock:
t0 = time.time()
sock.sendall(tx)
rx, how = recv_all_verbose(sock)
dt = time.time() - t0
except (ConnectionError, socket.error, TimeoutError) as exc:
print("--- %s snno=%d -> %s:%d" % (label, snno, host, port))
print(" tx (%d B): %s" % (len(tx), tx.hex()))
print(" error: %s: %s" % (type(exc).__name__, exc))
return b""
print("--- %s snno=%d -> %s:%d (%.1fs, %s)" % (
label, snno, host, port, dt, how))
print(" tx (%d B): %s" % (len(tx), tx.hex()))
print(" rx (%d B): %s" % (len(rx), rx.hex() or "(empty)"))
return rx
def session(label, host, port, frames, timeout):
print("=== %s -> %s:%d (one connection)" % (label, host, port))
try:
with socket.create_connection((host, port),
timeout=timeout) as sock:
for name, tx in frames:
t0 = time.time()
sock.sendall(tx)
rx, how = recv_all_verbose(sock)
dt = time.time() - t0
print("--- %s tx=%dB rx=%dB (%.1fs, %s)" % (
name, len(tx), len(rx), dt, how))
if rx:
print(" rx: %s" % rx.hex()[:3000])
if how == "closed-by-peer" and not rx:
print(" (peer closed; stopping session)")
break
except (ConnectionError, socket.error, TimeoutError) as exc:
print(" error: %s: %s" % (type(exc).__name__, exc))
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--sn", required=True) ap.add_argument("--sn", required=True)
@@ -114,32 +66,63 @@ def main():
ap.add_argument("--ports", default="9000") ap.add_argument("--ports", default="9000")
ap.add_argument("--timeout", type=float, default=5.0) ap.add_argument("--timeout", type=float, default=5.0)
args = ap.parse_args() args = ap.parse_args()
key = secret_key(args.sn) codec = EnvelopeCodec.for_serial(args.sn)
print("secret key for %s: %s (radix 32)" % (args.sn, key)) print("secret key for %s: %s (radix 16)"
% (args.sn, generate_secret_key(args.sn)))
def tx(cmd, data, equip=None, snno=None):
return encrypt(build_envelope(
args.sn, cmd, data, snno=snno,
equip=args.sn if equip is None else equip or "00000000"), key)
ports = [int(p) for p in args.ports.split(",") if p.strip()] ports = [int(p) for p in args.ports.split(",") if p.strip()]
for port in ports: for port in ports:
# A. persistent session: discovery first, then directed (app order) print("=== discovery-then-directed -> %s:%d (one connection)"
session("discovery-then-directed", args.host, port, [ % (args.host, port))
("disc/1101+empty", try:
tx(QUERY_INFO, {"minProtocolVer": "V1.00.00"}, equip="")), with socket.create_connection(
("info/1101+SN", (args.host, port), timeout=args.timeout) as sock:
tx(QUERY_INFO, {"minProtocolVer": "V1.00.00"})), for name, cmd, data, equip in (
("apower/1307", ("disc/1101+empty", CommunicationCmd.QUERY_DEVICE_INFO,
tx(APOWER, {"update": 2})), {"minProtocolVer": "V1.00.00"}, ""),
("bms/1705", tx(BMS, None)), ("info/1101+SN", CommunicationCmd.QUERY_DEVICE_INFO,
], args.timeout) {"minProtocolVer": "V1.00.00"}, None),
# B. high-snno fresh opens ("apower/1307", CommunicationCmd.DEVICE_APOWER_INFO,
one_shot("apower-high-snno", args.host, port, {"update": 2}, None),
tx(APOWER, {"update": 2}, snno=500), args.timeout, 500) ("bms/1705", CommunicationCmd.BMS, {"id": 1}, None),
one_shot("info-high-snno", args.host, port, ):
tx(QUERY_INFO, {"minProtocolVer": "V1.00.00"}, snno=501), payload = tx_for(codec, args.sn, cmd, data, equip)
args.timeout, 501) t0 = time.time()
sock.sendall(payload)
rx, how = recv_all_verbose(sock)
print("--- %s tx=%dB rx=%dB (%.1fs, %s)"
% (name, len(payload), len(rx),
time.time() - t0, how))
if rx:
print(" rx: %s" % rx.hex()[:400])
parsed = codec.try_decode(rx)
if parsed is not None:
print(" reply: " + json.dumps(
parsed, default=str)[:800])
if how == "closed-by-peer" and not rx:
print(" (peer closed; stopping session)")
break
except (ConnectionError, socket.error, TimeoutError) as exc:
print(" error: %s: %s" % (type(exc).__name__, exc))
client = LocalDeviceClient(args.sn, host=args.host,
socket_port=port, timeout=args.timeout)
for label, cmd, data, snno in (
("apower-high-snno", CommunicationCmd.DEVICE_APOWER_INFO,
{"update": 2}, 500),
("info-high-snno", CommunicationCmd.QUERY_DEVICE_INFO,
{"minProtocolVer": "V1.00.00"}, 501),
):
env = direct_connect_request(cmd, args.sn, data=data, snno=snno)
payload = codec.encode(env)
try:
rx = client.send_raw(payload, timeout=args.timeout)
except (ConnectionError, socket.error, TimeoutError) as exc:
print("--- %s: error: %s" % (label, exc))
continue
print("--- %s tx=%dB rx=%dB" % (label, len(payload), len(rx)))
parsed = codec.try_decode(rx)
if parsed is not None:
print(" reply: " + json.dumps(parsed, default=str)[:800])
if __name__ == "__main__": if __name__ == "__main__":
+45 -173
View File
@@ -1,194 +1,66 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Host probe v6 — verified-against-capture wire format (stdlib only). """Host probe v6 — envelope variants via the library business API.
Locked by two live frames (tests/fixtures/*.bin in the repo): Sends the ``battery.PROBE_VARIANTS`` envelopes through
* key radix 16 (``int("80",16)=128``, ``int("1c",16)=28``); ``LocalDeviceClient.send_probe`` (sequence numbers, ``opt``-seeded
* ``S1 || transform(S2)`` both directions; reply id = request id + 1 payloads, socket ids and cipher all come from the library) and prints
observed (1101 -> 1102); exact TX/RX bytes plus any parsed reply/SoC.
* socket payloads seeded with ``"opt": 0``;
* replies parsed by splitting at the 2nd comma (no ids needed up front).
Run on the SoftAP-joined host: Run from the repo root on the SoftAP-joined host:
python3 host_probe6.py --sn A03X2516013210060005 --ports 9000 python3 scripts/host_probe6.py --sn A03X2516013210060005 --ports 9000
""" """
import argparse import argparse
import json import json
import os
import socket import socket
import time import sys
import zlib
APOWER, QUERY_INFO, BMS, EMS = 0x51B, 0x44D, 0x6A9, 0x6AB sys.path.insert(0, os.path.dirname(os.path.dirname(
SNNO = [0] os.path.abspath(__file__))))
SOC_KEYS = ("currentBatterySoc", "soc", "batterySoc", "vppSoc")
from franklinwh_agate_client.battery import ( # noqa: E402
def secret_key(sn): extract_soc,
return format(sum(sn.encode()) & 0xFF, "x") probe_device,
)
from franklinwh_agate_client.protocol import ( # noqa: E402
def build_envelope(sn, cmd, args=None, snno=None, ts=None, equip=None): generate_secret_key,
if snno is None: )
SNNO[0] = (SNNO[0] % 9999) + 1
snno = SNNO[0]
data = {"opt": 0}
if args:
data.update(args)
compact = json.dumps(data, separators=(",", ":"))
env = {"cmdType": cmd, "equipNo": equip if equip is not None else sn,
"type": 0,
"timeStamp": ts if ts is not None else int(time.time()),
"snno": snno,
"len": len(compact.encode()),
"crc": format(zlib.crc32(compact.encode()), "X"),
"dataArea": data}
return json.dumps(env, separators=(",", ":"))
def encrypt(json_str, key):
parts = json_str.split(",")
a = (parts[0] + "," + parts[1] + ",").encode()
b = ",".join(parts[2:]).encode()
n = int(key, 16)
return a + bytes((b[i] + i + len(a) + n) & 0xFF
for i in range(len(b)))
def decrypt_wire(wire):
first = wire.find(b",")
second = wire.find(b",", first + 1)
if first < 0 or second < 0:
return ""
a, b = wire[:second + 1], wire[second + 1:]
try:
probe = json.loads(a[:-1].decode() + "}")
except Exception:
return ""
if not isinstance(probe, dict):
return ""
n = int(secret_key(str(probe.get("equipNo", ""))), 16)
try:
return (a + bytes((t - i - len(a) - n) & 0xFF
for i, t in enumerate(b))).decode()
except Exception:
return ""
def recv_all(sock, idle=8.0):
out = bytearray()
sock.settimeout(idle)
while True:
try:
chunk = sock.recv(65536)
except (socket.timeout, TimeoutError):
break
if not chunk:
break
out.extend(chunk)
return bytes(out)
def walk(obj, key, found):
if isinstance(obj, dict):
for k, v in obj.items():
if k == key:
found.append(v)
walk(v, key, found)
elif isinstance(obj, list):
for item in obj:
walk(item, key, found)
return found
def extract_soc(reply):
data = reply.get("dataArea", reply)
if isinstance(data, str):
try:
data = json.loads(data)
except ValueError:
data = {}
for key in SOC_KEYS:
hits = walk(reply, key, [])
for hit in hits:
if isinstance(hit, bool):
continue
try:
return float(hit), key
except (TypeError, ValueError):
continue
arrs = walk(data if isinstance(data, dict) else {}, "socArray", [])
for arr in arrs:
if isinstance(arr, list) and arr:
try:
nums = [float(v) for v in arr
if not isinstance(v, bool)]
if nums:
return sum(nums) / len(nums), "socArray(avg)"
except (TypeError, ValueError):
continue
return None, None
def run(label, host, port, tx, timeout, pre_read=0.0):
rx_pre, rx, err, reply = b"", b"", None, None
try:
with socket.create_connection((host, port),
timeout=timeout) as sock:
if pre_read:
rx_pre = recv_all(sock, idle=pre_read)
if tx:
sock.sendall(tx)
rx = recv_all(sock)
if rx:
dec = decrypt_wire(rx)
reply = json.loads(dec) if dec else {"_raw": rx.hex()}
except (ConnectionError, socket.error, TimeoutError) as exc:
err = "%s: %s" % (type(exc).__name__, exc)
print("--- %s -> %s:%d" % (label, host, port))
print(" tx (%d B): %s" % (len(tx), tx.hex() or "(none)"))
if rx_pre:
print(" rx_pre (%d B): %s" % (len(rx_pre), rx_pre.hex()))
print(" rx (%d B): %s" % (len(rx), rx.hex()[:400] or "(empty)"))
if reply is not None:
print(" reply: " + json.dumps(reply, default=str)[:3000])
if isinstance(reply, dict):
val, src = extract_soc(reply)
print(" soc: %s (source=%s)" % (val, src))
if err:
print(" error: %s" % err)
return rx
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--sn", required=True) ap.add_argument("--sn", required=True)
ap.add_argument("--host", default="10.100.1.1") ap.add_argument("--host", default="10.100.1.1")
ap.add_argument("--ports", default="9000") ap.add_argument("--ports", default="9000,18000")
ap.add_argument("--timeout", type=float, default=5.0) ap.add_argument("--timeout", type=float, default=5.0)
args = ap.parse_args() args = ap.parse_args()
key = secret_key(args.sn) print("secret key for %s: %s (radix 16)"
print("secret key for %s: %s (radix 16)" % (args.sn, key)) % (args.sn, generate_secret_key(args.sn)))
variants = [ ports = tuple(int(p) for p in args.ports.split(",") if p.strip())
("discovery/1101+empty", QUERY_INFO, try:
{"minProtocolVer": "V1.00.00"}, ""), results = probe_device(args.sn, host=args.host, ports=ports,
("queryInfo/1101+SN", QUERY_INFO, timeout=args.timeout)
{"minProtocolVer": "V1.00.00"}, None), except (ConnectionError, socket.error, TimeoutError) as exc:
("apower/1307+SN", APOWER, {"update": 2}, None), print("ERROR: %s: %s" % (type(exc).__name__, exc))
("apower/1307+IBG", APOWER, {"update": 2}, return 1
"10060005A03X25160132"), for out in results:
("bms/1705+SN", BMS, None, None), print("--- %s -> %s:%d cmd=%s"
("ems/1707+SN", EMS, None, None), % (out["variant"], args.host, out["port"], out["cmd"]))
] if out.get("envelope") is not None:
for port in [int(p) for p in args.ports.split(",") if p.strip()]: print(" envelope: " + json.dumps(out["envelope"], default=str))
for label, cmd, data, equip in variants: tx = out.get("tx") or b""
tx = encrypt(build_envelope( print(" tx (%d B): %s" % (len(tx), tx.hex()))
args.sn, cmd, data, rx = out.get("reply_raw") or b""
equip=args.sn if equip is None else equip or "00000000", print(" rx (%d B): %s" % (len(rx), rx.hex()[:400] or "(empty)"))
), key) if out.get("reply") is not None:
if run(label, args.host, port, tx, args.timeout, print(" reply: " + json.dumps(out["reply"], default=str)[:2000])
pre_read=4.0 if label.startswith("discovery") else 0.0): soc = extract_soc(out["reply"])
pass print(" soc: %s (source=%s)" % (soc["value"], soc["source"]))
if out.get("error"):
print(" error: %s" % out["error"])
return 0
if __name__ == "__main__": if __name__ == "__main__":
main() raise SystemExit(main())
+34 -131
View File
@@ -1,136 +1,43 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Host probe v7 — per-pack BMS reads (stdlib only). """Host probe v7 — per-pack BMS reads via the library business API.
The app's ``queryBmsInfoBySocket`` passes ``arguments={"id": pack}``; the The app's ``queryBmsInfoBySocket`` passes ``arguments={"id": pack}``; the
id-less query answers the all-zero ``id: 0`` slot. Pack ids 1..2 select id-less query answers the all-zero ``id: 0`` slot. Pack ids 1..2 select
the two batteries (``BMS_SN …B1/…B2``); ``batSoc`` is the pack charge. the two batteries (``BMS_SN …B1/…B2``); ``batSoc`` is the pack charge.
Run on the SoftAP-joined host: Run from the repo root on the SoftAP-joined host:
python3 host_probe7.py --sn A03X2516013210060005 --ports 9000 python3 scripts/host_probe7.py --sn A03X2516013210060005 --ports 9000
""" """
import argparse import argparse
import json import json
import os
import socket import socket
import time import sys
import zlib
APOWER, QUERY_INFO, BMS, EMS = 0x51B, 0x44D, 0x6A9, 0x6AB sys.path.insert(0, os.path.dirname(os.path.dirname(
SNNO = [0] os.path.abspath(__file__))))
SOC_KEYS = ("currentBatterySoc", "soc", "batSoc", "batterySoc", "vppSoc")
from franklinwh_agate_client.battery import extract_soc # noqa: E402
from franklinwh_agate_client.device_local import ( # noqa: E402
LocalDeviceClient,
)
from franklinwh_agate_client.protocol import ( # noqa: E402
CommunicationCmd,
generate_secret_key,
)
def secret_key(sn): def run(label, client, call):
return format(sum(sn.encode()) & 0xFF, "x")
def build_envelope(sn, cmd, args=None, snno=None, ts=None, equip=None):
if snno is None:
SNNO[0] = (SNNO[0] % 9999) + 1
snno = SNNO[0]
data = {"opt": 0}
if args:
data.update(args)
compact = json.dumps(data, separators=(",", ":"))
env = {"cmdType": cmd, "equipNo": equip if equip is not None else sn,
"type": 0,
"timeStamp": ts if ts is not None else int(time.time()),
"snno": snno,
"len": len(compact.encode()),
"crc": format(zlib.crc32(compact.encode()), "X"),
"dataArea": data}
return json.dumps(env, separators=(",", ":"))
def encrypt(json_str, key):
parts = json_str.split(",")
a = (parts[0] + "," + parts[1] + ",").encode()
b = ",".join(parts[2:]).encode()
n = int(key, 16)
return a + bytes((b[i] + i + len(a) + n) & 0xFF
for i in range(len(b)))
def decrypt_wire(wire):
first = wire.find(b",")
second = wire.find(b",", first + 1)
if first < 0 or second < 0:
return ""
a, b = wire[:second + 1], wire[second + 1:]
try: try:
probe = json.loads(a[:-1].decode() + "}") reply = call()
except Exception:
return ""
if not isinstance(probe, dict):
return ""
n = int(secret_key(str(probe.get("equipNo", ""))), 16)
try:
return (a + bytes((t - i - len(a) - n) & 0xFF
for i, t in enumerate(b))).decode()
except Exception:
return ""
def recv_all(sock, idle=8.0):
out = bytearray()
sock.settimeout(idle)
while True:
try:
chunk = sock.recv(65536)
except (socket.timeout, TimeoutError):
break
if not chunk:
break
out.extend(chunk)
return bytes(out)
def walk(obj, key, found):
if isinstance(obj, dict):
for k, v in obj.items():
if k == key:
found.append(v)
walk(v, key, found)
elif isinstance(obj, list):
for item in obj:
walk(item, key, found)
return found
def extract_soc(reply):
for key in SOC_KEYS:
for hit in walk(reply, key, []):
if isinstance(hit, bool):
continue
try:
return float(hit), key
except (TypeError, ValueError):
continue
return None, None
def run(label, host, port, tx, timeout):
rx, err, reply = b"", None, None
try:
with socket.create_connection((host, port),
timeout=timeout) as sock:
sock.sendall(tx)
rx = recv_all(sock)
if rx:
dec = decrypt_wire(rx)
reply = json.loads(dec) if dec else {"_raw": rx.hex()}
except (ConnectionError, socket.error, TimeoutError) as exc: except (ConnectionError, socket.error, TimeoutError) as exc:
err = "%s: %s" % (type(exc).__name__, exc) print("--- %s: error: %s: %s" % (label, type(exc).__name__, exc))
print("--- %s -> %s:%d" % (label, host, port)) return None
print(" rx (%d B)" % len(rx)) print("--- %s (%d B reply)" % (label, len(json.dumps(reply))))
if reply is not None: print(" reply: " + json.dumps(reply, default=str)[:1500])
da = reply.get("dataArea", reply) if isinstance(reply, dict) else {} result = extract_soc(reply)
print(" reply: " + json.dumps(reply, default=str)[:1500]) print(" ==> soc: %s (source=%s)" % (result["value"], result["source"]))
if isinstance(reply, dict):
val, src = extract_soc(reply)
print(" ==> soc: %s (source=%s)" % (val, src))
if err:
print(" error: %s" % err)
return reply return reply
@@ -141,21 +48,17 @@ def main():
ap.add_argument("--ports", default="9000") ap.add_argument("--ports", default="9000")
ap.add_argument("--timeout", type=float, default=5.0) ap.add_argument("--timeout", type=float, default=5.0)
args = ap.parse_args() args = ap.parse_args()
key = secret_key(args.sn) print("secret key for %s: %s (radix 16)"
print("secret key for %s: %s (radix 16)" % (args.sn, key)) % (args.sn, generate_secret_key(args.sn)))
variants = [
("bms/id1", BMS, {"id": 1}, None),
("bms/id2", BMS, {"id": 2}, None),
("apower/update2", APOWER, {"update": 2}, None),
("ems", EMS, None, None),
]
for port in [int(p) for p in args.ports.split(",") if p.strip()]: for port in [int(p) for p in args.ports.split(",") if p.strip()]:
for label, cmd, data, equip in variants: print("=== port %d" % port)
tx = encrypt(build_envelope( client = LocalDeviceClient(args.sn, host=args.host,
args.sn, cmd, data, socket_port=port, timeout=args.timeout)
equip=args.sn if equip is None else equip or "00000000", run("bms/id1", client, lambda: client.query_bms_info(1))
), key) run("bms/id2", client, lambda: client.query_bms_info(2))
run(label, args.host, port, tx, args.timeout) run("apower/update2", client, lambda: client.send_rpc(
CommunicationCmd.DEVICE_APOWER_INFO, data={"update": 2}))
run("ems", client, client.query_ems_info)
if __name__ == "__main__": if __name__ == "__main__":
+41 -99
View File
@@ -1,109 +1,49 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Host probe v8 — live household wattage (stdlib only). """Host probe v8 — live household wattage via the library business API.
Three socket APIs (all verified in disassembly; detail/load pending live Three socket APIs: realtime detail (home-page poll) -> ``totalPower``,
confirmation on this unit, EMS already answered live): per-switch load status -> ``SWxExpPower``, EMS telemetry -> watts math.
* DEVICE_DETAIL_INFO (1301, no args) -> totalPower [home-page poll]
* LOAD_STATUS (1411, no args) -> SW1ExpPower [per-switch watts]
* EMS (1707, no args) -> grid/load V+A [watts via math]
Run on the SoftAP-joined host: Run from the repo root on the SoftAP-joined host:
python3 host_probe8.py --sn A03X2516013210060005 --ports 9000 python3 scripts/host_probe8.py --sn A03X2516013210060005 --ports 9000
""" """
import argparse import argparse
import json import json
import os
import socket import socket
import time import sys
import zlib
DETAIL, LOAD, EMS = 0x515, 0x583, 0x6AB sys.path.insert(0, os.path.dirname(os.path.dirname(
SNNO = [0] os.path.abspath(__file__))))
from franklinwh_agate_client.device_local import ( # noqa: E402
LocalDeviceClient,
)
from franklinwh_agate_client.power import ( # noqa: E402
ems_load_watts,
extract_power,
)
from franklinwh_agate_client.protocol import ( # noqa: E402
generate_secret_key,
)
def secret_key(sn): def run(label, client, call):
return format(sum(sn.encode()) & 0xFF, "x")
def build_envelope(sn, cmd, args=None, snno=None, ts=None, equip=None):
if snno is None:
SNNO[0] = (SNNO[0] % 9999) + 1
snno = SNNO[0]
data = {"opt": 0}
if args:
data.update(args)
compact = json.dumps(data, separators=(",", ":"))
env = {"cmdType": cmd, "equipNo": equip if equip is not None else sn,
"type": 0,
"timeStamp": ts if ts is not None else int(time.time()),
"snno": snno,
"len": len(compact.encode()),
"crc": format(zlib.crc32(compact.encode()), "X"),
"dataArea": data}
return json.dumps(env, separators=(",", ":"))
def encrypt(json_str, key):
parts = json_str.split(",")
a = (parts[0] + "," + parts[1] + ",").encode()
b = ",".join(parts[2:]).encode()
n = int(key, 16)
return a + bytes((b[i] + i + len(a) + n) & 0xFF
for i in range(len(b)))
def decrypt_wire(wire):
first = wire.find(b",")
second = wire.find(b",", first + 1)
if first < 0 or second < 0:
return ""
a, b = wire[:second + 1], wire[second + 1:]
try: try:
probe = json.loads(a[:-1].decode() + "}") reply = call()
except Exception:
return ""
if not isinstance(probe, dict):
return ""
n = int(secret_key(str(probe.get("equipNo", ""))), 16)
try:
return (a + bytes((t - i - len(a) - n) & 0xFF
for i, t in enumerate(b))).decode()
except Exception:
return ""
def recv_all(sock, idle=8.0):
out = bytearray()
sock.settimeout(idle)
while True:
try:
chunk = sock.recv(65536)
except (socket.timeout, TimeoutError):
break
if not chunk:
break
out.extend(chunk)
return bytes(out)
def run(label, host, port, tx, timeout):
rx, err, reply = b"", None, None
try:
with socket.create_connection((host, port),
timeout=timeout) as sock:
sock.sendall(tx)
rx = recv_all(sock)
if rx:
dec = decrypt_wire(rx)
reply = json.loads(dec) if dec else {"_raw": rx.hex()}
except (ConnectionError, socket.error, TimeoutError) as exc: except (ConnectionError, socket.error, TimeoutError) as exc:
err = "%s: %s" % (type(exc).__name__, exc) print("--- %s: error: %s: %s" % (label, type(exc).__name__, exc))
print("--- %s -> %s:%d" % (label, host, port)) return None
print(" rx (%d B)" % len(rx)) print("--- %s" % label)
if reply is not None: print(" reply: " + json.dumps(reply, default=str)[:2000])
print(" reply: " + json.dumps(reply, default=str)[:2000]) result = extract_power(reply)
if err: value, source = result["value"], result["source"]
print(" error: %s" % err) if value is None:
watts = ems_load_watts(reply)
if watts is not None:
value, source = watts, "ems(loadCurr*V)"
print(" ==> watts: %s (source=%s)" % (value, source))
return reply return reply
@@ -114,13 +54,15 @@ def main():
ap.add_argument("--ports", default="9000") ap.add_argument("--ports", default="9000")
ap.add_argument("--timeout", type=float, default=5.0) ap.add_argument("--timeout", type=float, default=5.0)
args = ap.parse_args() args = ap.parse_args()
key = secret_key(args.sn) print("secret key for %s: %s (radix 16)"
print("secret key for %s: %s (radix 16)" % (args.sn, key)) % (args.sn, generate_secret_key(args.sn)))
for port in [int(p) for p in args.ports.split(",") if p.strip()]: for port in [int(p) for p in args.ports.split(",") if p.strip()]:
for label, cmd in (("detail/1301", DETAIL), ("load/1411", LOAD), print("=== port %d" % port)
("ems/1707", EMS)): client = LocalDeviceClient(args.sn, host=args.host,
tx = encrypt(build_envelope(args.sn, cmd), key) socket_port=port, timeout=args.timeout)
run(label, args.host, port, tx, args.timeout) run("detail/1301", client, client.query_detail_info)
run("load/1411", client, client.query_load_status)
run("ems/1707", client, client.query_ems_info)
if __name__ == "__main__": if __name__ == "__main__":
+32
View File
@@ -258,3 +258,35 @@ class TestLiveCaptures(unittest.TestCase):
split_wire(b'{"cmdType":1101') split_wire(b'{"cmdType":1101')
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
decrypt_wire(b"not json at all") decrypt_wire(b"not json at all")
class TestEnvelopeCodec(unittest.TestCase):
def test_for_serial_roundtrip(self):
from franklinwh_agate_client.protocol.messages import EnvelopeCodec
codec = EnvelopeCodec.for_serial(SN)
self.assertEqual(codec.key, generate_secret_key(SN))
env = direct_connect_request(CommunicationCmd.BMS, SN,
data={"id": 1})
wire = codec.encode(env)
back = codec.decode(wire)
self.assertEqual(back.to_dict(), env.to_dict())
def test_decode_fixtures(self):
from franklinwh_agate_client.protocol.messages import EnvelopeCodec
disc = open(os.path.join(FIXTURES, "discovery_req.bin"),
"rb").read()
info = open(os.path.join(FIXTURES, "device_info_reply.bin"),
"rb").read()
self.assertEqual(
EnvelopeCodec.for_serial("").decode(disc).cmd_type, 1101)
reply = EnvelopeCodec.for_serial(SN).decode(info)
self.assertEqual(reply.cmd_type, 1102)
self.assertEqual(reply.to_dict()["dataArea"]["FHP_NUM"], 2)
def test_try_decode_none(self):
from franklinwh_agate_client.protocol.messages import EnvelopeCodec
codec = EnvelopeCodec.for_serial(SN)
self.assertIsNone(codec.try_decode(b""))
self.assertIsNone(codec.try_decode(b"not,a wire"))
with self.assertRaises(ValueError):
codec.decode(b"not,a wire")