EnvelopeCodec message layer; scripts are thin business-API wrappers
This commit is contained in:
1 parent
f15ae42db7
commit
363c7cc6a2
14 files changed
+549
-898
No files matched your search
+6
-4
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||||
+33
-130
@@ -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:
|
|
||||||
da = reply.get("dataArea", reply) if isinstance(reply, dict) else {}
|
|
||||||
print(" reply: " + json.dumps(reply, default=str)[:1500])
|
print(" reply: " + json.dumps(reply, default=str)[:1500])
|
||||||
if isinstance(reply, dict):
|
result = extract_soc(reply)
|
||||||
val, src = extract_soc(reply)
|
print(" ==> soc: %s (source=%s)" % (result["value"], result["source"]))
|
||||||
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__":
|
||||||
|
|||||||
+40
-98
@@ -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])
|
||||||
if err:
|
result = extract_power(reply)
|
||||||
print(" error: %s" % err)
|
value, source = result["value"], result["source"]
|
||||||
|
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__":
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in new issue
Block a user