Compare commits
15
Commits
2cdc274fe3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bff812605f | ||
|
|
b60b32830f | ||
|
|
eda78dd7d1 | ||
|
|
3c7e74fd0e | ||
|
|
b79adc5d54 | ||
|
|
363c7cc6a2 | ||
|
|
f15ae42db7 | ||
|
|
c11188dd0b | ||
|
|
3d59a75f14 | ||
|
|
62e0fd649b | ||
|
|
0cd6b34a2e | ||
|
|
58227608a7 | ||
|
|
186f58da7d | ||
|
|
bdc98484a9 | ||
|
|
213a0518d6 |
No files matched your search
@@ -65,6 +65,22 @@ Probes `10.100.1.1:9000,18000`, sends `0x19/0x1D/0x1E`, prints
|
|||||||
`currentBatterySoc → soc → avg(socArray)`. Must run where the Wi-Fi adapter
|
`currentBatterySoc → soc → avg(socArray)`. Must run where the Wi-Fi adapter
|
||||||
is on the device AP — see [Battery PoC](docs/BATTERY_POC.md).
|
is on the device AP — see [Battery PoC](docs/BATTERY_POC.md).
|
||||||
|
|
||||||
|
## Battery report (pretty terminal output)
|
||||||
|
|
||||||
|
`scripts/agate_battery.sh` bakes in the device (serial + LAN address),
|
||||||
|
queries the aGate's local socket and renders the state of charge, health,
|
||||||
|
cell voltages/temperatures and the live power flow as a colourised report:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/agate_battery.sh # baked-in device
|
||||||
|
./scripts/agate_battery.sh --sn <SN> --ip <IP>
|
||||||
|
./scripts/agate_battery.sh --json # raw device replies
|
||||||
|
./scripts/agate_battery.sh --no-color # plain text
|
||||||
|
```
|
||||||
|
|
||||||
|
Env overrides: `AGATE_SN`, `AGATE_IP`, `AGATE_PORT`, `AGATE_TIMEOUT`,
|
||||||
|
`NO_COLOR`. Colour is auto-disabled when stdout is not a TTY.
|
||||||
|
|
||||||
## Local connect (device hotspot)
|
## Local connect (device hotspot)
|
||||||
|
|
||||||
The aGate runs its own SoftAP (gateway `10.100.1.1`); joining it is what
|
The aGate runs its own SoftAP (gateway `10.100.1.1`); joining it is what
|
||||||
|
|||||||
+37
-3
@@ -59,6 +59,21 @@ out = get_battery("...", host="10.100.1.1")
|
|||||||
print(out["value"], out["source"]) # e.g. 78.0 currentBatterySoc
|
print(out["value"], out["source"]) # e.g. 78.0 currentBatterySoc
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Business operations (one call per question, on a bound client):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from franklinwh_agate_client.device_local import LocalDeviceClient
|
||||||
|
client = LocalDeviceClient("A03X2516013210060005", host="10.100.1.1",
|
||||||
|
socket_port=9000)
|
||||||
|
client.get_battery_soc() # charge % (apower, then BMS packs 1-2)
|
||||||
|
client.get_household_wattage() # watts (detail, then load, then EMS math)
|
||||||
|
client.get_device_inventory() # IBG/FHP/BMS serials + versions
|
||||||
|
```
|
||||||
|
|
||||||
|
`get_*` returns `{"value", "source", "detail", "reply", ...}` with
|
||||||
|
`value: None` when nothing answered; transport errors propagate so
|
||||||
|
multi-port callers (`get_battery`, `get_power`) can move on.
|
||||||
|
|
||||||
## Host probe recipe (run on the machine joined to the device AP)
|
## Host probe recipe (run on the machine joined to the device AP)
|
||||||
|
|
||||||
The agent container cannot reach `10.100.1.x`; run this on the host:
|
The agent container cannot reach `10.100.1.x`; run this on the host:
|
||||||
@@ -69,6 +84,13 @@ 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/` — thin wrappers over the
|
||||||
|
> library business API (run from the repo root, e.g.
|
||||||
|
> `python3 scripts/host_probe7.py --sn …`). Wire bytes always come from
|
||||||
|
> `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
|
||||||
`{"cmdType":25,"equipNo":"A03X2516013210060005",`) and `rx` bytes.
|
`{"cmdType":25,"equipNo":"A03X2516013210060005",`) and `rx` bytes.
|
||||||
@@ -81,9 +103,21 @@ instead: install the Frida hook (`blutter_out/blutter_frida.js` pattern —
|
|||||||
log `Socket Manager send object: ` / `receive object: `), tap *Refresh* in
|
log `Socket Manager send object: ` / `receive object: `), tap *Refresh* in
|
||||||
the app while joined to the AP, and compare its TX bytes against ours.
|
the app while joined to the AP, and compare its TX bytes against ours.
|
||||||
|
|
||||||
## Limits
|
## Status (2026-10-16) — live round-trip works
|
||||||
|
|
||||||
- No live round-trip has succeeded yet from an isolated container netns
|
Two-way comms verified against `10.100.1.1:9000` from a host on
|
||||||
(all probes `ECONNREFUSED`); first success needs a `10.100.1.x` source.
|
`10.100.1.0/24`: discovery → `1102` device-info reply (versions, `FHP_SN`,
|
||||||
|
`BMS_SN`), per-pack BMS reads (`{"id": 1}` / `{"id": 2}` → live `batSoc`),
|
||||||
|
EMS telemetry. `get_battery()` runs that exact sequence (discovery,
|
||||||
|
apower, BMS packs 1–2, EMS) and returns the first SoC found; an all-zero
|
||||||
|
BMS slot is skipped, not reported as 0 %.
|
||||||
|
|
||||||
|
`DEVICE_APOWER_INFO` answers `{"result": 1, "reason": -1}` on the tested
|
||||||
|
unit — SoC comes from the BMS packs instead.
|
||||||
|
|
||||||
|
Remaining limits:
|
||||||
|
|
||||||
|
- Needs L2 reachability to `10.100.1.1` (host on the device AP or bridged
|
||||||
|
container); the app's own socket port `18000` is closed on this unit.
|
||||||
- BLE field order and `X-Device-*` binding remain open
|
- BLE field order and `X-Device-*` binding remain open
|
||||||
(`RE_DART_FINDINGS.md` Open items).
|
(`RE_DART_FINDINGS.md` Open items).
|
||||||
+226
-29
@@ -106,10 +106,17 @@ Null/empty payload ⇒ `len == 0`, `crc == ""`, no `dataArea` key.
|
|||||||
- The `communicate` factory (`0xc9264c`) builds the `data` map from the
|
- The `communicate` factory (`0xc9264c`) builds the `data` map from the
|
||||||
caller's `arguments` (e.g. `queryAPowerData` passes `{"update": 2}` with
|
caller's `arguments` (e.g. `queryAPowerData` passes `{"update": 2}` with
|
||||||
cmd `deviceApowerInfo`/`0x19`, operate `query`) and calls the ctor with
|
cmd `deviceApowerInfo`/`0x19`, operate `query`) and calls the ctor with
|
||||||
a named `data:` argument.
|
a named `data:` argument. On socket mode it **always seeds the map with
|
||||||
|
`"opt"`** (the operation type — `0` for query) before merging arguments,
|
||||||
|
so the app's apower query sends
|
||||||
|
`dataArea = {"opt": 0, "update": 2}` and its device-info query sends
|
||||||
|
`{"opt": 0, "minProtocolVer": "V1.00.00"}` (empty/non-query args still
|
||||||
|
yield `{"opt": 0}`). This matches the live capture sizes (§8).
|
||||||
|
|
||||||
## 4. `CommunicationCmd` (raw → name)
|
## 4. `CommunicationCmd` (raw → name)
|
||||||
|
|
||||||
|
`off_8` values (the enum's first id field):
|
||||||
|
|
||||||
```
|
```
|
||||||
0x00 queryDeviceInfo 0x13 pairPower 0x26 smartLoad2
|
0x00 queryDeviceInfo 0x13 pairPower 0x26 smartLoad2
|
||||||
0x01 networkInfo 0x14 processPairPower 0x27 rebootDevice
|
0x01 networkInfo 0x14 processPairPower 0x27 rebootDevice
|
||||||
@@ -132,6 +139,17 @@ Null/empty payload ⇒ `len == 0`, `crc == ""`, no `dataArea` key.
|
|||||||
```
|
```
|
||||||
(missing: 0x09, 0x1b, 0x2c, 0x31 — not surfaced as instances.)
|
(missing: 0x09, 0x1b, 0x2c, 0x31 — not surfaced as instances.)
|
||||||
|
|
||||||
|
**Two id spaces.** `CommunicationCmd::cmd(cmd, mode)` (0xc92894) returns the
|
||||||
|
*second* id field (`off_14` in the pool) for `socket`/`bluetooth`/`local`
|
||||||
|
modes and throws `"do not support http communication"` for `http`. The
|
||||||
|
socket-transport ids live in `SOCKET_CMD_TYPE`
|
||||||
|
(`protocol/commands.py`) — e.g. `queryDeviceInfo → 1101 (0x44D)`,
|
||||||
|
`deviceApowerInfo → 1307 (0x51B)`, `bms → 1705`, `ems → 1707`. The `off_8`
|
||||||
|
values above are **not** used on the socket/BLE wire. Verified live: a
|
||||||
|
PCAPdroid capture of the working app shows
|
||||||
|
`{"cmdType":1101,"equipNo":"00000000",…}` with the S1-plaintext-first
|
||||||
|
framing (§5).
|
||||||
|
|
||||||
## 5. Encryption — `communication_center/core/encryption.dart`
|
## 5. Encryption — `communication_center/core/encryption.dart`
|
||||||
|
|
||||||
**`generateSecretKey(String equipNo)`** = hex of `(sum(utf8(equipNo)) & 0xFF)` (via
|
**`generateSecretKey(String equipNo)`** = hex of `(sum(utf8(equipNo)) & 0xFF)` (via
|
||||||
@@ -143,14 +161,20 @@ unpadded. E.g. SN `A03X2516013210060005` → byte sum `1052 & 0xFF = 28` → `"1
|
|||||||
2. `S1 = parts[0] + "," + parts[1] + ","` (header, *with* trailing comma);
|
2. `S1 = parts[0] + "," + parts[1] + ","` (header, *with* trailing comma);
|
||||||
`a = UTF-8(S1)`.
|
`a = UTF-8(S1)`.
|
||||||
3. `S2 = join(",", parts[2..])` (the rest); `b = UTF-8(S2)`.
|
3. `S2 = join(",", parts[2..])` (the rest); `b = UTF-8(S2)`.
|
||||||
4. `keyInt = int.tryParse(key, radix: 32)` (literal `0x20` at the call site;
|
4. `keyInt = int.tryParse(key)` (default radix **16** — two live frames
|
||||||
null → 0).
|
decrypt cleanly only with radix 16 and then verify byte-exact on
|
||||||
|
`len`/CRC; an earlier radix-32 reading of a nearby `0x20` literal is
|
||||||
|
superseded).
|
||||||
5. `out[i] = (b[i] + i + len(a) + keyInt) & 0xFF` for all i (so each tail
|
5. `out[i] = (b[i] + i + len(a) + keyInt) & 0xFF` for all i (so each tail
|
||||||
byte is shifted by its *absolute* JSON index plus the key).
|
byte is shifted by its *absolute* JSON index plus the key).
|
||||||
6. Result = `a ++ out` — the plaintext header goes on the wire **first**,
|
6. Result = `a ++ out` — the plaintext header goes on the wire **first**,
|
||||||
transformed tail after (`addAll` onto the header copy).
|
transformed tail after (`addAll` onto the header copy).
|
||||||
|
|
||||||
**`decryptData(bytes)`** (single arg, the accumulated receive buffer):
|
**`decryptData(bytes)`** (single arg, the accumulated receive buffer):
|
||||||
|
as recovered from the disassembly (two-`0x58`-marker scan, `s + "}"`
|
||||||
|
equipNo probe, `s + "," + T` return). NOTE (2026-10-16): live socket
|
||||||
|
replies decrypt with the *request-side* split instead (see below); the
|
||||||
|
marker model is kept for the unverified Bluetooth path.
|
||||||
1. `i = indexWhere(byte == 0x58)`, `j = indexWhere(byte == 0x58, start: i+1)`;
|
1. `i = indexWhere(byte == 0x58)`, `j = indexWhere(byte == 0x58, start: i+1)`;
|
||||||
either missing → return `""`.
|
either missing → return `""`.
|
||||||
2. `s = utf8(wire[0..j])`; `probe = jsonDecode(s + "}")`, null → `""`.
|
2. `s = utf8(wire[0..j])`; `probe = jsonDecode(s + "}")`, null → `""`.
|
||||||
@@ -164,8 +188,23 @@ unpadded. E.g. SN `A03X2516013210060005` → byte sum `1052 & 0xFF = 28` → `"1
|
|||||||
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
|
||||||
|
request frame opens with the ASCII header
|
||||||
|
`{"cmdType":1101,"equipNo":"00000000",` — plaintext `S1` first, exactly as
|
||||||
|
derived, with the socket-space id (§4). A second frame shows
|
||||||
|
`{"cmdType":1102,"equipNo":"1006005A03X25160132",` (larger payload,
|
||||||
|
direction under analysis — see §8). Note the captures were exported as
|
||||||
|
text, so non-UTF-8 bytes were replaced with U+FFFD and only the ASCII
|
||||||
|
prefixes survive; a binary `.pcap` export is still wanted.
|
||||||
|
|
||||||
## 6. Checksums & byte order
|
## 6. Checksums & byte order
|
||||||
|
|
||||||
@@ -237,22 +276,178 @@ authS2C=2 "fwh/s2c/auth/" mngC2S=5 "fwh/c2s/mng/" deviceS2C "fwh/s2c/
|
|||||||
|
|
||||||
### Empirical — live aGate on its SoftAP (2026-10-13, from a client on `10.100.1.0/24`)
|
### Empirical — live aGate on its SoftAP (2026-10-13, from a client on `10.100.1.0/24`)
|
||||||
|
|
||||||
Full TCP sweep of `10.100.1.1` (ports 1–65535): exactly **two** ports open — **53** and **9000**. No 80/443, no web UI, and **18000 (`0x4650`) is closed** on this unit.
|
Full TCP sweep of `10.100.1.1` (ports 1–65535): exactly **two** ports open — **53** and **9000**. No 80/443, no web UI, and **18000 (`0x4650`) is closed** on this unit. No `9000` reference exists anywhere in the app (Dart or Java) — but see the 2026-10-15 update below.
|
||||||
|
|
||||||
- **Port 53** — authoritative, *closed* DNS: well-formed `NXDOMAIN` for everything
|
- **Port 53** — authoritative, *closed* DNS: well-formed `NXDOMAIN` for everything
|
||||||
(even `example.com`), no PTR for `1.1.0.100.in-addr.arpa`, no forwarding (the SoftAP
|
(even `example.com`), no PTR for `1.1.0.100.in-addr.arpa`, no forwarding (the SoftAP
|
||||||
is an island). Serves some local name pattern we haven't identified yet.
|
is an island). Serves some local name pattern we haven't identified yet.
|
||||||
- **Port 9000** — a **length-prefixed framing service** (int16LE length), consistent
|
- **Port 9000** — accepts TCP, reads one frame, answers zero bytes, closes —
|
||||||
with the app's socket layer:
|
for *every* probe (TLS ClientHello, HTTP GET, MQTT CONNECT, exact-spec
|
||||||
- every TLS `ClientHello` gets `UNEXPECTED_EOF` mid-handshake — first two bytes
|
envelopes raw/split/length-prefixed, greeting-first reads).
|
||||||
`16 03` = int16LE `790`, so the decoder waits for a 790-byte frame that never
|
|
||||||
completes, then closes on idle;
|
### Update (2026-10-16) — binary capture fully decoded
|
||||||
- cleartext HTTP `GET` (`47 45` = `17735`) hangs forever (read timeout).
|
|
||||||
- Earlier probes are superseded: they used an int16LE length prefix (the
|
Raw `.bin` payloads (no text mangling) decrypt completely offline with
|
||||||
socket layer sends **raw**), the old `transform(b) || a` order (correct
|
radix 16 (`tests/fixtures/*.bin` are golden vectors):
|
||||||
is `a || transform(b)` — §5), a truncated `equipNo`, and default radix
|
|
||||||
16. All are corrected in the current PoC; see `BATTERY_POC.md` for the
|
- Discovery request (153 B): `{"cmdType":1101,"equipNo":"00000000",…,
|
||||||
host probe recipe with the true SN.
|
"dataArea":{"opt":0,"minProtocolVer":"V1.00.00"}}`, key `"80"`/n=128.
|
||||||
|
- Device-info reply (1154 B): `cmdType` **1102**, `equipNo`
|
||||||
|
`10060005A03X25160132` (the unit's IBG SN — label SN
|
||||||
|
`A03X2516013210060005` shares its trailing 12 chars), `len` 1024 exact,
|
||||||
|
CRC exact, `dataArea.result == 0` with the full version inventory
|
||||||
|
(`FHP_NUM: 2`, two `FHP_SN`, two `BMS_SN`, `protocolVer V1.12.36`).
|
||||||
|
Encrypted with the **gateway** key (`"1c"`/n=28) while addressed to the
|
||||||
|
IBG SN — the gateway is the crypto endpoint; sub-unit addressing rides
|
||||||
|
inside.
|
||||||
|
|
||||||
|
Reply rules learned: same `S1 || transform(S2)` framing both directions;
|
||||||
|
reply id observed as request + 1 (`1101 → 1102`); universal split at the
|
||||||
|
2nd comma needs no ids up front (`decrypt_wire`). The remaining unknown
|
||||||
|
is the live endpoint (dst IP:port + direction per capture).
|
||||||
|
|
||||||
|
Superseded notes: pre-2026-10-16 probes additionally used the wrong key
|
||||||
|
radix (32 instead of 16) and mostly omitted the `opt`-seeded payload, so
|
||||||
|
every silent run is fully explained by bytes, with no need for exotic
|
||||||
|
causes. Current probes use socket-space ids + radix 16 + `opt` payloads
|
||||||
|
via `scripts/host_probe6.py`.
|
||||||
|
|
||||||
|
### Live results (2026-10-16, host probes with corrected bytes)
|
||||||
|
|
||||||
|
- Discovery + directed `queryDeviceInfo` + `BMS`/`EMS` all answer on
|
||||||
|
`10.100.1.1:9000` (replies: `1102` device info, `1706` BMS, `1708` EMS).
|
||||||
|
- `DEVICE_APOWER_INFO` (`1307`) answers `{"result": 1, "reason": -1}` —
|
||||||
|
rejected (args or unsupported on this firmware).
|
||||||
|
- `BMS` **requires the pack id** (`queryBmsInfoBySocket` passes
|
||||||
|
`arguments={"id"}`): id-less queries return the all-zero `id: 0` slot;
|
||||||
|
`id: 1`/`2` select the `…B1`/`…B2` packs whose `batSoc`/`batSoh` are the
|
||||||
|
charge/health readings. `scripts/host_probe7.py` reads both packs.
|
||||||
|
|
||||||
|
## 14. Direct-connect flow (manual AP join → return to app)
|
||||||
|
|
||||||
|
`sections/menus/direct_connect/` owns the SoftAP onboarding path:
|
||||||
|
|
||||||
|
- The *manual connect* button (`hotspot_guide_widget.dart`) calls
|
||||||
|
`goToSystemWifiSettings()` → `OpenSystemWifiSetting` method channel →
|
||||||
|
OS Wi-Fi picker. The app goes to background.
|
||||||
|
- On return, the page (`viewDidLoad`, plus the provider-state listener in
|
||||||
|
`direct_connect.dart`) re-evaluates: with a device SN selected it calls
|
||||||
|
`DirectConnectProvider.socketQueryDeviceInfo()` (also fired from
|
||||||
|
`onSelectHotspot` once wifi state settles).
|
||||||
|
- `socketQueryDeviceInfo` first awaits `CommunicationService.directConnect()`,
|
||||||
|
which is true when `connectedFhpAp()` (current SSID is the FHP AP) **or** a
|
||||||
|
BLE device is connected — otherwise it aborts silently.
|
||||||
|
- It then issues `queryDeviceInfo` in the *current* transport mode
|
||||||
|
(`_queryDeviceInfoFuture`, args `{"minProtocolVer": "V1.00.00"}`, or
|
||||||
|
`{"installInfo": 2}` for mqtt mode which is HTTP-forwarded instead).
|
||||||
|
In socket mode `_connectBeforeRequest` dials
|
||||||
|
`getWifiGatewayIP():18000` (`"10.100.1.1"` fallback) with ~2 retries.
|
||||||
|
- On the reply it updates the device model
|
||||||
|
(`updateFromDeviceInfoInitDataEntity`) and continues with
|
||||||
|
`_queryTrustedDeviceModel` → `_checkConnectedNotTrustedAp`.
|
||||||
|
- The `communicate` factory always seeds socket payloads with `"opt"`
|
||||||
|
(operation type, `0` for query), so the on-air discovery frame is shaped
|
||||||
|
`{"opt": 0, "minProtocolVer": "V1.00.00"}` (§3).
|
||||||
|
|
||||||
|
Implication: on the SoftAP the app speaks **only** to the gateway IP,
|
||||||
|
port **18000**. Port `9000` appears nowhere in the app; `18000` is closed
|
||||||
|
on the tested unit, so even the app's own socket path cannot work there —
|
||||||
|
consistent with all-silent probes.
|
||||||
|
|
||||||
|
## 15. Transport selection — one mode at a time, BLE provisions Wi-Fi
|
||||||
|
|
||||||
|
- `DirectConnectProvider.initSelectedMode`: on the FHP AP → `socket`;
|
||||||
|
otherwise, if a BLE device is connected (or a previous selection exists)
|
||||||
|
→ `bluetooth` (+ `initScan`); else `socket`. Every request carries its
|
||||||
|
transport in `selectedMode` (`field_5b`); `sendObjectOrganized` routes to
|
||||||
|
exactly one manager. Queries never run on both transports at once.
|
||||||
|
- Switching AP → socket explicitly tears down BLE first
|
||||||
|
(`onSelectHotspot`: `disconnectBluetooth` behind the
|
||||||
|
`switchBluetoothToHotspotTip` dialog).
|
||||||
|
- `directConnect()` (provider and service variants) is true when on the FHP
|
||||||
|
AP **or** BLE-connected — the two are alternative routes to one device.
|
||||||
|
- BLE and Wi-Fi *cooperate* at the commissioning layer, not the query
|
||||||
|
layer: ESP BLE provisioning (`prov-session/scan/config`, sec0/sec1)
|
||||||
|
delivers the home Wi-Fi SSID/password to the device **over BLE**; the
|
||||||
|
socket/HTTP hotspot APIs (`/terminal/*`, `wifi_iot` join) take it from
|
||||||
|
there. Same envelope + cipher on both transports (§5, §9).
|
||||||
|
|
||||||
|
## 16. Live power readings
|
||||||
|
|
||||||
|
- `queryDeviceRealTimeData` — what the home page polls
|
||||||
|
(`DeviceRealTimeDataMixin`, `startPollingDeviceRealTimeData`): cmd
|
||||||
|
`deviceDetailInfo` (socket `1301`), no arguments on socket
|
||||||
|
(`{"refreshData": 2}` is added only for mqtt mode). Reply `dataArea`:
|
||||||
|
`apowerCount`, **`totalPower`** (the household-draw headline).
|
||||||
|
- `queryLoadStatusInfo` — cmd `loadStatus` (socket `1411`), no arguments.
|
||||||
|
Reply per smart switch: `Sw1Volt`/`Sw2Volt`, `SW1Curr`, `SW1ExpPower`
|
||||||
|
(`SW2…`), `SW1ExpEnergy`, `index`. (`loadStatus2`, socket `1415`, is the
|
||||||
|
newer variant.)
|
||||||
|
- `queryEmsInfoBySocket` — cmd `ems` (socket `1707`), no arguments. Reply
|
||||||
|
carries `gridVol1/2`, `gridCurr1/2`, `loadCurr1/2`, `gridFreq`, relay
|
||||||
|
states. Household draw ≈
|
||||||
|
`(loadCurr1 + loadCurr2) × (gridVol1 + gridVol2) / 2`
|
||||||
|
(library: `power.ems_load_watts`).
|
||||||
|
- `InstallSunRunSummaryEntity` (`homeloadPower`, `solarPower`,
|
||||||
|
`gridPower`, `apowersPower`) is parsed in the bean factory layer, not
|
||||||
|
off the local socket — cloud/HTTP-side entity.
|
||||||
|
|
||||||
|
Library: `power.get_power()` tries detail → load → EMS in order
|
||||||
|
(`franklinwh_agate_client/power.py`); `LocalDeviceClient` gained
|
||||||
|
`query_detail_info()` / `query_load_status()`.
|
||||||
|
|
||||||
|
## 17. Cloud account binding (installer flow)
|
||||||
|
|
||||||
|
Binding a unit to an account is an *installer commissioning* action, not
|
||||||
|
a local-socket one (`sections/installer/bind_device/`):
|
||||||
|
|
||||||
|
- `BindDeviceProvider.bind()` POSTs a data map to
|
||||||
|
**`RemoteInterface.deviceBind` → `POST {cloud}/manage/deviceBindV2`**,
|
||||||
|
with `protocolVer: "V1.10.01"`, the installer `jobCode`, the
|
||||||
|
`commissionId` (preset-site path via `InstallContext`), and the
|
||||||
|
`BindDeviceEntity` payload. Result parses as
|
||||||
|
`HttpResultEntity<BindDeviceEntity>` (bound `account`, distributor /
|
||||||
|
financier ids/names).
|
||||||
|
- Mirror calls: `queryDeviceBindData` →
|
||||||
|
`GET {cloud}/manage/getGatewayBindData` (pre-check: is this gateway
|
||||||
|
already bound, to whom) and `deviceUnbind` →
|
||||||
|
`POST {cloud}/manage/unBindDevice` ("Are you sure you want to
|
||||||
|
unbind?", `unbindSuccess` strings).
|
||||||
|
- Request auth on cloud calls is a signature, not a session cookie:
|
||||||
|
`RemoteService` computes **`MD5(jsonEncode(body) + "B6F3Zu&aW8XlH9CA")`**
|
||||||
|
and sends it as the **`token`** header (dio `Options`). User login
|
||||||
|
itself is Okta (`sunrun.okta.com`, MFA endpoints); the known cloud
|
||||||
|
bases are `https://e-api.franklinwh.com/api/appapi` and
|
||||||
|
`https://energy.franklinwh.com/*` (exact `/manage` base mapping lives
|
||||||
|
in the `RemoteService` dio init).
|
||||||
|
- Locally, bound devices land in the trusted-devices table
|
||||||
|
(`DatabaseManager.getTrustedDevicesTableList`, consulted by
|
||||||
|
`_checkConnectedNotTrustedAp` on direct-connect).
|
||||||
|
|
||||||
|
## 18. Joining the aGate to home Wi-Fi (STA onboarding)
|
||||||
|
|
||||||
|
Three transports can deliver the credentials; all verified in
|
||||||
|
disassembly, socket path live-tested for framing (not the join itself):
|
||||||
|
|
||||||
|
- **Socket** (while on the SoftAP): `queryConnectWifi` →
|
||||||
|
`CommunicationService.connectWifi(entity, …)` → `_request` with cmd
|
||||||
|
`connectWifi` (socket `1111`), operate **SET** (`type: 1`), arguments =
|
||||||
|
`ConnectWifiEntity.toJson()`: `ssid`, `password`, `wifi_SSID`,
|
||||||
|
`wifi_Pw` (plus an unencrypted-network flag). Library:
|
||||||
|
`LocalDeviceClient.join_home_wifi(ssid, password)` (+ `opt: 1`
|
||||||
|
seeding via the shared factory path), `scan_home_wifi()`
|
||||||
|
(`scanWifiList`, socket `1109`, no args) to list visible networks,
|
||||||
|
`query_connection_status()` (`connectionStatus`, socket `1113`) to
|
||||||
|
confirm association.
|
||||||
|
- **BLE**: ESP protocomm (`prov-session`, `prov-scan`, `prov-config`,
|
||||||
|
`custom-data`, sec0/sec1) — the standard Espressif flow; `prov-config`
|
||||||
|
carries the SSID/password. Used when the phone is not on the SoftAP.
|
||||||
|
- **Phone-side**: `wifi_iot` plugin join/leave is the *phone* joining the
|
||||||
|
*device* AP (`wifi.join_device_hotspot`) — the opposite direction.
|
||||||
|
|
||||||
|
.. warning:: `join_home_wifi` is state-changing: on success the unit
|
||||||
|
leaves its SoftAP (or roams), dropping the current session. Only run
|
||||||
|
it with physical/rejoin access to the unit.
|
||||||
|
|
||||||
## 9. Bluetooth (BLE) transport — `communication_center/bluetooth`
|
## 9. Bluetooth (BLE) transport — `communication_center/bluetooth`
|
||||||
|
|
||||||
@@ -382,23 +577,25 @@ keepAlive 10 s, LWT, QoS 0, event names), `MethodHandlerManager` handler list,
|
|||||||
|
|
||||||
Still open:
|
Still open:
|
||||||
|
|
||||||
1. Exact BLE frame field order & the 2-byte magic semantics (confirm with `btsnoop`).
|
1. Live verification of the BLE frame layout (settled from disassembly in
|
||||||
2. ~~Exact `encryptObject` radix + trailing-header handling~~ — **resolved
|
§9 — `magic ++ crc ++ totalLen ++ sn ++ content`, radix-32 CRC pairs —
|
||||||
from disassembly (see §5): radix 32, plaintext header first
|
but no live BLE exchange yet) and the GATT service/notify
|
||||||
(`S1 || transform(S2)`), `decryptData` marker model. Remaining: first
|
characteristics (write char matches `0000f002-…`).
|
||||||
confirmed live round-trip.**
|
2. ~~Exact `encryptObject` radix + framing~~ — **resolved live (2026-10-16,
|
||||||
|
see §5 and `tests/fixtures/*.bin`): radix 16, plaintext header first
|
||||||
|
(`S1 || transform(S2)`) both directions.** (An earlier radix-32 reading
|
||||||
|
of a nearby `0x20` literal is superseded.) The `decryptData`
|
||||||
|
`0x58`-marker model is kept for the unverified Bluetooth path only.
|
||||||
3. ~~Local HTTP port + TLS behavior for `https://IP:PORT`~~ — **revised (2026-10-13,
|
3. ~~Local HTTP port + TLS behavior for `https://IP:PORT`~~ — **revised (2026-10-13,
|
||||||
§8 empirical):** no HTTP/TLS port exists on the device at all (only 53 + 9000).
|
§8 empirical):** no HTTP/TLS port exists on the device at all (only 53 + 9000).
|
||||||
Either local mode is socket-only on this firmware, or the HTTP service is
|
Either local mode is socket-only on this firmware, or the HTTP service is
|
||||||
state-dependent and wasn't running. Revisit if a `/terminal` response is ever
|
state-dependent and wasn't running. Revisit if a `/terminal` response is ever
|
||||||
captured from the SoftAP.
|
captured from the SoftAP.
|
||||||
4. ~~Local socket port confirmation~~ — **partially resolved: `9000` open on
|
4. ~~Local socket round-trip~~ — **done (2026-10-16): full two-way RPC on
|
||||||
one unit** (§8 empirical); the `0x4650` constant is not listening there,
|
`10.100.1.1:9000`** (discovery → `1102` info, per-pack BMS `batSoc`,
|
||||||
so the port may be firmware-/device-dependent. Remaining: first confirmed
|
EMS telemetry). Remaining: the live endpoint's exact role for port 9000
|
||||||
RPC round-trip — the corrected request bytes (raw `S1 || transform(S2)`,
|
vs the app's `18000` constant (closed here), and the `1102 = req + 1`
|
||||||
radix 32, int `snno`, seconds `timeStamp`, true SN
|
reply-numbering rule across more commands.
|
||||||
`A03X2516013210060005`) are ready via
|
|
||||||
`battery --probe`; needs a host on `10.100.1.0/24`.
|
|
||||||
5. Which class in `classes5.dex` sets the `X-Device-*` headers and their exact values
|
5. Which class in `classes5.dex` sets the `X-Device-*` headers and their exact values
|
||||||
(string-pool location confirmed; class binding in progress — §13).
|
(string-pool location confirmed; class binding in progress — §13).
|
||||||
6. (Tooling, optional) Enable REA's DEX tools: obtain a **jadx-headless-mcp 0.7.1** JAR
|
6. (Tooling, optional) Enable REA's DEX tools: obtain a **jadx-headless-mcp 0.7.1** JAR
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|||||||
|
|
||||||
from .device_local import LocalDeviceClient
|
from .device_local import LocalDeviceClient
|
||||||
from .protocol import CommunicationCmd
|
from .protocol import CommunicationCmd
|
||||||
from .wifi import derive_hotspot_credentials
|
from .wifi import DEVICE_GATEWAY_IP, derive_hotspot_credentials
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -30,7 +30,11 @@ log = logging.getLogger(__name__)
|
|||||||
SOCKET_PORT_CANDIDATES: Tuple[int, ...] = (9000, 18000)
|
SOCKET_PORT_CANDIDATES: Tuple[int, ...] = (9000, 18000)
|
||||||
|
|
||||||
#: Reply keys searched for the aggregate percentage, in priority order.
|
#: Reply keys searched for the aggregate percentage, in priority order.
|
||||||
SOC_KEYS: Tuple[str, ...] = ("currentBatterySoc", "soc", "batterySoc", "vppSoc")
|
#: ``batSoc`` is the per-pack charge from the BMS socket query
|
||||||
|
#: (``queryBmsInfoBySocket`` passes ``arguments={"id": pack}`` — without
|
||||||
|
#: the pack id the device answers the all-zero ``id: 0`` slot).
|
||||||
|
SOC_KEYS: Tuple[str, ...] = ("currentBatterySoc", "soc", "batSoc",
|
||||||
|
"batterySoc", "vppSoc")
|
||||||
|
|
||||||
|
|
||||||
def _as_dict(data: Any) -> Optional[Dict[str, Any]]:
|
def _as_dict(data: Any) -> Optional[Dict[str, Any]]:
|
||||||
@@ -121,20 +125,41 @@ def _soc_detail(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def _is_empty_pack(reply: Dict[str, Any], result: Dict[str, Any]) -> bool:
|
||||||
|
"""True when a BMS reply is the all-zero placeholder slot.
|
||||||
|
|
||||||
|
A genuine 0 % pack still reports health/voltage (``batSoh``,
|
||||||
|
``batTotalVolt``); the unaddressed ``id: 0`` slot reports literal
|
||||||
|
zeros with ``result == 0``. Returning ``0.0`` for that slot would
|
||||||
|
mask the real packs, so the caller keeps probing instead.
|
||||||
|
"""
|
||||||
|
if result.get("source") != "batSoc" or result.get("value") != 0:
|
||||||
|
return False
|
||||||
|
data = reply.get("dataArea", reply)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return False
|
||||||
|
return (data.get("result") == 0 and data.get("batSoh") == 0
|
||||||
|
and data.get("batTotalVolt") == 0)
|
||||||
|
|
||||||
|
|
||||||
def get_battery(
|
def get_battery(
|
||||||
sn: str,
|
sn: str,
|
||||||
host: str = "10.100.1.1",
|
host: str = DEVICE_GATEWAY_IP,
|
||||||
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
|
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
|
||||||
key_radix: int = 32,
|
key_radix: int = 16,
|
||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Query the aggregate charge percentage over the local TCP socket.
|
"""Query the aggregate charge percentage over the local TCP socket.
|
||||||
|
|
||||||
Mirrors the app's ``queryAPowerData`` first (``DEVICE_APOWER_INFO`` with
|
Mirrors the app's ``queryAPowerData`` first (``DEVICE_APOWER_INFO`` with
|
||||||
``data={"update": 2}``), then ``BMS`` and ``EMS`` as fallbacks, probing
|
``data={"opt": 0, "update": 2}``), then per-pack ``BMS`` socket queries
|
||||||
each candidate port. Returns the ``extract_soc`` dict plus
|
(``queryBmsInfoBySocket`` passes the pack ``id`` — ``batSoc`` is the
|
||||||
``cmd``/``port``/``reply`` context. Raises the last socket error if no
|
pack charge), then ``EMS`` as a fallback, probing each candidate port.
|
||||||
port answers.
|
Like the app's direct-connect flow, a discovery query
|
||||||
|
(``QUERY_DEVICE_INFO``, empty serial) opens each port's sequence — its
|
||||||
|
reply also confirms two-way crypto before the battery read. Returns
|
||||||
|
the ``extract_soc`` dict plus ``cmd``/``port``/``reply`` context.
|
||||||
|
Raises the last socket error if no port answers.
|
||||||
"""
|
"""
|
||||||
last_err: Optional[Exception] = None
|
last_err: Optional[Exception] = None
|
||||||
for port in ports:
|
for port in ports:
|
||||||
@@ -142,26 +167,17 @@ def get_battery(
|
|||||||
sn=sn, host=host, socket_port=port,
|
sn=sn, host=host, socket_port=port,
|
||||||
key_radix=key_radix, timeout=timeout,
|
key_radix=key_radix, timeout=timeout,
|
||||||
)
|
)
|
||||||
for cmd, data in (
|
try:
|
||||||
(CommunicationCmd.DEVICE_APOWER_INFO, {"update": 2}),
|
result = client.get_battery_soc()
|
||||||
(CommunicationCmd.BMS, None),
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
(CommunicationCmd.EMS, None),
|
last_err = exc
|
||||||
):
|
log.debug("port %d failed: %s", port, exc)
|
||||||
try:
|
continue # port unreachable — try next port
|
||||||
reply = client.send_rpc(cmd, data=data)
|
if result["value"] is not None:
|
||||||
except (ConnectionError, socket.error, TimeoutError) as exc:
|
result.update({"port": port})
|
||||||
last_err = exc
|
return result
|
||||||
log.debug("port %d cmd %s failed: %s", port, cmd, exc)
|
last_err = ValueError(
|
||||||
break # port unreachable — try next port
|
f"no SoC field in replies via {host}:{port}")
|
||||||
except Exception as exc: # envelope-level failure; try next cmd
|
|
||||||
last_err = exc
|
|
||||||
log.debug("port %d cmd %s error: %s", port, cmd, exc)
|
|
||||||
continue
|
|
||||||
result = extract_soc(reply)
|
|
||||||
result.update({"cmd": str(cmd), "port": port, "reply": reply})
|
|
||||||
if result["value"] is not None:
|
|
||||||
return result
|
|
||||||
last_err = ValueError(f"reply on port {port} cmd {cmd} has no SoC field")
|
|
||||||
raise ConnectionError(f"no battery reply via {host}:{list(ports)}") from last_err
|
raise ConnectionError(f"no battery reply via {host}:{list(ports)}") from last_err
|
||||||
|
|
||||||
|
|
||||||
@@ -173,16 +189,17 @@ PROBE_VARIANTS: Tuple[Tuple[str, CommunicationCmd, Any], ...] = (
|
|||||||
("apower/none", CommunicationCmd.DEVICE_APOWER_INFO, None),
|
("apower/none", CommunicationCmd.DEVICE_APOWER_INFO, None),
|
||||||
("apower/empty", CommunicationCmd.DEVICE_APOWER_INFO, {}),
|
("apower/empty", CommunicationCmd.DEVICE_APOWER_INFO, {}),
|
||||||
("queryDeviceInfo/none", CommunicationCmd.QUERY_DEVICE_INFO, None),
|
("queryDeviceInfo/none", CommunicationCmd.QUERY_DEVICE_INFO, None),
|
||||||
("bms/none", CommunicationCmd.BMS, None),
|
("bms/id1", CommunicationCmd.BMS, {"id": 1}),
|
||||||
|
("bms/id2", CommunicationCmd.BMS, {"id": 2}),
|
||||||
("ems/none", CommunicationCmd.EMS, None),
|
("ems/none", CommunicationCmd.EMS, None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def probe_device(
|
def probe_device(
|
||||||
sn: str,
|
sn: str,
|
||||||
host: str = "10.100.1.1",
|
host: str = DEVICE_GATEWAY_IP,
|
||||||
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
|
ports: Tuple[int, ...] = SOCKET_PORT_CANDIDATES,
|
||||||
key_radix: int = 32,
|
key_radix: int = 16,
|
||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Send every :data:`PROBE_VARIANTS` envelope; return exact TX/RX bytes."""
|
"""Send every :data:`PROBE_VARIANTS` envelope; return exact TX/RX bytes."""
|
||||||
@@ -223,12 +240,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:
|
||||||
@@ -237,9 +251,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
|
||||||
@@ -249,9 +262,9 @@ _MQTT_CONNECT = bytes.fromhex("101200044d5154540402003c000670726f62653031")
|
|||||||
|
|
||||||
def probe_transport(
|
def probe_transport(
|
||||||
sn: str,
|
sn: str,
|
||||||
host: str = "10.100.1.1",
|
host: str = DEVICE_GATEWAY_IP,
|
||||||
ports: Tuple[int, ...] = (9000,),
|
ports: Tuple[int, ...] = (9000,),
|
||||||
key_radix: int = 32,
|
key_radix: int = 16,
|
||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Transport-level probes against one service port.
|
"""Transport-level probes against one service port.
|
||||||
@@ -262,7 +275,7 @@ def probe_transport(
|
|||||||
prefixes (LE16/BE16/LE32), an MQTT CONNECT, and a double-send over one
|
prefixes (LE16/BE16/LE32), an MQTT CONNECT, and a double-send over one
|
||||||
persistent connection. Each entry carries exact ``tx``/``rx`` bytes.
|
persistent connection. Each entry carries exact ``tx``/``rx`` bytes.
|
||||||
"""
|
"""
|
||||||
from .protocol import decrypt_data
|
from .protocol.messages import EnvelopeCodec
|
||||||
|
|
||||||
results: List[Dict[str, Any]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
for port in ports:
|
for port in ports:
|
||||||
@@ -295,12 +308,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"]:
|
||||||
decoded = decrypt_data(entry["rx"], radix=key_radix)
|
entry["reply"] = EnvelopeCodec.for_serial(
|
||||||
if decoded:
|
sn, radix=key_radix).try_decode(entry["rx"])
|
||||||
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)
|
||||||
@@ -318,12 +327,12 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
description="Read aGate battery percentage over the device SoftAP socket."
|
description="Read aGate battery percentage over the device SoftAP socket."
|
||||||
)
|
)
|
||||||
parser.add_argument("--sn", required=True, help="20-char device serial")
|
parser.add_argument("--sn", required=True, help="20-char device serial")
|
||||||
parser.add_argument("--host", default="10.100.1.1", help="aGate gateway IP")
|
parser.add_argument("--host", default=DEVICE_GATEWAY_IP, help="aGate gateway IP")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--ports", default=",".join(map(str, SOCKET_PORT_CANDIDATES)),
|
"--ports", default=",".join(map(str, SOCKET_PORT_CANDIDATES)),
|
||||||
help="comma-separated socket ports to probe",
|
help="comma-separated socket ports to probe",
|
||||||
)
|
)
|
||||||
parser.add_argument("--key-radix", type=int, default=32, choices=(16, 32))
|
parser.add_argument("--key-radix", type=int, default=16, choices=(16, 32))
|
||||||
parser.add_argument("--timeout", type=float, default=5.0)
|
parser.add_argument("--timeout", type=float, default=5.0)
|
||||||
parser.add_argument("--show-hotspot", action="store_true",
|
parser.add_argument("--show-hotspot", action="store_true",
|
||||||
help="print derived AP SSID/password and exit")
|
help="print derived AP SSID/password and exit")
|
||||||
|
|||||||
@@ -7,22 +7,39 @@ from __future__ import annotations
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from .bluetooth import BluetoothManager
|
from .bluetooth import BluetoothManager
|
||||||
from .wifi import HotspotProfile, WifiBackend, WifiManager
|
from .wifi import DEVICE_GATEWAY_IP, HotspotProfile, WifiBackend, WifiManager
|
||||||
|
|
||||||
|
|
||||||
class AgateClient:
|
class AgateClient:
|
||||||
"""Main client for FranklinWH AGATE device operations."""
|
"""Main client for FranklinWH AGATE device operations.
|
||||||
|
|
||||||
|
:param device_id: optional default device identifier.
|
||||||
|
:param wifi_backend: platform backend used for hotspot joins.
|
||||||
|
:param address: optional default aGate address (IP or hostname).
|
||||||
|
Overrides the ``10.100.1.1`` SoftAP default for direct-connect
|
||||||
|
calls — e.g. the home-LAN address (``192.168.1.x``) once the unit
|
||||||
|
is on the router. Per-call ``address``/``host``/``gateway_ip``
|
||||||
|
arguments take precedence over this default.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None,
|
def __init__(self, device_id: Optional[str] = None,
|
||||||
wifi_backend: Optional[WifiBackend] = None) -> None:
|
wifi_backend: Optional[WifiBackend] = None,
|
||||||
|
address: Optional[str] = None) -> None:
|
||||||
self.device_id = device_id
|
self.device_id = device_id
|
||||||
self.bluetooth_manager: Optional[BluetoothManager] = None
|
self.bluetooth_manager: Optional[BluetoothManager] = None
|
||||||
self.wifi_manager = WifiManager(backend=wifi_backend)
|
self.wifi_manager = WifiManager(backend=wifi_backend)
|
||||||
|
self.address = address
|
||||||
|
|
||||||
# -- device hotspot (local connect) -------------------------------------
|
# -- device hotspot (local connect) -------------------------------------
|
||||||
def join_hotspot(self, sn: str, **kwargs: Any) -> HotspotProfile:
|
def join_hotspot(self, sn: str, gateway_ip: Optional[str] = None,
|
||||||
"""Join the device SoftAP (derives creds, joins, verifies)."""
|
**kwargs: Any) -> HotspotProfile:
|
||||||
return self.wifi_manager.join_device_hotspot(sn, **kwargs)
|
"""Join the device SoftAP (derives creds, joins, verifies).
|
||||||
|
|
||||||
|
``gateway_ip`` overrides the address recorded on the profile
|
||||||
|
without changing the radio join.
|
||||||
|
"""
|
||||||
|
return self.wifi_manager.join_device_hotspot(
|
||||||
|
sn, gateway_ip=gateway_ip or self.address, **kwargs)
|
||||||
|
|
||||||
def leave_hotspot(self) -> None:
|
def leave_hotspot(self) -> None:
|
||||||
"""Disconnect from the device hotspot."""
|
"""Disconnect from the device hotspot."""
|
||||||
@@ -30,9 +47,23 @@ class AgateClient:
|
|||||||
|
|
||||||
def attach_local(self, sn: str, *, verify_join: bool = True,
|
def attach_local(self, sn: str, *, verify_join: bool = True,
|
||||||
settle_timeout: float = 30.0, poll_interval: float = 1.0,
|
settle_timeout: float = 30.0, poll_interval: float = 1.0,
|
||||||
|
address: Optional[str] = None,
|
||||||
|
host: Optional[str] = None,
|
||||||
|
gateway_ip: Optional[str] = None,
|
||||||
|
join: Optional[bool] = None,
|
||||||
**local_kwargs: Any):
|
**local_kwargs: Any):
|
||||||
"""Join the device hotspot, then return a ``LocalDeviceClient``
|
"""Join the device hotspot, then return a ``LocalDeviceClient``.
|
||||||
aimed at the device gateway (``https://10.100.1.1:18000`` by default).
|
|
||||||
|
Without an explicit address this keeps the existing behaviour:
|
||||||
|
join the SoftAP and aim at the profile gateway
|
||||||
|
(``https://10.100.1.1:18000`` by default).
|
||||||
|
|
||||||
|
Pass ``address`` (``host``/``gateway_ip`` are aliases) to reach the
|
||||||
|
aGate elsewhere — e.g. its home-LAN IP once it is on the router.
|
||||||
|
An explicit address defaults to *skipping* the hotspot radio join
|
||||||
|
(pass ``join=True`` to force it); ``join=False`` never touches the
|
||||||
|
radio. The client-level ``address`` default applies when no
|
||||||
|
per-call address is given.
|
||||||
|
|
||||||
``verify_join`` gates the association check (TLS verification of the
|
``verify_join`` gates the association check (TLS verification of the
|
||||||
device API is a separate concern — pass ``verify=`` in
|
device API is a separate concern — pass ``verify=`` in
|
||||||
@@ -45,13 +76,21 @@ class AgateClient:
|
|||||||
(``port``, ``scheme``, ``verify``, ``socket_port``, ``key_radix``,
|
(``port``, ``scheme``, ``verify``, ``socket_port``, ``key_radix``,
|
||||||
``timeout``, ``extra_headers``, ``session``).
|
``timeout``, ``extra_headers``, ``session``).
|
||||||
"""
|
"""
|
||||||
profile = self.wifi_manager.join_device_hotspot(
|
explicit = address if address is not None else (
|
||||||
sn, verify=verify_join, settle_timeout=settle_timeout,
|
host if host is not None else gateway_ip)
|
||||||
poll_interval=poll_interval,
|
target = explicit if explicit is not None else self.address
|
||||||
)
|
do_join = (target is None) if join is None else join
|
||||||
|
if do_join:
|
||||||
|
profile = self.wifi_manager.join_device_hotspot(
|
||||||
|
sn, verify=verify_join, settle_timeout=settle_timeout,
|
||||||
|
poll_interval=poll_interval,
|
||||||
|
gateway_ip=target,
|
||||||
|
)
|
||||||
|
target = profile.gateway_ip
|
||||||
from .device_local import LocalDeviceClient
|
from .device_local import LocalDeviceClient
|
||||||
|
|
||||||
return LocalDeviceClient(sn=sn, host=profile.gateway_ip, **local_kwargs)
|
return LocalDeviceClient(sn=sn, host=target or DEVICE_GATEWAY_IP,
|
||||||
|
**local_kwargs)
|
||||||
|
|
||||||
# -- cloud / stub operations (pending RE) -------------------------------
|
# -- cloud / stub operations (pending RE) -------------------------------
|
||||||
def query_agate_info(self, device_id: Optional[str] = None):
|
def query_agate_info(self, device_id: Optional[str] = None):
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
"""Local (direct-connect) device client.
|
"""Local (direct-connect) device client.
|
||||||
|
|
||||||
Talks to the aGate **on its own network** after joining the device hotspot —
|
Talks to the aGate **on its own network** after joining the device hotspot —
|
||||||
the ``mode == local`` path of the app (see ``docs/RE_DART_FINDINGS.md`` §8,
|
the direct-connect path of the app (see ``docs/RE_DART_FINDINGS.md`` §8,
|
||||||
§11). Two channels:
|
§11, §14). Two channels:
|
||||||
|
|
||||||
* **HTTP** to ``https://IP:PORT/terminal/...`` — the ``/terminal/*`` local
|
* **HTTP** to ``https://IP:PORT/terminal/...`` — the ``/terminal/*`` local
|
||||||
endpoint family (hotspot info, aGate info, overall info, MQTT relay, ...).
|
endpoint family (hotspot info, aGate info, overall info, MQTT relay, ...).
|
||||||
Scheme/port are *not* confirmed (Open item #3); defaults are candidates.
|
Scheme/port are *not* confirmed live (Open item #3); defaults follow the
|
||||||
* **TCP socket** to ``10.100.1.1:18000`` (candidate port — Open item #4)
|
app's ``RemoteService`` shape.
|
||||||
carrying the encrypted :class:`~franklinwh_agate_client.protocol.CommunicationObject`
|
* **TCP socket** to ``10.100.1.1:9000`` — verified live 2026-10-16. The
|
||||||
envelope with little-endian int16 length framing.
|
app dials the gateway IP port ``18000`` (``0x4650``), which is closed on
|
||||||
|
the tested unit; ``9000`` answers the same encrypted
|
||||||
|
:class:`~franklinwh_agate_client.protocol.CommunicationObject` envelope
|
||||||
|
sent **raw** (``dart:io _Socket.add``, no length prefix), with socket-id
|
||||||
|
``cmdType`` values (``SOCKET_CMD_TYPE``) and ``opt``-seeded payloads.
|
||||||
|
Replies use the identical framing with id ``request + 1``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
@@ -27,13 +31,11 @@ from .protocol import (
|
|||||||
CommunicationOperate,
|
CommunicationOperate,
|
||||||
SequenceNumber,
|
SequenceNumber,
|
||||||
SocketFrameDecoder,
|
SocketFrameDecoder,
|
||||||
decrypt_data,
|
|
||||||
decrypt_request,
|
|
||||||
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__)
|
||||||
@@ -64,8 +66,8 @@ class LocalDeviceClient:
|
|||||||
:param socket_port: local TCP socket port (candidate 18000).
|
:param socket_port: local TCP socket port (candidate 18000).
|
||||||
:param verify: TLS verification for the device API (device likely serves a
|
:param verify: TLS verification for the device API (device likely serves a
|
||||||
self-signed cert, so ``False`` is the practical default).
|
self-signed cert, so ``False`` is the practical default).
|
||||||
:param key_radix: key parse radix for the envelope cipher (32, per the
|
:param key_radix: key parse radix for the envelope cipher (16 —
|
||||||
``int.tryParse(key, radix: 32)`` call in both directions).
|
default ``int.tryParse`` radix, verified against live captures).
|
||||||
:param timeout: per-request timeout in seconds.
|
:param timeout: per-request timeout in seconds.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ class LocalDeviceClient:
|
|||||||
scheme: str = "https",
|
scheme: str = "https",
|
||||||
verify: Optional[bool] = False,
|
verify: Optional[bool] = False,
|
||||||
socket_port: Optional[int] = None,
|
socket_port: Optional[int] = None,
|
||||||
key_radix: int = 32,
|
key_radix: int = 16,
|
||||||
timeout: float = 10.0,
|
timeout: float = 10.0,
|
||||||
extra_headers: Optional[Dict[str, str]] = None,
|
extra_headers: Optional[Dict[str, str]] = None,
|
||||||
session: Optional[requests.Session] = None,
|
session: Optional[requests.Session] = None,
|
||||||
@@ -92,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(
|
||||||
@@ -181,63 +184,72 @@ class LocalDeviceClient:
|
|||||||
``increaseSN`` counter, serialised to compact JSON,
|
``increaseSN`` counter, serialised to compact JSON,
|
||||||
``encrypt_object``-ed, and sent **raw** (``_Socket.add``, no length
|
``encrypt_object``-ed, and sent **raw** (``_Socket.add``, no length
|
||||||
prefix). The reply is accumulated chunk by chunk and run through
|
prefix). The reply is accumulated chunk by chunk and run through
|
||||||
``decrypt_data`` until it yields a JSON object.
|
``decrypt_wire`` (2nd-comma split, equipNo-derived key) until it
|
||||||
|
yields a JSON object.
|
||||||
"""
|
"""
|
||||||
if envelope is None:
|
if envelope is None:
|
||||||
envelope = direct_connect_request(
|
envelope = direct_connect_request(
|
||||||
cmd, equip_no or self.sn, data=data,
|
cmd, self.sn if equip_no is None else equip_no, data=data,
|
||||||
snno=self._sn_seq.next(),
|
snno=self._sn_seq.next(), operate=operate,
|
||||||
)
|
)
|
||||||
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:
|
||||||
reply = decrypt_data(raw, radix=self.key_radix)
|
reply = self.codec.decode(raw).to_dict()
|
||||||
if not reply:
|
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,
|
||||||
|
operate: CommunicationOperate = CommunicationOperate.QUERY,
|
||||||
) -> 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(), operate=operate,
|
||||||
)
|
)
|
||||||
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:
|
|
||||||
decoded = decrypt_data(reply_raw, radix=self.key_radix)
|
|
||||||
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:
|
||||||
@@ -268,18 +280,43 @@ class LocalDeviceClient:
|
|||||||
break
|
break
|
||||||
if collect_only:
|
if collect_only:
|
||||||
return bytes(received)
|
return bytes(received)
|
||||||
if decrypt_data(bytes(received), radix=self.key_radix):
|
try:
|
||||||
return bytes(received)
|
self.codec.decode(bytes(received))
|
||||||
raise ConnectionError(
|
except ValueError:
|
||||||
f"no reply frame from {self.socket_host}:{self.socket_port}"
|
raise ConnectionError(
|
||||||
)
|
f"no reply frame from {self.socket_host}:{self.socket_port}"
|
||||||
|
)
|
||||||
|
return bytes(received)
|
||||||
|
|
||||||
# ------------------------------------------------------- convenience RPCs
|
# ------------------------------------------------------- convenience RPCs
|
||||||
def query_device_info(self) -> Dict[str, Any]:
|
def query_device_info(self) -> Dict[str, Any]:
|
||||||
return self.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO)
|
return self.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO)
|
||||||
|
|
||||||
def query_bms_info(self) -> Dict[str, Any]:
|
def query_detail_info(self) -> Dict[str, Any]:
|
||||||
return self.send_rpc(CommunicationCmd.BMS)
|
"""``queryDeviceRealTimeData`` — realtime headline (socket id 1301).
|
||||||
|
|
||||||
|
No arguments. The reply ``dataArea`` carries ``totalPower``
|
||||||
|
(the household draw the home page polls) and ``apowerCount``.
|
||||||
|
"""
|
||||||
|
return self.send_rpc(CommunicationCmd.DEVICE_DETAIL_INFO)
|
||||||
|
|
||||||
|
def query_load_status(self) -> Dict[str, Any]:
|
||||||
|
"""``queryLoadStatusInfo`` — per smart-switch watts (socket id 1411).
|
||||||
|
|
||||||
|
No arguments. The reply ``dataArea`` carries per-channel
|
||||||
|
``Sw1Volt``/``SW1Curr``/``SW1ExpPower`` (and ``SW2…``) plus
|
||||||
|
``index``.
|
||||||
|
"""
|
||||||
|
return self.send_rpc(CommunicationCmd.LOAD_STATUS)
|
||||||
|
|
||||||
|
def query_bms_info(self, pack_id: int = 1) -> Dict[str, Any]:
|
||||||
|
"""``queryBmsInfoBySocket`` — BMS pack query (``arguments={"id"}``).
|
||||||
|
|
||||||
|
The pack index selects the battery (``BMS_SN`` ``…B1``/``…B2`` in
|
||||||
|
the device-info reply); omitting it yields the all-zero ``id: 0``
|
||||||
|
slot. ``batSoc``/``batSoh`` carry the pack charge/health.
|
||||||
|
"""
|
||||||
|
return self.send_rpc(CommunicationCmd.BMS, data={"id": int(pack_id)})
|
||||||
|
|
||||||
def query_mppt_info(self) -> Dict[str, Any]:
|
def query_mppt_info(self) -> Dict[str, Any]:
|
||||||
return self.send_rpc(CommunicationCmd.MPPT)
|
return self.send_rpc(CommunicationCmd.MPPT)
|
||||||
@@ -294,7 +331,13 @@ class LocalDeviceClient:
|
|||||||
return self.send_rpc(CommunicationCmd.EMS)
|
return self.send_rpc(CommunicationCmd.EMS)
|
||||||
|
|
||||||
def query_a_power(self) -> Dict[str, Any]:
|
def query_a_power(self) -> Dict[str, Any]:
|
||||||
return self.send_rpc(CommunicationCmd.DEVICE_APOWER_INFO)
|
"""``queryAPowerData`` — apower query (``arguments={"update": 2}``).
|
||||||
|
|
||||||
|
Note: the tested unit answers ``{"result": 1, "reason": -1}`` here;
|
||||||
|
prefer :meth:`query_bms_info` / :meth:`get_battery_soc` for charge.
|
||||||
|
"""
|
||||||
|
return self.send_rpc(CommunicationCmd.DEVICE_APOWER_INFO,
|
||||||
|
data={"update": 2})
|
||||||
|
|
||||||
def query_warning_list(self) -> Dict[str, Any]:
|
def query_warning_list(self) -> Dict[str, Any]:
|
||||||
return self.send_rpc(CommunicationCmd.DEVICE_ALARMS)
|
return self.send_rpc(CommunicationCmd.DEVICE_ALARMS)
|
||||||
@@ -311,3 +354,145 @@ class LocalDeviceClient:
|
|||||||
|
|
||||||
def query_modes_v2(self) -> Dict[str, Any]:
|
def query_modes_v2(self) -> Dict[str, Any]:
|
||||||
return self.send_rpc(CommunicationCmd.MODE_V2)
|
return self.send_rpc(CommunicationCmd.MODE_V2)
|
||||||
|
|
||||||
|
def join_home_wifi(self, ssid: str, password: str = "") -> Dict[str, Any]:
|
||||||
|
"""``connectWifi`` — join the aGate to a home Wi-Fi network.
|
||||||
|
|
||||||
|
Mirrors ``WifiOnboardingConnectProvider``: sends a
|
||||||
|
``ConnectWifiEntity`` shaped payload (``ssid``/``password`` plus
|
||||||
|
the ``wifi_SSID``/``wifi_Pw`` mirrors) with operate SET over the
|
||||||
|
current transport. Follow with :meth:`query_connection_status`
|
||||||
|
to confirm association.
|
||||||
|
|
||||||
|
.. warning:: State-changing: on success the unit leaves its
|
||||||
|
SoftAP (or roams), so the current socket/HTTP session drops.
|
||||||
|
Only run when you can rejoin the device network afterwards.
|
||||||
|
"""
|
||||||
|
return self.send_rpc(
|
||||||
|
CommunicationCmd.CONNECT_WIFI,
|
||||||
|
operate=CommunicationOperate.SET,
|
||||||
|
data={"ssid": ssid, "password": password,
|
||||||
|
"wifi_SSID": ssid, "wifi_Pw": password},
|
||||||
|
)
|
||||||
|
|
||||||
|
def scan_home_wifi(self) -> Dict[str, Any]:
|
||||||
|
"""``scanWifiList`` — networks visible to the aGate (socket 1109)."""
|
||||||
|
return self.send_rpc(CommunicationCmd.SCAN_WIFI_LIST)
|
||||||
|
|
||||||
|
def query_connection_status(self) -> Dict[str, Any]:
|
||||||
|
"""``connectionStatus`` — current STA association state."""
|
||||||
|
return self.send_rpc(CommunicationCmd.CONNECTION_STATUS)
|
||||||
|
|
||||||
|
# --------------------------------------------------- business operations
|
||||||
|
# Intention-revealing reads: each runs the app's query sequence for one
|
||||||
|
# question and returns ``{"value", "source", "detail", "reply", ...}``.
|
||||||
|
# Transport errors propagate (caller picks the next port/device);
|
||||||
|
# answered-but-empty replies yield ``value: None``.
|
||||||
|
|
||||||
|
def get_device_inventory(self) -> Dict[str, Any]:
|
||||||
|
"""Discovery + device info: identities, versions, FHP/BMS serials.
|
||||||
|
|
||||||
|
Mirrors the direct-connect open: a best-effort discovery query
|
||||||
|
(empty serial), then ``queryDeviceInfo``. Returns the parsed
|
||||||
|
device-info reply plus an ``identity`` summary (``ibg_sn``,
|
||||||
|
``fhp_sn``/``bms_sn`` lists, ``protocol_ver``).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
equip_no="",
|
||||||
|
data={"minProtocolVer": "V1.00.00"})
|
||||||
|
except Exception:
|
||||||
|
log.debug("discovery probe failed (best-effort)", exc_info=True)
|
||||||
|
reply = self.send_rpc(
|
||||||
|
CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
data={"minProtocolVer": "V1.00.00"})
|
||||||
|
data = reply.get("dataArea", {})
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
return {
|
||||||
|
"reply": reply,
|
||||||
|
"ibg_sn": reply.get("equipNo"),
|
||||||
|
"fhp_sn": list(data.get("FHP_SN", []) or []),
|
||||||
|
"bms_sn": list(data.get("BMS_SN", []) or []),
|
||||||
|
"protocol_ver": data.get("protocolVer"),
|
||||||
|
"result": data.get("result"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_battery_soc(self) -> Dict[str, Any]:
|
||||||
|
"""Aggregate battery charge percentage for this device.
|
||||||
|
|
||||||
|
Tries apower, then per-pack BMS reads (``id`` 1..2 — the id-less
|
||||||
|
slot answers all zeros), skipping empty placeholder slots.
|
||||||
|
Returns ``extract_soc`` fields plus ``reply``/``cmd`` context;
|
||||||
|
``value`` is ``None`` when nothing answered with charge data.
|
||||||
|
"""
|
||||||
|
from .battery import _is_empty_pack, extract_soc
|
||||||
|
|
||||||
|
try: # best-effort open, mirrors the direct-connect flow
|
||||||
|
self.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
equip_no="",
|
||||||
|
data={"minProtocolVer": "V1.00.00"})
|
||||||
|
except Exception:
|
||||||
|
log.debug("discovery probe failed (best-effort)", exc_info=True)
|
||||||
|
last: Dict[str, Any] = {"value": None, "source": None,
|
||||||
|
"detail": {}, "reply": None, "cmd": None}
|
||||||
|
for cmd, data in (
|
||||||
|
(CommunicationCmd.DEVICE_APOWER_INFO, {"update": 2}),
|
||||||
|
(CommunicationCmd.BMS, {"id": 1}),
|
||||||
|
(CommunicationCmd.BMS, {"id": 2}),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
reply = self.send_rpc(cmd, data=data)
|
||||||
|
except (ConnectionError, socket.error, TimeoutError):
|
||||||
|
raise
|
||||||
|
except Exception as exc: # envelope-level failure; next attempt
|
||||||
|
log.debug("get_battery_soc %s error: %s", cmd, exc)
|
||||||
|
continue
|
||||||
|
result = extract_soc(reply)
|
||||||
|
result.update({"reply": reply, "cmd": str(cmd)})
|
||||||
|
if (result["value"] is not None
|
||||||
|
and not _is_empty_pack(reply, result)):
|
||||||
|
return result
|
||||||
|
last = result
|
||||||
|
if last["value"] is not None and _is_empty_pack(
|
||||||
|
last.get("reply") or {}, last):
|
||||||
|
# every attempt hit placeholder slots: unknown, not 0 %.
|
||||||
|
last = dict(last, value=None, source=None)
|
||||||
|
return last
|
||||||
|
|
||||||
|
def get_household_wattage(self) -> Dict[str, Any]:
|
||||||
|
"""Live household wattage draw for this device.
|
||||||
|
|
||||||
|
Tries realtime detail (``totalPower``), then per-switch load
|
||||||
|
status, then EMS current×voltage math. Returns ``extract_power``
|
||||||
|
fields plus ``reply``/``cmd`` context; ``value`` is ``None`` when
|
||||||
|
nothing answered with power data.
|
||||||
|
"""
|
||||||
|
from .power import _power_detail, ems_load_watts, extract_power
|
||||||
|
|
||||||
|
last: Dict[str, Any] = {"value": None, "source": None,
|
||||||
|
"detail": {}, "reply": None, "cmd": None}
|
||||||
|
for name, call in (
|
||||||
|
("detail", self.query_detail_info),
|
||||||
|
("load", self.query_load_status),
|
||||||
|
("ems", self.query_ems_info),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
reply = call()
|
||||||
|
except (ConnectionError, socket.error, TimeoutError):
|
||||||
|
raise
|
||||||
|
except Exception as exc: # envelope-level failure; next attempt
|
||||||
|
log.debug("get_household_wattage %s error: %s", name, exc)
|
||||||
|
continue
|
||||||
|
result = extract_power(reply)
|
||||||
|
if result["value"] is None and name == "ems":
|
||||||
|
watts = ems_load_watts(reply)
|
||||||
|
if watts is not None:
|
||||||
|
result = {"value": watts, "source": "ems(loadCurr*V)",
|
||||||
|
"detail": _power_detail(
|
||||||
|
reply.get("dataArea", reply))}
|
||||||
|
result.update({"reply": reply, "cmd": name})
|
||||||
|
if result["value"] is not None:
|
||||||
|
return result
|
||||||
|
last = result
|
||||||
|
return last
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Live household power over the local socket.
|
||||||
|
|
||||||
|
The app's home page polls ``queryDeviceRealTimeData`` (socket
|
||||||
|
``DEVICE_DETAIL_INFO``) for its energy-flow screen; per-switch watts come
|
||||||
|
from ``queryLoadStatusInfo`` (socket ``LOAD_STATUS``); grid/load
|
||||||
|
current+voltage telemetry comes from ``queryEmsInfoBySocket``. See
|
||||||
|
``docs/RE_DART_FINDINGS.md`` §4 (ids) and §16.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .battery import _walk
|
||||||
|
from .device_local import LocalDeviceClient
|
||||||
|
from .wifi import DEVICE_GATEWAY_IP
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HOST = DEVICE_GATEWAY_IP
|
||||||
|
PORTS: Tuple[int, ...] = (9000, 18000)
|
||||||
|
|
||||||
|
#: Reply keys holding watt readings, in priority order.
|
||||||
|
POWER_KEYS: Tuple[str, ...] = (
|
||||||
|
"totalPower",
|
||||||
|
"homeloadPower",
|
||||||
|
"gridPower",
|
||||||
|
"solarPower",
|
||||||
|
"SW1ExpPower",
|
||||||
|
"SW2ExpPower",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(value: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_power(reply: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Pull watt readings out of a parsed reply envelope.
|
||||||
|
|
||||||
|
Returns ``{"value": float|None, "source": str|None, "detail": {...}}``.
|
||||||
|
``value`` is the first ``POWER_KEYS`` hit (headline: ``totalPower``);
|
||||||
|
``detail`` carries the supporting fields (per-switch watts/currents,
|
||||||
|
grid volts/amps) when present.
|
||||||
|
"""
|
||||||
|
data = reply.get("dataArea", reply)
|
||||||
|
if isinstance(data, str):
|
||||||
|
try:
|
||||||
|
data = json.loads(data)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
data = {}
|
||||||
|
for key in POWER_KEYS:
|
||||||
|
for hit in _walk(reply, key):
|
||||||
|
num = _to_float(hit)
|
||||||
|
if num is not None:
|
||||||
|
return {"value": num, "source": key,
|
||||||
|
"detail": _power_detail(data)}
|
||||||
|
return {"value": None, "source": None, "detail": _power_detail(data)}
|
||||||
|
|
||||||
|
|
||||||
|
def _power_detail(data: Any) -> Dict[str, Any]:
|
||||||
|
detail: Dict[str, Any] = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return detail
|
||||||
|
for key in (
|
||||||
|
"totalPower", "apowerCount", "homeloadPower", "solarPower",
|
||||||
|
"gridPower", "SW1ExpPower", "SW2ExpPower", "Sw1Volt", "Sw2Volt",
|
||||||
|
"SW1Curr", "gridVol1", "gridVol2", "gridCurr1", "gridCurr2",
|
||||||
|
"loadCurr1", "loadCurr2", "gridLineVol", "gridFreq",
|
||||||
|
):
|
||||||
|
hits = _walk(data, key)
|
||||||
|
if hits:
|
||||||
|
detail[key] = hits[0] if len(hits) == 1 else hits
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def ems_load_watts(reply: Dict[str, Any]) -> Optional[float]:
|
||||||
|
"""Estimate household draw from an EMS reply, when present.
|
||||||
|
|
||||||
|
``(loadCurr1 + loadCurr2) * (gridVol1 + gridVol2) / 2`` — per-leg
|
||||||
|
current times mean leg voltage. Returns ``None`` if the fields are
|
||||||
|
absent (the EMS reply on the tested unit carries live grid Vol/Curr).
|
||||||
|
"""
|
||||||
|
data = reply.get("dataArea", reply)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
amps = float(data["loadCurr1"]) + float(data["loadCurr2"])
|
||||||
|
volts = (float(data["gridVol1"]) + float(data["gridVol2"])) / 2
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return amps * volts
|
||||||
|
|
||||||
|
|
||||||
|
def get_power(
|
||||||
|
sn: str,
|
||||||
|
host: str = HOST,
|
||||||
|
ports: Tuple[int, ...] = PORTS,
|
||||||
|
key_radix: int = 16,
|
||||||
|
timeout: float = 5.0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Read live household wattage over the local TCP socket.
|
||||||
|
|
||||||
|
Tries realtime detail (``DEVICE_DETAIL_INFO`` → ``totalPower``),
|
||||||
|
then per-switch load status, then EMS telemetry, probing each port.
|
||||||
|
Returns the ``extract_power`` dict plus ``cmd``/``port``/``reply``.
|
||||||
|
"""
|
||||||
|
last_err: Optional[Exception] = None
|
||||||
|
for port in ports:
|
||||||
|
client = LocalDeviceClient(
|
||||||
|
sn=sn, host=host, socket_port=port,
|
||||||
|
key_radix=key_radix, timeout=timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = client.get_household_wattage()
|
||||||
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
|
last_err = exc
|
||||||
|
log.debug("port %d failed: %s", port, exc)
|
||||||
|
continue # port unreachable — try next port
|
||||||
|
if result["value"] is not None:
|
||||||
|
result.update({"port": port})
|
||||||
|
return result
|
||||||
|
last_err = ValueError(
|
||||||
|
f"no power field in replies via {host}:{port}")
|
||||||
|
raise ConnectionError(f"no power reply via {host}:{list(ports)}") from last_err
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Read live household wattage over the device SoftAP socket.")
|
||||||
|
parser.add_argument("--sn", required=True, help="20-char device serial")
|
||||||
|
parser.add_argument("--host", default=HOST, help="aGate gateway IP")
|
||||||
|
parser.add_argument("--ports", default=",".join(map(str, PORTS)),
|
||||||
|
help="comma-separated socket ports to probe")
|
||||||
|
parser.add_argument("--timeout", type=float, default=5.0)
|
||||||
|
parser.add_argument("--raw", action="store_true",
|
||||||
|
help="print full reply JSON")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
ports = tuple(int(p) for p in args.ports.split(",") if p.strip())
|
||||||
|
try:
|
||||||
|
result = get_power(args.sn, host=args.host, ports=ports,
|
||||||
|
timeout=args.timeout)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: {exc}")
|
||||||
|
return 1
|
||||||
|
if args.raw:
|
||||||
|
print(json.dumps(result["reply"], indent=2, default=str))
|
||||||
|
else:
|
||||||
|
value = result["value"]
|
||||||
|
print(f"{value:g}W ({result['source']})"
|
||||||
|
if value is not None else "unknown")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -4,7 +4,13 @@ Everything here is hardware-independent and covered by unit tests. See
|
|||||||
``docs/RE_DART_FINDINGS.md`` for the reverse-engineering evidence.
|
``docs/RE_DART_FINDINGS.md`` for the reverse-engineering evidence.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .commands import CommunicationCmd, CommunicationMode, CommunicationOperate
|
from .commands import (
|
||||||
|
CommunicationCmd,
|
||||||
|
CommunicationMode,
|
||||||
|
CommunicationOperate,
|
||||||
|
SOCKET_CMD_TYPE,
|
||||||
|
socket_cmd_type,
|
||||||
|
)
|
||||||
from .crc import (
|
from .crc import (
|
||||||
CRC16_INIT,
|
CRC16_INIT,
|
||||||
CRC16_POLY,
|
CRC16_POLY,
|
||||||
@@ -20,10 +26,12 @@ from .crypto import (
|
|||||||
decrypt_data,
|
decrypt_data,
|
||||||
decrypt_object,
|
decrypt_object,
|
||||||
decrypt_request,
|
decrypt_request,
|
||||||
|
decrypt_wire,
|
||||||
encrypt_object,
|
encrypt_object,
|
||||||
generate_secret_key,
|
generate_secret_key,
|
||||||
header_len_for,
|
header_len_for,
|
||||||
split_envelope,
|
split_envelope,
|
||||||
|
split_wire,
|
||||||
)
|
)
|
||||||
from .envelope import (
|
from .envelope import (
|
||||||
KEY_ORDER,
|
KEY_ORDER,
|
||||||
@@ -35,8 +43,12 @@ from .envelope import (
|
|||||||
direct_connect_request,
|
direct_connect_request,
|
||||||
)
|
)
|
||||||
from .framing import (
|
from .framing import (
|
||||||
|
BLE_HEADER_LEN,
|
||||||
MAGIC_ACK,
|
MAGIC_ACK,
|
||||||
MAGIC_DATA,
|
MAGIC_DATA,
|
||||||
|
MAGIC_MTU,
|
||||||
|
ble_frame_sn,
|
||||||
|
ble_frame_total_len,
|
||||||
build_ble_ack_frame,
|
build_ble_ack_frame,
|
||||||
build_ble_data_frame,
|
build_ble_data_frame,
|
||||||
chunk_for_mtu,
|
chunk_for_mtu,
|
||||||
@@ -49,11 +61,14 @@ 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",
|
||||||
"CommunicationMode",
|
"CommunicationMode",
|
||||||
"CommunicationOperate",
|
"CommunicationOperate",
|
||||||
|
"SOCKET_CMD_TYPE",
|
||||||
|
"socket_cmd_type",
|
||||||
"CRC16_INIT",
|
"CRC16_INIT",
|
||||||
"CRC16_POLY",
|
"CRC16_POLY",
|
||||||
"crc16_modbus",
|
"crc16_modbus",
|
||||||
@@ -69,6 +84,8 @@ __all__ = [
|
|||||||
"decrypt_object",
|
"decrypt_object",
|
||||||
"decrypt_request",
|
"decrypt_request",
|
||||||
"decrypt_data",
|
"decrypt_data",
|
||||||
|
"decrypt_wire",
|
||||||
|
"split_wire",
|
||||||
"header_len_for",
|
"header_len_for",
|
||||||
"KEY_ORDER",
|
"KEY_ORDER",
|
||||||
"SNNO_MIN",
|
"SNNO_MIN",
|
||||||
@@ -90,4 +107,5 @@ __all__ = [
|
|||||||
"chunk_for_mtu",
|
"chunk_for_mtu",
|
||||||
"frame_socket_payload",
|
"frame_socket_payload",
|
||||||
"SocketFrameDecoder",
|
"SocketFrameDecoder",
|
||||||
|
"EnvelopeCodec",
|
||||||
]
|
]
|
||||||
@@ -84,3 +84,73 @@ class CommunicationCmd(IntEnum):
|
|||||||
BREAKER_COM_STATE = 0x32
|
BREAKER_COM_STATE = 0x32
|
||||||
BREAKER_LOAD_STATUS = 0x33
|
BREAKER_LOAD_STATUS = 0x33
|
||||||
BREAKER_IDENTIFY_ME = 0x34
|
BREAKER_IDENTIFY_ME = 0x34
|
||||||
|
|
||||||
|
|
||||||
|
#: ``cmdType`` values on the socket/Bluetooth/local transports.
|
||||||
|
#:
|
||||||
|
#: ``CommunicationCmd::cmd(cmd, mode)`` returns the *second* id field
|
||||||
|
#: (``off_14`` in the pool) for ``socket``/``bluetooth``/``local`` modes and
|
||||||
|
#: throws for ``http`` — verified live: a PCAPdroid capture of the app shows
|
||||||
|
#: ``{"cmdType":1101, ...}`` (``QUERY_DEVICE_INFO`` here, ``off_8`` ``0x00``)
|
||||||
|
#: with the S1-plaintext-first framing (see ``docs/RE_DART_FINDINGS.md``
|
||||||
|
#: §5). The ``off_8`` values above are *not* used on the wire for these
|
||||||
|
#: transports. Entries mapped to ``None`` have no pool id (``0xFFFF…``).
|
||||||
|
SOCKET_CMD_TYPE = {
|
||||||
|
CommunicationCmd.QUERY_DEVICE_INFO: 0x44D,
|
||||||
|
CommunicationCmd.NETWORK_INFO: 0x45D,
|
||||||
|
CommunicationCmd.RESOURCE_ADDRESS: 0x4B5,
|
||||||
|
CommunicationCmd.SUNSPEC_MODBUS: 0x4B5,
|
||||||
|
CommunicationCmd.NETWORK_SWITCH: 0x45F,
|
||||||
|
CommunicationCmd.FEEDING_GRID_POWER: 0x6A5,
|
||||||
|
CommunicationCmd.MODE: 0x57B,
|
||||||
|
CommunicationCmd.MODE_V2: 0x6BD,
|
||||||
|
CommunicationCmd.MODE_SETTING_V2: 0x6BF,
|
||||||
|
CommunicationCmd.SOLAR_INFO: 0x76F,
|
||||||
|
CommunicationCmd.ENERGY_MANAGE_INFO: 0x4B3,
|
||||||
|
CommunicationCmd.GENERATOR_INFO: 0x76D,
|
||||||
|
CommunicationCmd.BLUETOOTH_HEARTBEAT: 0x7D7,
|
||||||
|
CommunicationCmd.SCAN_WIFI_LIST: 0x455,
|
||||||
|
CommunicationCmd.CONNECT_WIFI: 0x457,
|
||||||
|
CommunicationCmd.CONNECTION_STATUS: 0x459,
|
||||||
|
CommunicationCmd.NETWORK_CHANNEL_STATUS: 0x46D,
|
||||||
|
CommunicationCmd.NETWORK_CHANNEL: 0x46B,
|
||||||
|
CommunicationCmd.PAIR_POWER: 0x44F,
|
||||||
|
CommunicationCmd.PROCESS_PAIR_POWER: 0x451,
|
||||||
|
CommunicationCmd.CHECK_BLUETOOTH_PASSWORD: 0x7D5,
|
||||||
|
CommunicationCmd.GO_OFF_GRID_STATUS: 0x6BB,
|
||||||
|
CommunicationCmd.A_POWER_LED_SWITCH: None,
|
||||||
|
CommunicationCmd.DEVICE_DETAIL_INFO: 0x515,
|
||||||
|
CommunicationCmd.DEVICE_APOWER_INFO: 0x51B,
|
||||||
|
CommunicationCmd.DEVICE_ALARMS: 0x725,
|
||||||
|
CommunicationCmd.PE: 0x6A7,
|
||||||
|
CommunicationCmd.BMS: 0x6A9,
|
||||||
|
CommunicationCmd.EMS: 0x6AB,
|
||||||
|
CommunicationCmd.MPPT: 0x6B5,
|
||||||
|
CommunicationCmd.A_HUB: 0x771,
|
||||||
|
CommunicationCmd.CT_TEST_REVERSE: None,
|
||||||
|
CommunicationCmd.DEVICE_DATA: None,
|
||||||
|
CommunicationCmd.LOAD_STATUS: 0x583,
|
||||||
|
CommunicationCmd.LOAD_STATUS_2: 0x587,
|
||||||
|
CommunicationCmd.SMART_LOAD: 0x581,
|
||||||
|
CommunicationCmd.SMART_LOAD_2: 0x585,
|
||||||
|
CommunicationCmd.REBOOT_DEVICE: 0x6B9,
|
||||||
|
CommunicationCmd.DEBUG_MODE: 0x71D,
|
||||||
|
CommunicationCmd.POWER_ON: 0x71F,
|
||||||
|
CommunicationCmd.CHARGE_DEVICE: 0x721,
|
||||||
|
CommunicationCmd.CLEAR_FAULT: 0x6AD,
|
||||||
|
CommunicationCmd.DEVICE_ADDRESS_INFO: 0x4B1,
|
||||||
|
CommunicationCmd.OVERLOAD_CUT_OFF: 0x6A5,
|
||||||
|
CommunicationCmd.WIFI_FREQUECY: 0x46F,
|
||||||
|
CommunicationCmd.BREAKER_COMMISSION_NOTIFY: None,
|
||||||
|
CommunicationCmd.BREAKER_COM_STATE: None,
|
||||||
|
CommunicationCmd.BREAKER_LOAD_STATUS: 0x471,
|
||||||
|
CommunicationCmd.BREAKER_IDENTIFY_ME: 0x473,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def socket_cmd_type(cmd: CommunicationCmd) -> int:
|
||||||
|
"""Wire ``cmdType`` for *cmd* on the socket transport (``cmd()``)."""
|
||||||
|
value = SOCKET_CMD_TYPE[cmd]
|
||||||
|
if value is None:
|
||||||
|
raise KeyError(f"{cmd} has no socket-transport command id")
|
||||||
|
return int(value)
|
||||||
@@ -5,42 +5,42 @@ Reconstructed from the blutter disassembly; see
|
|||||||
|
|
||||||
Request wire form of an envelope::
|
Request wire form of an envelope::
|
||||||
|
|
||||||
s = compact JSON, e.g. {"cmdType":25,"equipNo":"SN",...}
|
s = compact JSON, e.g. {"cmdType":1101,"equipNo":"SN",...}
|
||||||
S1 = parts[0] + "," + parts[1] + "," # quartet header, trailing comma
|
S1 = parts[0] + "," + parts[1] + "," # quartet header, trailing comma
|
||||||
S2 = ",".join(parts[2:]) # the rest of the JSON
|
S2 = ",".join(parts[2:]) # the rest of the JSON
|
||||||
a = UTF-8(S1) # sent PLAINTEXT first
|
a = UTF-8(S1) # sent PLAINTEXT first
|
||||||
n = int.tryParse(key, radix: 32) or 0
|
n = int.tryParse(key) or 0 # default radix 16
|
||||||
wire = a || bytes((S2[i] + i + len(a) + n) & 0xFF)
|
wire = a || bytes((S2[i] + i + len(a) + n) & 0xFF)
|
||||||
|
|
||||||
i.e. the two leading JSON parts go on the wire **unchanged first**, and the
|
i.e. the two leading JSON parts go on the wire **unchanged first**, and the
|
||||||
tail is transformed byte-wise with a position-dependent additive stream
|
tail is transformed byte-wise with a position-dependent additive stream
|
||||||
(index ``i`` is relative to the tail; ``i + len(a)`` is the absolute JSON
|
(index ``i`` is relative to the tail; ``i + len(a)`` is the absolute JSON
|
||||||
index). The receiver that knows the serial can compute ``len(a)`` up front
|
index). Both directions use this same framing (verified live: a 1154-byte
|
||||||
(``header_len_for``) and split the wire deterministically.
|
device reply decrypts with the identical split — see ``decrypt_wire``).
|
||||||
|
|
||||||
Responses use a different framing (``decrypt_data``): the wire holds the
|
.. note:: An earlier revision claimed radix 32 from a ``0x20`` literal
|
||||||
plaintext JSON prefix ``s`` (which itself contains the serial's ``X``),
|
next to the ``tryParse`` call sites. Two independent live frames (an
|
||||||
a literal ``0x58`` separator, then the encoded tail::
|
app discovery request and a device info reply) decrypt cleanly only
|
||||||
|
with radix 16 (``int("80", 16) == 128``, ``int("1c", 16) == 28``) and
|
||||||
|
then verify byte-exact (``len``/uppercase CRC-32). Ground truth wins:
|
||||||
|
the ``0x20`` belongs to a neighbouring call.
|
||||||
|
|
||||||
wire = UTF-8(s) || 0x58 || encode(tail)
|
Confirmed from the disassembly *and live traffic*:
|
||||||
|
|
||||||
``decrypt_data`` locates the *second* ``0x58`` byte ``j``, takes
|
|
||||||
``s = UTF-8(wire[:j])``, probes ``s + "}"`` as JSON to recover ``equipNo``
|
|
||||||
(and hence the key), decodes the tail with the same stream in reverse, and
|
|
||||||
returns ``s + "," + tail_plain``. Anything unexpected yields ``""`` (the
|
|
||||||
app's ``decryptData`` returns ``""`` on marker/parse failure and the socket
|
|
||||||
layer keeps accumulating bytes).
|
|
||||||
|
|
||||||
Confirmed from the disassembly:
|
|
||||||
|
|
||||||
* ``generateSecretKey`` = ``sum(utf8(sn)) & 0xFF`` rendered as lowercase hex
|
* ``generateSecretKey`` = ``sum(utf8(sn)) & 0xFF`` rendered as lowercase hex
|
||||||
with **no** zero padding (Dart ``_toPow2String(16)``).
|
with **no** zero padding (Dart ``_toPow2String(16)``). E.g. ``"1c"`` for
|
||||||
* The key is parsed with ``int.tryParse(key, radix: 32)`` — the literal
|
the gateway SN, ``"80"`` for the empty/placeholder SN.
|
||||||
``0x20`` (32) is materialised right before the call in both the encrypt
|
* The key is parsed with ``int.tryParse(key)`` — default radix **16**,
|
||||||
and decrypt paths.
|
verified against two live frames (``tests/fixtures/*.bin``).
|
||||||
* ``encryptObject(object, key)`` takes the object in the first slot and the
|
* ``encryptObject(object, key)`` takes the object in the first slot and the
|
||||||
key in the second; its result is ``a ++ transform(b)`` (plaintext header
|
key in the second; its result is ``a ++ transform(b)`` (plaintext header
|
||||||
first — ``addAll(transformed)`` onto the header copy).
|
first — ``addAll(transformed)`` onto the header copy).
|
||||||
|
* Replies use the same ``S1 || transform(S2)`` framing (the device info
|
||||||
|
reply decrypts identically). The reply ``cmdType`` observed live is the
|
||||||
|
request id + 1 (``1101`` → ``1102``); replies are therefore parsed with
|
||||||
|
:func:`decrypt_wire`, which locates the header without knowing the ids
|
||||||
|
up front. ``decrypt_data`` (``0x58``-marker model) is kept for the
|
||||||
|
Bluetooth path, which is unverified live.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -48,8 +48,10 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
#: Key radix used by both ``encryptObject`` and ``decryptData`` (literal 0x20).
|
#: Key radix used by the envelope cipher (default ``tryParse`` radix).
|
||||||
DEFAULT_KEY_RADIX = 32
|
#: Verified live: ``int("80", 16) == 128`` and ``int("1c", 16) == 28``
|
||||||
|
#: decode two independent captured frames byte-exact.
|
||||||
|
DEFAULT_KEY_RADIX = 16
|
||||||
|
|
||||||
#: Response separator byte (``0x58``, ASCII ``X``).
|
#: Response separator byte (``0x58``, ASCII ``X``).
|
||||||
MARKER = 0x58
|
MARKER = 0x58
|
||||||
@@ -143,6 +145,53 @@ def decrypt_request(
|
|||||||
decrypt_object = decrypt_request
|
decrypt_object = decrypt_request
|
||||||
|
|
||||||
|
|
||||||
|
def split_wire(wire: bytes) -> Tuple[bytes, bytes]:
|
||||||
|
"""Split any envelope wire into ``(a, b)`` at the 2nd comma.
|
||||||
|
|
||||||
|
The header ``S1 = parts[0] + "," + parts[1] + ","`` is always ASCII
|
||||||
|
with no commas inside, so the first two commas on the wire are its
|
||||||
|
structural boundaries — no ids or serials needed up front. Works for
|
||||||
|
requests and replies alike.
|
||||||
|
"""
|
||||||
|
raw = bytes(wire)
|
||||||
|
first = raw.find(b",")
|
||||||
|
if first < 0:
|
||||||
|
raise ValueError("no header comma in wire buffer")
|
||||||
|
second = raw.find(b",", first + 1)
|
||||||
|
if second < 0:
|
||||||
|
raise ValueError("truncated header in wire buffer")
|
||||||
|
return raw[: second + 1], raw[second + 1:]
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_wire(wire: bytes, radix: int = DEFAULT_KEY_RADIX) -> str:
|
||||||
|
"""Decode one complete envelope wire (request or reply) to JSON.
|
||||||
|
|
||||||
|
Splits via :func:`split_wire`, recovers ``equipNo`` from the
|
||||||
|
``a + "}"`` probe (exactly how the receiver learns which key to use),
|
||||||
|
and reverses the additive stream. Raises ``ValueError`` on any
|
||||||
|
failure (use :func:`decrypt_data`-style ``""`` handling when
|
||||||
|
accumulating).
|
||||||
|
"""
|
||||||
|
a, b = split_wire(wire)
|
||||||
|
try:
|
||||||
|
# ``a`` ends with the header's trailing comma — drop it so the
|
||||||
|
# probe is valid JSON (mirrors the ``s + "}"`` equipNo probe).
|
||||||
|
probe = json.loads(a[:-1].decode("utf-8") + "}")
|
||||||
|
except (UnicodeDecodeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"header probe is not JSON: {exc}") from exc
|
||||||
|
if not isinstance(probe, dict):
|
||||||
|
raise ValueError("header probe is not an object")
|
||||||
|
key = generate_secret_key(str(probe.get("equipNo", "")))
|
||||||
|
key_int = _parse_key(key, radix)
|
||||||
|
plain_b = bytes(
|
||||||
|
(byte - i - len(a) - key_int) & 0xFF for i, byte in enumerate(b)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return (a + plain_b).decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError(f"tail is not UTF-8: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def decrypt_data(wire: bytes, radix: int = DEFAULT_KEY_RADIX) -> str:
|
def decrypt_data(wire: bytes, radix: int = DEFAULT_KEY_RADIX) -> str:
|
||||||
"""``decryptData`` — decode one accumulated *response* buffer.
|
"""``decryptData`` — decode one accumulated *response* buffer.
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from .commands import CommunicationCmd, CommunicationOperate
|
from .commands import CommunicationCmd, CommunicationOperate, socket_cmd_type
|
||||||
from .crc import crc32
|
from .crc import crc32
|
||||||
|
|
||||||
#: Exact key order used by the app's ``toJson`` (``dataArea`` conditional).
|
#: Exact key order used by the app's ``toJson`` (``dataArea`` conditional).
|
||||||
@@ -175,19 +175,29 @@ def direct_connect_request(
|
|||||||
equip_no: str,
|
equip_no: str,
|
||||||
data: Optional[Dict[str, Any]] = None,
|
data: Optional[Dict[str, Any]] = None,
|
||||||
snno: int = 0,
|
snno: int = 0,
|
||||||
|
operate: CommunicationOperate = CommunicationOperate.QUERY,
|
||||||
) -> CommunicationObject:
|
) -> CommunicationObject:
|
||||||
"""Build a *direct-connect* query envelope.
|
"""Build a *direct-connect* query envelope.
|
||||||
|
|
||||||
Mirrors the Dart ``CommunicationObject.communicate`` factory +
|
Mirrors the Dart ``CommunicationObject.communicate`` factory +
|
||||||
``CommunicationService._query*ByDirectConnect`` family
|
``CommunicationService._query*ByDirectConnect`` family
|
||||||
(``docs/RE_DART_FINDINGS.md`` §3). Note ``snno`` is assigned by
|
(``docs/RE_DART_FINDINGS.md`` §3). The wire ``cmdType`` is the
|
||||||
``sendObject`` (see :class:`SequenceNumber`); pass the issued value when
|
*socket-transport* id (``CommunicationCmd::cmd`` for socket mode —
|
||||||
re-serialising.
|
e.g. ``DEVICE_APOWER_INFO`` → ``1307``, verified live). On socket
|
||||||
|
mode the factory always seeds the payload with ``"opt"`` (the
|
||||||
|
operation type, ``0`` for query) and merges the caller's arguments
|
||||||
|
after it — e.g. the app's ``queryAPowerData`` sends
|
||||||
|
``dataArea = {"opt": 0, "update": 2}``. Note ``snno`` is assigned by
|
||||||
|
``sendObject`` (see :class:`SequenceNumber`); pass the issued value
|
||||||
|
when re-serialising.
|
||||||
"""
|
"""
|
||||||
|
payload: Dict[str, Any] = {"opt": int(operate)}
|
||||||
|
if data:
|
||||||
|
payload.update(data)
|
||||||
return CommunicationObject(
|
return CommunicationObject(
|
||||||
cmd_type=int(cmd),
|
cmd_type=socket_cmd_type(cmd),
|
||||||
equip_no=equip_no,
|
equip_no=equip_no,
|
||||||
op_type=CommunicationOperate.QUERY,
|
op_type=int(operate),
|
||||||
snno=snno,
|
snno=snno,
|
||||||
data=data,
|
data=payload,
|
||||||
)
|
)
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
"""Frame builders for the two byte transports.
|
"""Frame builders for the two byte transports.
|
||||||
|
|
||||||
* **BLE** — ``communication_center/bluetooth/bluetooth_frame.dart`` (see
|
* **BLE** — ``communication_center/bluetooth/bluetooth_frame.dart`` (see
|
||||||
``docs/RE_DART_FINDINGS.md`` §9). Frame layout and magics are confirmed;
|
``docs/RE_DART_FINDINGS.md`` §9). On-air layout, settled from
|
||||||
the *exact* on-air order of the magic relative to the frame and the MTU
|
``createBluetoothFrame``::
|
||||||
continuation scheme still need a live capture.
|
|
||||||
* **Local TCP socket** — ``communication_center/socket`` uses ``int16ToBytes``
|
magic[2] ++ crc[2] ++ int16LE(totalLen) ++ int16LE(sn) ++ content
|
||||||
/ ``bytesToInt16`` (little-endian) framing (see §8). A length-prefixed
|
|
||||||
frame is the working candidate; adjust once a capture is available.
|
* ``totalLen = 6 + len(content)`` (2 crc-slot + 2 len + 2 sn bytes).
|
||||||
|
* CRC-16/MODBUS over the buffer with ``00 00`` in the crc slots, rendered
|
||||||
|
as the app's swapped-lowercase 4-hex string; each hex *pair* is then run
|
||||||
|
through ``int.parse(pair, radix: 32)`` (literal ``0x20`` — quirky but
|
||||||
|
explicit) and written into slots 2..3.
|
||||||
|
* Magics: ``5A A5`` data (default), ``4A A4`` ack, ``2A A2`` MTU announce.
|
||||||
|
* The encrypted envelope (same bytes as the socket path) is split into
|
||||||
|
MTU-sized slices when longer than the negotiated MTU; every slice goes
|
||||||
|
out as its own frame with its own ``_increaseBluetoothSN()`` value.
|
||||||
|
The receiver dedups by sn, accumulates ``content`` parts and runs the
|
||||||
|
same ``decryptData`` over the concatenation.
|
||||||
|
* **Local TCP socket** — raw encrypted bytes, no framing (see §8).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,12 +31,79 @@ from .crc import crc16_hex_string, crc16_modbus, int16_to_le_bytes, le_bytes_to_
|
|||||||
MAGIC_ACK = b"\x4a\xa4"
|
MAGIC_ACK = b"\x4a\xa4"
|
||||||
#: First two bytes of a **data** frame (receiving one triggers the app's ack).
|
#: First two bytes of a **data** frame (receiving one triggers the app's ack).
|
||||||
MAGIC_DATA = b"\x5a\xa5"
|
MAGIC_DATA = b"\x5a\xa5"
|
||||||
|
#: Magic of the MTU-announce frame (``_sendMtu`` sends ``mtuToData()``).
|
||||||
|
MAGIC_MTU = b"\x2a\xa2"
|
||||||
|
|
||||||
MAGIC_NAMES = {
|
MAGIC_NAMES = {
|
||||||
MAGIC_ACK[0:2]: "ack",
|
MAGIC_ACK[0:2]: "ack",
|
||||||
MAGIC_DATA[0:2]: "data",
|
MAGIC_DATA[0:2]: "data",
|
||||||
|
MAGIC_MTU[0:2]: "mtu",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#: On-air header length: magic(2) + crc(2) + totalLen(2) + sn(2).
|
||||||
|
BLE_HEADER_LEN = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _ble_crc_bytes(body_with_zero_slots: bytes) -> bytes:
|
||||||
|
"""CRC slot bytes the way ``createBluetoothFrame`` derives them.
|
||||||
|
|
||||||
|
CRC-16/MODBUS over the frame with ``00 00`` slots, swapped-lowercase
|
||||||
|
4-hex string, then ``int.parse(pair, radix: 32)`` per pair (the
|
||||||
|
disassembly materialises ``0x20`` for the radix). Values can exceed
|
||||||
|
one byte in theory; the GATT write path must narrow them, so mask
|
||||||
|
here (assumption — confirm against a capture).
|
||||||
|
"""
|
||||||
|
hexstr = crc16_hex_string(body_with_zero_slots)
|
||||||
|
return bytes(int(hexstr[i:i + 2], 32) & 0xFF for i in (0, 2))
|
||||||
|
|
||||||
|
|
||||||
|
def create_ble_frame(
|
||||||
|
content: bytes,
|
||||||
|
sn: int,
|
||||||
|
magic: bytes = MAGIC_DATA,
|
||||||
|
) -> bytes:
|
||||||
|
"""``createBluetoothFrame(content, magic, sn)`` — full on-air frame.
|
||||||
|
|
||||||
|
Layout::
|
||||||
|
|
||||||
|
magic ++ crc[2] ++ int16LE(6 + len(content)) ++ int16LE(sn) ++ content
|
||||||
|
"""
|
||||||
|
total = 6 + len(content)
|
||||||
|
buf = bytearray(
|
||||||
|
bytes(magic[:2])
|
||||||
|
+ b"\x00\x00"
|
||||||
|
+ int16_to_le_bytes(total)
|
||||||
|
+ int16_to_le_bytes(sn)
|
||||||
|
+ bytes(content)
|
||||||
|
)
|
||||||
|
buf[2:4] = _ble_crc_bytes(bytes(buf))
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
def build_ble_data_frame(content: bytes, sn: int) -> bytes:
|
||||||
|
"""Full on-air data frame (magic ``0x5A 0xA5``)."""
|
||||||
|
return create_ble_frame(content, sn, MAGIC_DATA)
|
||||||
|
|
||||||
|
|
||||||
|
def build_ble_ack_frame(content: bytes, sn: int) -> bytes:
|
||||||
|
"""Full on-air ACK frame (magic ``0x4A 0xA4``)."""
|
||||||
|
return create_ble_frame(content, sn, MAGIC_ACK)
|
||||||
|
|
||||||
|
|
||||||
|
def magic_in_data(data: bytes) -> Optional[str]:
|
||||||
|
"""Return ``"ack"`` / ``"data"`` / ``"mtu"`` if known; else ``None``."""
|
||||||
|
return MAGIC_NAMES.get(bytes(data[:2]))
|
||||||
|
|
||||||
|
|
||||||
|
def ble_frame_sn(data: bytes) -> int:
|
||||||
|
"""``sn`` field of a frame (int16LE at offset 6)."""
|
||||||
|
return le_bytes_to_int16(data[6:8])
|
||||||
|
|
||||||
|
|
||||||
|
def ble_frame_total_len(data: bytes) -> int:
|
||||||
|
"""``totalLen`` field of a frame (int16LE at offset 4)."""
|
||||||
|
return le_bytes_to_int16(data[4:6])
|
||||||
|
|
||||||
# In createBluetoothFrame the CRC placeholder sits at byte offsets 2..5 of the
|
# In createBluetoothFrame the CRC placeholder sits at byte offsets 2..5 of the
|
||||||
# frame body (content ++ [00 00 00 00] ++ int16LE(totalLen) ++ int16LE(value) ++ sn).
|
# frame body (content ++ [00 00 00 00] ++ int16LE(totalLen) ++ int16LE(value) ++ sn).
|
||||||
_CRC_SLOTS = slice(2, 4)
|
_CRC_SLOTS = slice(2, 4)
|
||||||
@@ -77,51 +155,40 @@ def magic_in_data(data: bytes) -> Optional[str]:
|
|||||||
return MAGIC_NAMES.get(bytes(data[:2]))
|
return MAGIC_NAMES.get(bytes(data[:2]))
|
||||||
|
|
||||||
|
|
||||||
def sn_in_data(data: bytes, sn: str) -> bool:
|
def sn_in_data(data: bytes, sn: int) -> bool:
|
||||||
"""True if the frame (after any magic) ends with the device SN."""
|
"""True if the frame's ``sn`` field equals *sn*."""
|
||||||
sn_bytes = sn.encode("utf-8")
|
if len(data) < BLE_HEADER_LEN:
|
||||||
return data.endswith(sn_bytes)
|
return False
|
||||||
|
return ble_frame_sn(data) == int(sn)
|
||||||
|
|
||||||
|
|
||||||
def content_in_data(data: bytes, sn: str) -> Optional[bytes]:
|
def content_in_data(data: bytes) -> Optional[bytes]:
|
||||||
"""Extract the ``content`` bytes of a data/ack frame.
|
"""Frame ``content`` (everything after the 8-byte header)."""
|
||||||
|
if len(data) < BLE_HEADER_LEN:
|
||||||
Layout after the 2-byte magic is fixed-size except for ``content``, so::
|
|
||||||
|
|
||||||
content = frame[2 : len(frame) - len(sn) - FRAME_TAIL_SIZE]
|
|
||||||
"""
|
|
||||||
body = data[2:] if len(data) >= 2 else data
|
|
||||||
sn_bytes = sn.encode("utf-8")
|
|
||||||
if not body.endswith(sn_bytes):
|
|
||||||
return None
|
return None
|
||||||
end = len(body) - len(sn_bytes) - _FRAME_TAIL_SIZE
|
return bytes(data[BLE_HEADER_LEN:])
|
||||||
if end < 2: # need at least the 4-byte crc slots
|
|
||||||
return None
|
|
||||||
return body[2:end]
|
|
||||||
|
|
||||||
|
|
||||||
def verify_frame_crc(data: bytes) -> bool:
|
def verify_frame_crc(data: bytes) -> bool:
|
||||||
"""Recompute the CRC-16 over the frame body and compare slots 2..3."""
|
"""Recompute the CRC-16 over the frame (zeroed slots) and compare."""
|
||||||
body = data[2:] if len(data) >= 2 else data
|
if len(data) < BLE_HEADER_LEN:
|
||||||
if len(body) < 4:
|
|
||||||
return False
|
return False
|
||||||
candidate = body[:]
|
candidate = bytearray(data)
|
||||||
candidate[2:4] = b"\x00\x00\x00\x00"
|
candidate[2:4] = b"\x00\x00"
|
||||||
return bytes.fromhex(crc16_hex_string(bytes(candidate))) == body[2:4]
|
return _ble_crc_bytes(bytes(candidate)) == bytes(data[2:4])
|
||||||
|
|
||||||
|
|
||||||
def chunk_for_mtu(frame: bytes, mtu: int = 20) -> List[bytes]:
|
def chunk_for_mtu(encrypted: bytes, mtu: int = 20) -> List[bytes]:
|
||||||
"""Split a frame for GATT writes.
|
"""Split the encrypted envelope into MTU-sized slices.
|
||||||
|
|
||||||
First chunk carries the 2-byte magic; the rest are raw continuation
|
Mirrors the ``sendObject`` over-length branch (``ceil(len/mtu)`` slices
|
||||||
chunks. The app's ``mtuToData`` does something similar — the exact
|
via ``sublist``): every slice is sent as its own BLE frame with its own
|
||||||
continuation framing (length headers? re-magic?) needs confirmation.
|
``_increaseBluetoothSN()`` value (see :func:`create_ble_frame`).
|
||||||
"""
|
"""
|
||||||
if mtu <= 2:
|
if mtu <= 0:
|
||||||
raise ValueError("mtu must exceed the 2-byte magic")
|
raise ValueError("mtu must be positive")
|
||||||
if len(frame) <= mtu:
|
return [bytes(encrypted[i:i + mtu])
|
||||||
return [frame]
|
for i in range(0, len(encrypted), mtu)] or [b""]
|
||||||
return [frame[i:i + mtu] for i in range(0, len(frame), mtu)]
|
|
||||||
|
|
||||||
|
|
||||||
def magicEquals(a: bytes, b: bytes) -> bool:
|
def magicEquals(a: bytes, b: bytes) -> bool:
|
||||||
@@ -145,10 +212,9 @@ def frame_socket_payload(payload: bytes) -> bytes:
|
|||||||
class SocketFrameDecoder:
|
class SocketFrameDecoder:
|
||||||
"""Accumulating socket receiver (``SocketManager._onSocketReceiveData``).
|
"""Accumulating socket receiver (``SocketManager._onSocketReceiveData``).
|
||||||
|
|
||||||
Each incoming chunk is appended to a growable buffer and ``decrypt_data``
|
Each incoming chunk is appended to a growable buffer and ``decrypt_wire``
|
||||||
is attempted over the whole buffer (inside try/catch in the app); a
|
is attempted over the whole buffer; a decoded string that parses as
|
||||||
non-empty decoded string that parses as JSON is one complete reply, at
|
JSON is one complete reply, at which point the buffer is cleared. Use :meth:`feed` for raw chunks and
|
||||||
which point the buffer is cleared. Use :meth:`feed` for raw chunks and
|
|
||||||
:meth:`feed_json` when you want parsed envelopes back.
|
:meth:`feed_json` when you want parsed envelopes back.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -157,11 +223,12 @@ class SocketFrameDecoder:
|
|||||||
|
|
||||||
def feed(self, data: bytes) -> List[str]:
|
def feed(self, data: bytes) -> List[str]:
|
||||||
"""Append *data*; return a list of complete decoded JSON strings."""
|
"""Append *data*; return a list of complete decoded JSON strings."""
|
||||||
from .crypto import decrypt_data # local import: cheap, no cycles
|
from .crypto import decrypt_wire # local import: cheap, no cycles
|
||||||
|
|
||||||
self._buf.extend(data)
|
self._buf.extend(data)
|
||||||
decoded = decrypt_data(bytes(self._buf))
|
try:
|
||||||
if not decoded:
|
decoded = decrypt_wire(bytes(self._buf))
|
||||||
|
except ValueError:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
json.loads(decoded)
|
json.loads(decoded)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -65,7 +65,8 @@ class HotspotJoinError(RuntimeError):
|
|||||||
"""A join was issued but the association never verified in time."""
|
"""A join was issued but the association never verified in time."""
|
||||||
|
|
||||||
|
|
||||||
def derive_hotspot_credentials(sn: str) -> "HotspotProfile":
|
def derive_hotspot_credentials(sn: str,
|
||||||
|
gateway_ip: Optional[str] = None) -> "HotspotProfile":
|
||||||
"""``getDeviceApSsid`` / ``getDeviceApDefaultPwd``.
|
"""``getDeviceApSsid`` / ``getDeviceApDefaultPwd``.
|
||||||
|
|
||||||
Offline fallback: for a *20-character* SN the SSID is ``"AP_" + sn[11:]``
|
Offline fallback: for a *20-character* SN the SSID is ``"AP_" + sn[11:]``
|
||||||
@@ -74,6 +75,9 @@ def derive_hotspot_credentials(sn: str) -> "HotspotProfile":
|
|||||||
|
|
||||||
The authoritative values come from the live device
|
The authoritative values come from the live device
|
||||||
(``/terminal/getHotSpotInfo`` + ``/terminal/getHosSpotPassword``).
|
(``/terminal/getHotSpotInfo`` + ``/terminal/getHosSpotPassword``).
|
||||||
|
|
||||||
|
``gateway_ip`` overrides the default SoftAP gateway (``10.100.1.1``) —
|
||||||
|
e.g. when the aGate is reached on its home-LAN address instead.
|
||||||
"""
|
"""
|
||||||
if len(sn) == VALID_SN_LENGTH:
|
if len(sn) == VALID_SN_LENGTH:
|
||||||
ssid = "AP_" + sn[11:]
|
ssid = "AP_" + sn[11:]
|
||||||
@@ -83,7 +87,8 @@ def derive_hotspot_credentials(sn: str) -> "HotspotProfile":
|
|||||||
len(sn), VALID_SN_LENGTH)
|
len(sn), VALID_SN_LENGTH)
|
||||||
ssid = "AP_" + sn
|
ssid = "AP_" + sn
|
||||||
password = FALLBACK_PASSWORD
|
password = FALLBACK_PASSWORD
|
||||||
return HotspotProfile(ssid=ssid, password=password)
|
return HotspotProfile(ssid=ssid, password=password,
|
||||||
|
gateway_ip=gateway_ip or DEVICE_GATEWAY_IP)
|
||||||
|
|
||||||
|
|
||||||
class WifiBackend(ABC):
|
class WifiBackend(ABC):
|
||||||
@@ -308,9 +313,14 @@ class WifiManager:
|
|||||||
# -- device hotspot ------------------------------------------------------
|
# -- device hotspot ------------------------------------------------------
|
||||||
def join_device_hotspot(self, sn: str, *, verify: bool = True,
|
def join_device_hotspot(self, sn: str, *, verify: bool = True,
|
||||||
settle_timeout: float = 30.0,
|
settle_timeout: float = 30.0,
|
||||||
poll_interval: float = 1.0) -> HotspotProfile:
|
poll_interval: float = 1.0,
|
||||||
"""Derive the profile from the SN, join, and verify the association."""
|
gateway_ip: Optional[str] = None) -> HotspotProfile:
|
||||||
profile = derive_hotspot_credentials(sn)
|
"""Derive the profile from the SN, join, and verify the association.
|
||||||
|
|
||||||
|
``gateway_ip`` overrides the device address recorded on the
|
||||||
|
profile (default ``10.100.1.1``) without changing the radio join.
|
||||||
|
"""
|
||||||
|
profile = derive_hotspot_credentials(sn, gateway_ip=gateway_ip)
|
||||||
return self._join(profile, verify=verify,
|
return self._join(profile, verify=verify,
|
||||||
settle_timeout=settle_timeout,
|
settle_timeout=settle_timeout,
|
||||||
poll_interval=poll_interval)
|
poll_interval=poll_interval)
|
||||||
|
|||||||
Executable
+411
@@ -0,0 +1,411 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# agate_battery.sh -- FranklinWH AGATE battery state, pretty-printed.
|
||||||
|
#
|
||||||
|
# Bakes in the device under test (serial number + LAN address) and runs the
|
||||||
|
# library's direct-connect battery read over the aGate's local TCP socket
|
||||||
|
# (the encrypted CommunicationObject envelope -- no cloud, no BLE), then
|
||||||
|
# renders the answer as a colourised terminal report.
|
||||||
|
#
|
||||||
|
# ./scripts/agate_battery.sh # baked-in device
|
||||||
|
# ./scripts/agate_battery.sh --sn <SN> --ip <IP>
|
||||||
|
# ./scripts/agate_battery.sh --json # raw replies as JSON
|
||||||
|
# ./scripts/agate_battery.sh --no-color # plain text
|
||||||
|
#
|
||||||
|
# Env overrides: AGATE_SN, AGATE_IP, AGATE_PORT, AGATE_TIMEOUT, NO_COLOR.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- config ---
|
||||||
|
DEVICE_SN="${AGATE_SN:-A03X2516013210060005}"
|
||||||
|
DEVICE_IP="${AGATE_IP:-192.168.1.142}"
|
||||||
|
DEVICE_PORT="${AGATE_PORT:-9000}"
|
||||||
|
TIMEOUT="${AGATE_TIMEOUT:-8}"
|
||||||
|
JSON_OUT=0
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
export PYTHONPATH="${REPO_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
usage: ${0##*/} [options]
|
||||||
|
|
||||||
|
--sn <serial> device serial (default: ${DEVICE_SN})
|
||||||
|
--ip <addr> aGate address (default: ${DEVICE_IP})
|
||||||
|
--port <n> local socket port (default: ${DEVICE_PORT})
|
||||||
|
--timeout <s> per-request timeout (default: ${TIMEOUT})
|
||||||
|
--json dump the raw device replies as JSON
|
||||||
|
--no-color disable ANSI colour
|
||||||
|
-h, --help show this help
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--sn) DEVICE_SN="$2"; shift 2 ;;
|
||||||
|
--ip) DEVICE_IP="$2"; shift 2 ;;
|
||||||
|
--port) DEVICE_PORT="$2"; shift 2 ;;
|
||||||
|
--timeout) TIMEOUT="$2"; shift 2 ;;
|
||||||
|
--json) JSON_OUT=1; shift ;;
|
||||||
|
--no-color) NO_COLOR=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- colour ---
|
||||||
|
if [[ -t 1 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
|
||||||
|
AGATE_COLOR=1
|
||||||
|
else
|
||||||
|
AGATE_COLOR=0
|
||||||
|
fi
|
||||||
|
export AGATE_COLOR
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- preflight ---
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
printf '\033[31m✗ python3 not found\033[0m\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 -c 'import requests' >/dev/null 2>&1; then
|
||||||
|
printf '\033[31m✗ the requests package is missing (pip install requests)\033[0m\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
if ! timeout "${TIMEOUT}" bash -c "exec 3<>/dev/tcp/${DEVICE_IP}/${DEVICE_PORT}" 2>/dev/null; then
|
||||||
|
printf '\033[31m✗ %s:%s is unreachable\033[0m\n' "${DEVICE_IP}" "${DEVICE_PORT}" >&2
|
||||||
|
printf ' the aGate local socket only answers on its own network\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ query ---
|
||||||
|
export AGATE_SN="$DEVICE_SN" DEVICE_IP DEVICE_PORT TIMEOUT JSON_OUT
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
"""Query the aGate and render a colourised terminal report."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from franklinwh_agate_client.device_local import LocalDeviceClient
|
||||||
|
from franklinwh_agate_client.protocol import CommunicationCmd
|
||||||
|
|
||||||
|
SN = os.environ["AGATE_SN"]
|
||||||
|
HOST = os.environ["DEVICE_IP"]
|
||||||
|
PORT = int(os.environ["DEVICE_PORT"])
|
||||||
|
TIMEOUT = float(os.environ["TIMEOUT"])
|
||||||
|
JSON_OUT = os.environ.get("JSON_OUT") == "1"
|
||||||
|
COLOR = os.environ.get("AGATE_COLOR") == "1"
|
||||||
|
|
||||||
|
WIDTH = 74
|
||||||
|
#: label column width inside the boxes
|
||||||
|
LABELS = 13
|
||||||
|
#: usable width for a value that follows a label (inner width minus the
|
||||||
|
#: leading space of the box line and the label column)
|
||||||
|
VALUE_WIDTH = WIDTH - 2 - 1 - LABELS
|
||||||
|
#: usable width for a full-width box line
|
||||||
|
LINE_WIDTH = WIDTH - 2 - 1
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ colour ---
|
||||||
|
_C = {
|
||||||
|
"off": "\033[0m", "bold": "\033[1m", "dim": "\033[2m",
|
||||||
|
"red": "\033[31m", "green": "\033[32m", "yellow": "\033[33m",
|
||||||
|
"blue": "\033[34m", "magenta": "\033[35m", "cyan": "\033[36m",
|
||||||
|
"grey": "\033[90m", "bgreen": "\033[92m", "bcyan": "\033[96m",
|
||||||
|
"byellow": "\033[93m", "bred": "\033[91m",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def col(name, text):
|
||||||
|
if not COLOR:
|
||||||
|
return text
|
||||||
|
return f"{_C[name]}{text}{_C['off']}"
|
||||||
|
|
||||||
|
|
||||||
|
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
|
def pad(text, width, align="left"):
|
||||||
|
"""Pad to a visible width, ignoring ANSI escapes."""
|
||||||
|
visible = len(_ANSI.sub("", text))
|
||||||
|
gap = width - visible
|
||||||
|
if gap <= 0:
|
||||||
|
return text
|
||||||
|
return (" " * gap + text) if align == "right" else (text + " " * gap)
|
||||||
|
|
||||||
|
|
||||||
|
def kv(label, value, width=13):
|
||||||
|
return col("grey", label.ljust(width)) + value
|
||||||
|
|
||||||
|
|
||||||
|
def box_top(title=""):
|
||||||
|
inner = WIDTH - 2
|
||||||
|
if title:
|
||||||
|
head = f" {title} "
|
||||||
|
line = "─" + head + "─" * max(0, inner - 1 - len(head))
|
||||||
|
else:
|
||||||
|
line = "─" * inner
|
||||||
|
return col("cyan", "╭") + col("dim", line) + col("cyan", "╮")
|
||||||
|
|
||||||
|
|
||||||
|
def box_bottom():
|
||||||
|
return col("cyan", "╰") + col("dim", "─" * (WIDTH - 2)) + col("cyan", "╯")
|
||||||
|
|
||||||
|
|
||||||
|
def box_line(content=""):
|
||||||
|
return (col("cyan", "│") + pad(" " + content, WIDTH - 2)
|
||||||
|
+ col("cyan", "│"))
|
||||||
|
|
||||||
|
|
||||||
|
def blank():
|
||||||
|
return box_line()
|
||||||
|
|
||||||
|
|
||||||
|
SPARK = "▁▂▃▄▅▆▇█"
|
||||||
|
|
||||||
|
|
||||||
|
def sparkline(values):
|
||||||
|
"""Unicode bar chart of a numeric series."""
|
||||||
|
vals = [float(v) for v in values]
|
||||||
|
if not vals:
|
||||||
|
return ""
|
||||||
|
lo, hi = min(vals), max(vals)
|
||||||
|
if hi - lo < 1e-9:
|
||||||
|
return SPARK[3] * len(vals)
|
||||||
|
return "".join(
|
||||||
|
SPARK[int(round((v - lo) / (hi - lo) * (len(SPARK) - 1)))] for v in vals)
|
||||||
|
|
||||||
|
|
||||||
|
def soc_bar(pct, width=26):
|
||||||
|
"""Charge bar, coloured by level (green >= 50, yellow >= 20, red below)."""
|
||||||
|
pct = max(0.0, min(100.0, pct))
|
||||||
|
filled = int(round(width * pct / 100.0))
|
||||||
|
tone = "bgreen" if pct >= 50 else ("byellow" if pct >= 20 else "bred")
|
||||||
|
return col(tone, "█" * filled) + col("grey", "░" * (width - filled))
|
||||||
|
|
||||||
|
|
||||||
|
def num(value, unit="", digits=1):
|
||||||
|
if value is None:
|
||||||
|
return col("grey", "n/a")
|
||||||
|
return f"{value:,.{digits}f} {unit}" if unit else f"{value:,.{digits}f}"
|
||||||
|
|
||||||
|
|
||||||
|
def signed(value, unit="", digits=1):
|
||||||
|
if value is None:
|
||||||
|
return col("grey", "n/a")
|
||||||
|
text = f"{value:+,.{digits}f}"
|
||||||
|
return f"{text} {unit}" if unit else text
|
||||||
|
|
||||||
|
|
||||||
|
def pack(items, budget, sep=" | "):
|
||||||
|
"""Greedily pack short strings into lines no wider than *budget*."""
|
||||||
|
lines, current = [], ""
|
||||||
|
for item in items:
|
||||||
|
candidate = item if not current else f"{current}{sep}{item}"
|
||||||
|
if len(_ANSI.sub("", candidate)) <= budget or not current:
|
||||||
|
current = candidate
|
||||||
|
else:
|
||||||
|
lines.append(current)
|
||||||
|
current = item
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ query ---
|
||||||
|
client = LocalDeviceClient(sn=SN, host=HOST, socket_port=PORT, timeout=TIMEOUT)
|
||||||
|
|
||||||
|
raw = {}
|
||||||
|
|
||||||
|
|
||||||
|
def rpc(name, cmd, data=None, equip_no=None):
|
||||||
|
"""Send one envelope; never raise -- a failed read is just empty."""
|
||||||
|
try:
|
||||||
|
reply = client.send_rpc(cmd, data=data, equip_no=equip_no)
|
||||||
|
except Exception as exc: # noqa: BLE001 -- report, do not explode
|
||||||
|
raw[name] = {"error": f"{type(exc).__name__}: {exc}"}
|
||||||
|
return {}
|
||||||
|
raw[name] = reply
|
||||||
|
return reply.get("dataArea", {}) if isinstance(reply, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
# best-effort discovery probe (mirrors the app's direct-connect open)
|
||||||
|
try:
|
||||||
|
client.send_rpc(CommunicationCmd.QUERY_DEVICE_INFO, equip_no="",
|
||||||
|
data={"minProtocolVer": "V1.00.00"})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dev = rpc("device_info", CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
{"minProtocolVer": "V1.00.00"})
|
||||||
|
detail = rpc("detail", CommunicationCmd.DEVICE_DETAIL_INFO)
|
||||||
|
apower = rpc("apower", CommunicationCmd.DEVICE_APOWER_INFO, {"update": 2})
|
||||||
|
|
||||||
|
# pack ids come from the BMS_SN suffixes (…B1 / …B2)
|
||||||
|
bms_sns = [str(s) for s in (dev.get("BMS_SN") or [])]
|
||||||
|
pack_ids = []
|
||||||
|
for sn in bms_sns:
|
||||||
|
match = re.search(r"([0-9]+)\s*$", sn)
|
||||||
|
pack_ids.append(int(match.group(1)) if match else len(pack_ids) + 1)
|
||||||
|
if not pack_ids:
|
||||||
|
pack_ids = [1, 2]
|
||||||
|
|
||||||
|
packs = []
|
||||||
|
for pid in pack_ids:
|
||||||
|
data = rpc(f"bms{pid}", CommunicationCmd.BMS, {"id": pid})
|
||||||
|
if not data or data.get("result") != 0:
|
||||||
|
continue
|
||||||
|
packs.append({
|
||||||
|
"id": pid,
|
||||||
|
"sn": bms_sns[pack_ids.index(pid)] if pid in bms_sns else "",
|
||||||
|
"soc": data.get("batSoc"),
|
||||||
|
"soh": data.get("batSoh"),
|
||||||
|
"volt": data.get("batTotalVolt"),
|
||||||
|
"curr": data.get("currGrp"),
|
||||||
|
"alarm": data.get("alarmLevel"),
|
||||||
|
"temps": data.get("batTemp") or [],
|
||||||
|
"volts": data.get("batVolt") or [],
|
||||||
|
})
|
||||||
|
|
||||||
|
if JSON_OUT:
|
||||||
|
print(json.dumps(raw, indent=2, default=str))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not packs and not detail:
|
||||||
|
print(col("bred", "✗ the device did not answer any battery query"))
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ render ---
|
||||||
|
print()
|
||||||
|
print(box_top("FRANKLINWH AGATE"))
|
||||||
|
print(box_line())
|
||||||
|
print(box_line(kv("serial", col("bold", SN))))
|
||||||
|
print(box_line(kv("address", f"{HOST}:{PORT} "
|
||||||
|
+ col("grey", "(local aGate socket)"))))
|
||||||
|
if dev.get("protocolVer"):
|
||||||
|
print(box_line(kv("protocol", dev["protocolVer"])))
|
||||||
|
fhp = [str(s) for s in (dev.get("FHP_SN") or [])]
|
||||||
|
if fhp:
|
||||||
|
for i, s in enumerate(fhp):
|
||||||
|
print(box_line(kv("FHP unit %d" % (i + 1), s)))
|
||||||
|
mode = detail.get("name") or detail.get("mode")
|
||||||
|
if mode:
|
||||||
|
print(box_line(kv("mode", f"{mode}"
|
||||||
|
+ (col("grey", f" (status {detail['run_status']})")
|
||||||
|
if detail.get("run_status") is not None else ""))))
|
||||||
|
print(box_line())
|
||||||
|
print(box_bottom())
|
||||||
|
print()
|
||||||
|
|
||||||
|
# -- battery --------------------------------------------------------------
|
||||||
|
print(box_top("BATTERY"))
|
||||||
|
print(box_line())
|
||||||
|
|
||||||
|
pack_socs = [p["soc"] for p in packs if p.get("soc") is not None]
|
||||||
|
overall = sum(pack_socs) / len(pack_socs) if pack_socs else None
|
||||||
|
gw_soc = detail.get("soc")
|
||||||
|
|
||||||
|
if overall is not None:
|
||||||
|
bar = soc_bar(overall)
|
||||||
|
print(box_line(kv("overall", bar + " " + col("bold", f"{overall:.1f} %"))))
|
||||||
|
if gw_soc is not None and abs(gw_soc - overall) >= 0.5:
|
||||||
|
print(box_line(kv("", col("grey",
|
||||||
|
f"gateway aggregate reads {gw_soc:.1f} %"))))
|
||||||
|
print(box_line())
|
||||||
|
|
||||||
|
for p in packs:
|
||||||
|
soc = p["soc"]
|
||||||
|
label = f"Pack {p['id']}"
|
||||||
|
if p["sn"]:
|
||||||
|
label += col("grey", f" {p['sn'][-3:]}")
|
||||||
|
print(box_line(kv(label, (soc_bar(soc) + " " + col("bold", f"{soc:.1f} %")
|
||||||
|
if soc is not None
|
||||||
|
else col("grey", "no data")))))
|
||||||
|
meta = []
|
||||||
|
if p["soh"] is not None:
|
||||||
|
meta.append(col("grey", "SoH ") + col("bcyan", f"{p['soh']:.1f} %"))
|
||||||
|
if p["volt"] is not None:
|
||||||
|
meta.append(col("grey", "voltage ")
|
||||||
|
+ col("bcyan", f"{p['volt']:.2f} V"))
|
||||||
|
if p["curr"] is not None:
|
||||||
|
flow = "discharging" if p["curr"] < 0 else "charging"
|
||||||
|
meta.append(col("grey", "current ")
|
||||||
|
+ col("byellow" if p["curr"] < 0 else "bgreen",
|
||||||
|
f"{signed(p['curr'], 'A')} {flow}"))
|
||||||
|
if p["temps"]:
|
||||||
|
meta.append(col("grey", "temp ")
|
||||||
|
+ col("bcyan", f"{min(p['temps']):.1f}–"
|
||||||
|
f"{max(p['temps']):.1f} °C"))
|
||||||
|
alarm = p["alarm"]
|
||||||
|
meta.append(col("grey", "alarms ")
|
||||||
|
+ (col("bgreen", "none") if not alarm
|
||||||
|
else col("bred", f"level {alarm}")))
|
||||||
|
for line in pack(meta, VALUE_WIDTH):
|
||||||
|
print(box_line(kv("", line)))
|
||||||
|
if p["volts"]:
|
||||||
|
print(box_line(kv("", col("grey", "cells")
|
||||||
|
+ " " + col("bcyan", sparkline(p["volts"]))
|
||||||
|
+ col("grey", f" {min(p['volts']):.0f}–"
|
||||||
|
f"{max(p['volts']):.0f} mV"
|
||||||
|
f" ({len(p['volts'])})"))))
|
||||||
|
if p["temps"]:
|
||||||
|
print(box_line(kv("", col("grey", "temps ")
|
||||||
|
+ " " + col("bcyan", sparkline(p["temps"]))
|
||||||
|
+ col("grey", " °C"))))
|
||||||
|
print(box_line())
|
||||||
|
|
||||||
|
if not packs:
|
||||||
|
print(box_line(col("grey", "no BMS pack answered")))
|
||||||
|
print(box_line())
|
||||||
|
|
||||||
|
print(box_bottom())
|
||||||
|
print()
|
||||||
|
|
||||||
|
# -- power ----------------------------------------------------------------
|
||||||
|
if detail:
|
||||||
|
print(box_top("POWER"))
|
||||||
|
print(box_line())
|
||||||
|
flows = (
|
||||||
|
("load", detail.get("p_load"), "W", "byellow"),
|
||||||
|
("FHP", detail.get("p_fhp"), "W", "bcyan"),
|
||||||
|
("solar", detail.get("p_sun"), "W", "byellow"),
|
||||||
|
("generator", detail.get("p_gen"), "W", "bgreen"),
|
||||||
|
)
|
||||||
|
row = " ".join(
|
||||||
|
col("grey", f"{name} ") + col(tone, num(val, unit))
|
||||||
|
for name, val, unit, tone in flows if val is not None)
|
||||||
|
for line in pack([row], LINE_WIDTH, sep=" "):
|
||||||
|
print(box_line(line))
|
||||||
|
energy = (
|
||||||
|
("load", detail.get("kwh_load")),
|
||||||
|
("solar", detail.get("kwh_sun")),
|
||||||
|
("FHP charge", detail.get("kwh_fhp_chg")),
|
||||||
|
("FHP discharge", detail.get("kwh_fhp_di")),
|
||||||
|
)
|
||||||
|
items = [col("grey", f"{name} ") + col("bcyan", f"{val:.2f} kWh")
|
||||||
|
for name, val in energy if val is not None]
|
||||||
|
if items:
|
||||||
|
print(box_line())
|
||||||
|
for line in pack(items, LINE_WIDTH, sep=" "):
|
||||||
|
print(box_line(line))
|
||||||
|
print(box_line())
|
||||||
|
print(box_bottom())
|
||||||
|
print()
|
||||||
|
|
||||||
|
stamp = (raw.get("detail") or {}).get("timeStamp")
|
||||||
|
stamp_txt = ""
|
||||||
|
if stamp:
|
||||||
|
try:
|
||||||
|
stamp_txt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stamp))
|
||||||
|
except (ValueError, OSError, OverflowError):
|
||||||
|
stamp_txt = str(stamp)
|
||||||
|
print(col("grey", " device clock " + (stamp_txt or "unknown")
|
||||||
|
+ " local " + time.strftime("%Y-%m-%d %H:%M:%S")))
|
||||||
|
print()
|
||||||
|
PY
|
||||||
+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__":
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host probe v3 — socket-id variants via the library business API.
|
||||||
|
|
||||||
|
Historical sweep that established the socket-transport command space
|
||||||
|
(``1101/1307/1705/1707`` instead of the ``off_8`` ids). Superseded by
|
||||||
|
``host_probe6.py`` for routine use; kept as a variant-list driver over
|
||||||
|
``LocalDeviceClient.send_probe``.
|
||||||
|
|
||||||
|
Run from the repo root on the SoftAP-joined host:
|
||||||
|
python3 scripts/host_probe3.py --sn A03X2516013210060005 --ports 9000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
VARIANTS = (
|
||||||
|
# (label, cmd, data, equip_no override or None for the client SN)
|
||||||
|
("queryInfo/1101+SN", CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
{"minProtocolVer": "V1.00.00"}, None),
|
||||||
|
("queryInfo/1101+empty", CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
{"minProtocolVer": "V1.00.00"}, ""),
|
||||||
|
("apower/1307+update2", CommunicationCmd.DEVICE_APOWER_INFO,
|
||||||
|
{"update": 2}, None),
|
||||||
|
("apower/1307+none", CommunicationCmd.DEVICE_APOWER_INFO, None, None),
|
||||||
|
("bms/1705", CommunicationCmd.BMS, None, None),
|
||||||
|
("ems/1707", CommunicationCmd.EMS, None, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--sn", required=True)
|
||||||
|
ap.add_argument("--host", default="10.100.1.1")
|
||||||
|
ap.add_argument("--ports", default="9000")
|
||||||
|
ap.add_argument("--timeout", type=float, default=5.0)
|
||||||
|
args = ap.parse_args()
|
||||||
|
print("secret key for %s: %s (radix 16)"
|
||||||
|
% (args.sn, generate_secret_key(args.sn)))
|
||||||
|
for port in [int(p) for p in args.ports.split(",") if p.strip()]:
|
||||||
|
client = LocalDeviceClient(args.sn, host=args.host,
|
||||||
|
socket_port=port, timeout=args.timeout)
|
||||||
|
for label, cmd, data, equip in VARIANTS:
|
||||||
|
try:
|
||||||
|
out = client.send_probe(cmd, data=data, timeout=args.timeout,
|
||||||
|
equip_no=equip)
|
||||||
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
|
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__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host probe v4 — ``opt``-seeded payloads via the library business API.
|
||||||
|
|
||||||
|
Historical sweep from the ``communicate``-factory finding (socket payloads
|
||||||
|
always start with ``"opt": 0``). Now built into
|
||||||
|
``direct_connect_request``; kept as a variant-list driver over
|
||||||
|
``LocalDeviceClient.send_probe``.
|
||||||
|
|
||||||
|
Run from the repo root on the SoftAP-joined host:
|
||||||
|
python3 scripts/host_probe4.py --sn A03X2516013210060005 --ports 9000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
VARIANTS = (
|
||||||
|
("queryInfo+minProto+SN", CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
{"minProtocolVer": "V1.00.00"}, None),
|
||||||
|
("queryInfo+minProto+empty", CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
{"minProtocolVer": "V1.00.00"}, ""),
|
||||||
|
("apower+opt+update2", CommunicationCmd.DEVICE_APOWER_INFO,
|
||||||
|
{"update": 2}, None),
|
||||||
|
("bms+opt", CommunicationCmd.BMS, None, None),
|
||||||
|
("ems+opt", CommunicationCmd.EMS, None, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--sn", required=True)
|
||||||
|
ap.add_argument("--host", default="10.100.1.1")
|
||||||
|
ap.add_argument("--ports", default="9000")
|
||||||
|
ap.add_argument("--timeout", type=float, default=5.0)
|
||||||
|
args = ap.parse_args()
|
||||||
|
print("secret key for %s: %s (radix 16)"
|
||||||
|
% (args.sn, generate_secret_key(args.sn)))
|
||||||
|
for port in [int(p) for p in args.ports.split(",") if p.strip()]:
|
||||||
|
client = LocalDeviceClient(args.sn, host=args.host,
|
||||||
|
socket_port=port, timeout=args.timeout)
|
||||||
|
for label, cmd, data, equip in VARIANTS:
|
||||||
|
try:
|
||||||
|
out = client.send_probe(cmd, data=data, timeout=args.timeout,
|
||||||
|
equip_no=equip)
|
||||||
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
|
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__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host probe v5 — session sequencing via the library.
|
||||||
|
|
||||||
|
One persistent connection carrying discovery → directed queries with
|
||||||
|
app-like sequential ``snno``, plus close-vs-timeout reporting and
|
||||||
|
high-snno opens. Payload bytes come from ``direct_connect_request`` +
|
||||||
|
``EnvelopeCodec``; only the connection orchestration lives here.
|
||||||
|
|
||||||
|
Run from the repo root on the SoftAP-joined host:
|
||||||
|
python3 scripts/host_probe5.py --sn A03X2516013210060005 --ports 9000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
_seq = SequenceNumber(start=100)
|
||||||
|
|
||||||
|
|
||||||
|
def tx_for(codec: EnvelopeCodec, sn: str, cmd: CommunicationCmd,
|
||||||
|
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 recv_all_verbose(sock: socket.socket, idle: float = 8.0):
|
||||||
|
out = bytearray()
|
||||||
|
sock.settimeout(idle)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = sock.recv(65536)
|
||||||
|
except (socket.timeout, TimeoutError):
|
||||||
|
return bytes(out), "idle-timeout(%.1fs)" % idle
|
||||||
|
if not chunk:
|
||||||
|
return bytes(out), "closed-by-peer"
|
||||||
|
out.extend(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--sn", required=True)
|
||||||
|
ap.add_argument("--host", default="10.100.1.1")
|
||||||
|
ap.add_argument("--ports", default="9000")
|
||||||
|
ap.add_argument("--timeout", type=float, default=5.0)
|
||||||
|
args = ap.parse_args()
|
||||||
|
codec = EnvelopeCodec.for_serial(args.sn)
|
||||||
|
print("secret key for %s: %s (radix 16)"
|
||||||
|
% (args.sn, generate_secret_key(args.sn)))
|
||||||
|
ports = [int(p) for p in args.ports.split(",") if p.strip()]
|
||||||
|
for port in ports:
|
||||||
|
print("=== discovery-then-directed -> %s:%d (one connection)"
|
||||||
|
% (args.host, port))
|
||||||
|
try:
|
||||||
|
with socket.create_connection(
|
||||||
|
(args.host, port), timeout=args.timeout) as sock:
|
||||||
|
for name, cmd, data, equip in (
|
||||||
|
("disc/1101+empty", CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
{"minProtocolVer": "V1.00.00"}, ""),
|
||||||
|
("info/1101+SN", CommunicationCmd.QUERY_DEVICE_INFO,
|
||||||
|
{"minProtocolVer": "V1.00.00"}, None),
|
||||||
|
("apower/1307", CommunicationCmd.DEVICE_APOWER_INFO,
|
||||||
|
{"update": 2}, None),
|
||||||
|
("bms/1705", CommunicationCmd.BMS, {"id": 1}, None),
|
||||||
|
):
|
||||||
|
payload = tx_for(codec, args.sn, cmd, data, equip)
|
||||||
|
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__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host probe v6 — envelope variants via the library business API.
|
||||||
|
|
||||||
|
Sends the ``battery.PROBE_VARIANTS`` envelopes through
|
||||||
|
``LocalDeviceClient.send_probe`` (sequence numbers, ``opt``-seeded
|
||||||
|
payloads, socket ids and cipher all come from the library) and prints
|
||||||
|
exact TX/RX bytes plus any parsed reply/SoC.
|
||||||
|
|
||||||
|
Run from the repo root on the SoftAP-joined host:
|
||||||
|
python3 scripts/host_probe6.py --sn A03X2516013210060005 --ports 9000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(
|
||||||
|
os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from franklinwh_agate_client.battery import ( # noqa: E402
|
||||||
|
extract_soc,
|
||||||
|
probe_device,
|
||||||
|
)
|
||||||
|
from franklinwh_agate_client.protocol import ( # noqa: E402
|
||||||
|
generate_secret_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--sn", required=True)
|
||||||
|
ap.add_argument("--host", default="10.100.1.1")
|
||||||
|
ap.add_argument("--ports", default="9000,18000")
|
||||||
|
ap.add_argument("--timeout", type=float, default=5.0)
|
||||||
|
args = ap.parse_args()
|
||||||
|
print("secret key for %s: %s (radix 16)"
|
||||||
|
% (args.sn, generate_secret_key(args.sn)))
|
||||||
|
ports = tuple(int(p) for p in args.ports.split(",") if p.strip())
|
||||||
|
try:
|
||||||
|
results = probe_device(args.sn, host=args.host, ports=ports,
|
||||||
|
timeout=args.timeout)
|
||||||
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
|
print("ERROR: %s: %s" % (type(exc).__name__, exc))
|
||||||
|
return 1
|
||||||
|
for out in results:
|
||||||
|
print("--- %s -> %s:%d cmd=%s"
|
||||||
|
% (out["variant"], args.host, out["port"], out["cmd"]))
|
||||||
|
if out.get("envelope") is not None:
|
||||||
|
print(" envelope: " + json.dumps(out["envelope"], default=str))
|
||||||
|
tx = out.get("tx") or b""
|
||||||
|
print(" tx (%d B): %s" % (len(tx), tx.hex()))
|
||||||
|
rx = out.get("reply_raw") or b""
|
||||||
|
print(" rx (%d B): %s" % (len(rx), rx.hex()[:400] or "(empty)"))
|
||||||
|
if out.get("reply") is not None:
|
||||||
|
print(" reply: " + json.dumps(out["reply"], default=str)[:2000])
|
||||||
|
soc = extract_soc(out["reply"])
|
||||||
|
print(" soc: %s (source=%s)" % (soc["value"], soc["source"]))
|
||||||
|
if out.get("error"):
|
||||||
|
print(" error: %s" % out["error"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host probe v7 — battery charge via the business operations API.
|
||||||
|
|
||||||
|
One call per port: ``LocalDeviceClient.get_battery_soc`` (discovery,
|
||||||
|
apower, per-pack BMS reads). Prints the percentage.
|
||||||
|
|
||||||
|
Run from the repo root on the SoftAP-joined host:
|
||||||
|
python3 scripts/host_probe7.py --sn A03X2516013210060005 --ports 9000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
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
|
||||||
|
generate_secret_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--sn", required=True)
|
||||||
|
ap.add_argument("--host", default="10.100.1.1")
|
||||||
|
ap.add_argument("--ports", default="9000")
|
||||||
|
ap.add_argument("--timeout", type=float, default=5.0)
|
||||||
|
ap.add_argument("--raw", action="store_true",
|
||||||
|
help="print the full reply envelope")
|
||||||
|
args = ap.parse_args()
|
||||||
|
print("secret key for %s: %s (radix 16)"
|
||||||
|
% (args.sn, generate_secret_key(args.sn)))
|
||||||
|
for port in [int(p) for p in args.ports.split(",") if p.strip()]:
|
||||||
|
client = LocalDeviceClient(args.sn, host=args.host,
|
||||||
|
socket_port=port, timeout=args.timeout)
|
||||||
|
try:
|
||||||
|
result = client.get_battery_soc()
|
||||||
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
|
print("=== port %d: error: %s: %s"
|
||||||
|
% (port, type(exc).__name__, exc))
|
||||||
|
continue
|
||||||
|
print("=== port %d" % port)
|
||||||
|
if args.raw:
|
||||||
|
print(json.dumps(result.get("reply"), indent=2, default=str))
|
||||||
|
return 0
|
||||||
|
value = result["value"]
|
||||||
|
print("==> soc: %s%% (source=%s, cmd=%s)"
|
||||||
|
% (value, result["source"], result["cmd"])
|
||||||
|
if value is not None else "==> soc: unknown")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host probe v8 — household wattage via the business operations API.
|
||||||
|
|
||||||
|
One call per port: ``LocalDeviceClient.get_household_wattage`` (realtime
|
||||||
|
detail → per-switch load → EMS math). Prints the watts.
|
||||||
|
|
||||||
|
Run from the repo root on the SoftAP-joined host:
|
||||||
|
python3 scripts/host_probe8.py --sn A03X2516013210060005 --ports 9000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
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
|
||||||
|
generate_secret_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--sn", required=True)
|
||||||
|
ap.add_argument("--host", default="10.100.1.1")
|
||||||
|
ap.add_argument("--ports", default="9000")
|
||||||
|
ap.add_argument("--timeout", type=float, default=5.0)
|
||||||
|
ap.add_argument("--raw", action="store_true",
|
||||||
|
help="print the full reply envelope")
|
||||||
|
args = ap.parse_args()
|
||||||
|
print("secret key for %s: %s (radix 16)"
|
||||||
|
% (args.sn, generate_secret_key(args.sn)))
|
||||||
|
for port in [int(p) for p in args.ports.split(",") if p.strip()]:
|
||||||
|
client = LocalDeviceClient(args.sn, host=args.host,
|
||||||
|
socket_port=port, timeout=args.timeout)
|
||||||
|
try:
|
||||||
|
result = client.get_household_wattage()
|
||||||
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
|
print("=== port %d: error: %s: %s"
|
||||||
|
% (port, type(exc).__name__, exc))
|
||||||
|
continue
|
||||||
|
print("=== port %d" % port)
|
||||||
|
if args.raw:
|
||||||
|
print(json.dumps(result.get("reply"), indent=2, default=str))
|
||||||
|
return 0
|
||||||
|
value = result["value"]
|
||||||
|
if value is not None:
|
||||||
|
print("==> watts: %gW (source=%s, cmd=%s)"
|
||||||
|
% (value, result["source"], result["cmd"]))
|
||||||
|
else:
|
||||||
|
print("==> watts: unknown")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
{"cmdType":1101,"equipNo":"00000000",Ç ÌåÜÙÐ#(#'Úóëòõïïð÷úò÷ðç9568ìýùð;5?ô␍ù;K=ýÿ%+
|
||||||
|
MK_M.`TQ,ndfk3*'jgmPsqwshus^n|-F/d@>ABADE8”•
|
||||||
@@ -37,3 +37,39 @@ class TestExtractSoc(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBmsSoc(unittest.TestCase):
|
||||||
|
def test_batsoc_key(self):
|
||||||
|
from franklinwh_agate_client.battery import SOC_KEYS
|
||||||
|
self.assertIn("batSoc", SOC_KEYS)
|
||||||
|
|
||||||
|
def test_batsoc_extracted(self):
|
||||||
|
reply = {"cmdType": 1706,
|
||||||
|
"dataArea": {"opt": 0, "result": 0, "id": 1, "batSoc": 87,
|
||||||
|
"batSoh": 100}}
|
||||||
|
out = extract_soc(reply)
|
||||||
|
self.assertEqual(out["value"], 87)
|
||||||
|
self.assertEqual(out["source"], "batSoc")
|
||||||
|
|
||||||
|
def test_bms_query_carries_pack_id(self):
|
||||||
|
from franklinwh_agate_client.protocol import (
|
||||||
|
CommunicationCmd, direct_connect_request)
|
||||||
|
env = direct_connect_request(CommunicationCmd.BMS, "SN",
|
||||||
|
data={"id": 2})
|
||||||
|
self.assertEqual(env.to_dict()["dataArea"], {"opt": 0, "id": 2})
|
||||||
|
self.assertEqual(env.cmd_type, 1705)
|
||||||
|
|
||||||
|
def test_empty_pack_slot_skipped(self):
|
||||||
|
from franklinwh_agate_client.battery import _is_empty_pack
|
||||||
|
reply = {"dataArea": {"opt": 0, "result": 0, "id": 0, "batSoc": 0,
|
||||||
|
"batSoh": 0, "batTotalVolt": 0}}
|
||||||
|
self.assertTrue(_is_empty_pack(
|
||||||
|
reply, {"value": 0.0, "source": "batSoc"}))
|
||||||
|
|
||||||
|
def test_genuine_zero_pack_kept(self):
|
||||||
|
from franklinwh_agate_client.battery import _is_empty_pack
|
||||||
|
reply = {"dataArea": {"opt": 0, "result": 0, "id": 1, "batSoc": 0,
|
||||||
|
"batSoh": 96, "batTotalVolt": 512}}
|
||||||
|
self.assertFalse(_is_empty_pack(
|
||||||
|
reply, {"value": 0.0, "source": "batSoc"}))
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Offline tests for the business operations (transport stubbed)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from franklinwh_agate_client.device_local import LocalDeviceClient
|
||||||
|
from franklinwh_agate_client.protocol import CommunicationCmd
|
||||||
|
|
||||||
|
|
||||||
|
def _client(handler):
|
||||||
|
client = LocalDeviceClient("SN", host="127.0.0.1", timeout=0.1)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_send_rpc(cmd, equip_no=None, operate=None, data=None,
|
||||||
|
envelope=None):
|
||||||
|
calls.append((cmd, equip_no, data))
|
||||||
|
return handler(cmd, equip_no, data)
|
||||||
|
|
||||||
|
client.send_rpc = fake_send_rpc
|
||||||
|
return client, calls
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatterySocOp(unittest.TestCase):
|
||||||
|
def test_apower_win(self):
|
||||||
|
client, calls = _client(
|
||||||
|
lambda cmd, eq, data: {"dataArea": {"currentBatterySoc": 78}})
|
||||||
|
out = client.get_battery_soc()
|
||||||
|
self.assertEqual(out["value"], 78)
|
||||||
|
self.assertEqual(out["source"], "currentBatterySoc")
|
||||||
|
|
||||||
|
def test_bms_pack_fallback(self):
|
||||||
|
def handler(cmd, eq, data):
|
||||||
|
if cmd == CommunicationCmd.DEVICE_APOWER_INFO:
|
||||||
|
return {"dataArea": {"result": 1, "reason": -1}}
|
||||||
|
if (data or {}).get("id") == 1:
|
||||||
|
return {"dataArea": {"opt": 0, "result": 0, "id": 1,
|
||||||
|
"batSoc": 64, "batSoh": 99,
|
||||||
|
"batTotalVolt": 512}}
|
||||||
|
return {"dataArea": {"opt": 0, "result": 0, "id": 0,
|
||||||
|
"batSoc": 0, "batSoh": 0,
|
||||||
|
"batTotalVolt": 0}}
|
||||||
|
|
||||||
|
client, calls = _client(handler)
|
||||||
|
out = client.get_battery_soc()
|
||||||
|
self.assertEqual(out["value"], 64)
|
||||||
|
self.assertEqual(out["source"], "batSoc")
|
||||||
|
# discovery + apower + bms id=1 (empty id=0 never needed)
|
||||||
|
ids = [d.get("id") if isinstance(d, dict) else None
|
||||||
|
for _, _, d in calls]
|
||||||
|
self.assertIn(1, ids)
|
||||||
|
|
||||||
|
def test_empty_slots_skipped(self):
|
||||||
|
client, _ = _client(
|
||||||
|
lambda cmd, eq, data: {"dataArea": {"opt": 0, "result": 0,
|
||||||
|
"id": 0, "batSoc": 0,
|
||||||
|
"batSoh": 0,
|
||||||
|
"batTotalVolt": 0}})
|
||||||
|
out = client.get_battery_soc()
|
||||||
|
self.assertIsNone(out["value"])
|
||||||
|
|
||||||
|
def test_transport_error_propagates(self):
|
||||||
|
client, _ = _client(None)
|
||||||
|
|
||||||
|
def boom(cmd, equip_no=None, operate=None, data=None,
|
||||||
|
envelope=None):
|
||||||
|
raise ConnectionError("down")
|
||||||
|
|
||||||
|
client.send_rpc = boom
|
||||||
|
with self.assertRaises(ConnectionError):
|
||||||
|
client.get_battery_soc()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWattageOp(unittest.TestCase):
|
||||||
|
def test_detail_win(self):
|
||||||
|
client, _ = _client(
|
||||||
|
lambda cmd, eq, data: {"dataArea": {"apowerCount": 2,
|
||||||
|
"totalPower": 1872.5}})
|
||||||
|
out = client.get_household_wattage()
|
||||||
|
self.assertEqual(out["value"], 1872.5)
|
||||||
|
self.assertEqual(out["source"], "totalPower")
|
||||||
|
|
||||||
|
def test_ems_math_fallback(self):
|
||||||
|
def handler(cmd, eq, data):
|
||||||
|
if cmd == CommunicationCmd.EMS:
|
||||||
|
return {"dataArea": {"gridVol1": 120.0, "gridVol2": 120.0,
|
||||||
|
"loadCurr1": 5.0, "loadCurr2": 5.0}}
|
||||||
|
return {"dataArea": {"result": 1, "reason": -1}}
|
||||||
|
|
||||||
|
client, _ = _client(handler)
|
||||||
|
out = client.get_household_wattage()
|
||||||
|
self.assertAlmostEqual(out["value"], 1200.0)
|
||||||
|
self.assertEqual(out["source"], "ems(loadCurr*V)")
|
||||||
|
|
||||||
|
def test_inventory(self):
|
||||||
|
client, _ = _client(
|
||||||
|
lambda cmd, eq, data: {
|
||||||
|
"cmdType": 1102, "equipNo": "IBG",
|
||||||
|
"dataArea": {"opt": 0, "result": 0, "FHP_SN": ["A", "B"],
|
||||||
|
"BMS_SN": ["C"], "protocolVer": "V1"}})
|
||||||
|
out = client.get_device_inventory()
|
||||||
|
self.assertEqual(out["ibg_sn"], "IBG")
|
||||||
|
self.assertEqual(out["fhp_sn"], ["A", "B"])
|
||||||
|
self.assertEqual(out["protocol_ver"], "V1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWifiOps(unittest.TestCase):
|
||||||
|
def test_join_home_wifi_shape(self):
|
||||||
|
from franklinwh_agate_client.protocol import direct_connect_request
|
||||||
|
from franklinwh_agate_client.protocol import CommunicationCmd
|
||||||
|
from franklinwh_agate_client.protocol import CommunicationOperate
|
||||||
|
env = direct_connect_request(
|
||||||
|
CommunicationCmd.CONNECT_WIFI, "SN",
|
||||||
|
data={"ssid": "Home", "password": "pw",
|
||||||
|
"wifi_SSID": "Home", "wifi_Pw": "pw"},
|
||||||
|
operate=CommunicationOperate.SET)
|
||||||
|
d = env.to_dict()
|
||||||
|
self.assertEqual(d["cmdType"], 1111) # socket id of connectWifi
|
||||||
|
self.assertEqual(d["type"], 1) # SET
|
||||||
|
self.assertEqual(d["dataArea"]["opt"], 1)
|
||||||
|
self.assertEqual(d["dataArea"]["ssid"], "Home")
|
||||||
|
|
||||||
|
def test_scan_and_status_shape(self):
|
||||||
|
from franklinwh_agate_client.protocol import direct_connect_request
|
||||||
|
from franklinwh_agate_client.protocol import CommunicationCmd
|
||||||
|
scan = direct_connect_request(CommunicationCmd.SCAN_WIFI_LIST, "SN")
|
||||||
|
self.assertEqual(scan.cmd_type, 1109)
|
||||||
|
self.assertEqual(scan.to_dict()["dataArea"], {"opt": 0})
|
||||||
|
status = direct_connect_request(
|
||||||
|
CommunicationCmd.CONNECTION_STATUS, "SN")
|
||||||
|
self.assertEqual(status.cmd_type, 1113)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Offline tests for live-power extraction (no radio/hardware needed)."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from franklinwh_agate_client.power import (
|
||||||
|
ems_load_watts,
|
||||||
|
extract_power,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractPower(unittest.TestCase):
|
||||||
|
def test_total_power_wins(self):
|
||||||
|
reply = {"cmdType": 1301,
|
||||||
|
"dataArea": {"apowerCount": 2, "totalPower": 1872.5}}
|
||||||
|
out = extract_power(reply)
|
||||||
|
self.assertEqual(out["value"], 1872.5)
|
||||||
|
self.assertEqual(out["source"], "totalPower")
|
||||||
|
|
||||||
|
def test_switch_power_fallback(self):
|
||||||
|
reply = {"dataArea": {"index": 1, "Sw1Volt": 120.1,
|
||||||
|
"SW1Curr": 3.2, "SW1ExpPower": 384.0}}
|
||||||
|
out = extract_power(reply)
|
||||||
|
self.assertEqual(out["value"], 384.0)
|
||||||
|
self.assertEqual(out["source"], "SW1ExpPower")
|
||||||
|
|
||||||
|
def test_missing_power(self):
|
||||||
|
out = extract_power({"dataArea": {"result": 1, "reason": -1}})
|
||||||
|
self.assertIsNone(out["value"])
|
||||||
|
self.assertIsNone(out["source"])
|
||||||
|
|
||||||
|
def test_ems_load_watts_math(self):
|
||||||
|
reply = {"dataArea": {"gridVol1": 120.4, "gridVol2": 120.5,
|
||||||
|
"loadCurr1": 7.8, "loadCurr2": 7.8,
|
||||||
|
"gridLineVol": 2410}}
|
||||||
|
watts = ems_load_watts(reply)
|
||||||
|
self.assertAlmostEqual(watts, 15.6 * 120.45, places=1)
|
||||||
|
|
||||||
|
def test_ems_load_watts_missing(self):
|
||||||
|
self.assertIsNone(ems_load_watts({"dataArea": {}}))
|
||||||
|
self.assertIsNone(ems_load_watts({}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+139
-39
@@ -6,6 +6,7 @@ No radio/hardware needed. Vectors mirror the blutter disassembly of
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from franklinwh_agate_client.protocol import (
|
from franklinwh_agate_client.protocol import (
|
||||||
@@ -13,15 +14,18 @@ from franklinwh_agate_client.protocol import (
|
|||||||
CommunicationCmd,
|
CommunicationCmd,
|
||||||
CommunicationObject,
|
CommunicationObject,
|
||||||
SequenceNumber,
|
SequenceNumber,
|
||||||
|
socket_cmd_type,
|
||||||
SocketFrameDecoder,
|
SocketFrameDecoder,
|
||||||
decrypt_data,
|
decrypt_data,
|
||||||
decrypt_request,
|
decrypt_request,
|
||||||
|
decrypt_wire,
|
||||||
direct_connect_request,
|
direct_connect_request,
|
||||||
encrypt_object,
|
encrypt_object,
|
||||||
frame_socket_payload,
|
frame_socket_payload,
|
||||||
generate_secret_key,
|
generate_secret_key,
|
||||||
header_len_for,
|
header_len_for,
|
||||||
split_envelope,
|
split_envelope,
|
||||||
|
split_wire,
|
||||||
)
|
)
|
||||||
from franklinwh_agate_client.protocol.crypto import MARKER
|
from franklinwh_agate_client.protocol.crypto import MARKER
|
||||||
|
|
||||||
@@ -38,13 +42,32 @@ def _apower_json(snno=1, time_stamp=1792105196):
|
|||||||
return env.to_json_string()
|
return env.to_json_string()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSocketCmdSpace(unittest.TestCase):
|
||||||
|
def test_socket_ids(self):
|
||||||
|
self.assertEqual(socket_cmd_type(CommunicationCmd.QUERY_DEVICE_INFO), 1101)
|
||||||
|
self.assertEqual(socket_cmd_type(CommunicationCmd.DEVICE_APOWER_INFO), 1307)
|
||||||
|
self.assertEqual(socket_cmd_type(CommunicationCmd.BMS), 1705)
|
||||||
|
self.assertEqual(socket_cmd_type(CommunicationCmd.EMS), 1707)
|
||||||
|
|
||||||
|
def test_direct_request_uses_socket_id(self):
|
||||||
|
env = direct_connect_request(CommunicationCmd.DEVICE_APOWER_INFO, SN)
|
||||||
|
self.assertEqual(env.cmd_type, 1307)
|
||||||
|
self.assertTrue(env.to_json_string().startswith('{"cmdType":1307,'))
|
||||||
|
|
||||||
|
def test_discovery_shape_like_capture(self):
|
||||||
|
env = direct_connect_request(CommunicationCmd.QUERY_DEVICE_INFO, "")
|
||||||
|
body = env.to_json_string()
|
||||||
|
self.assertTrue(body.startswith(
|
||||||
|
'{"cmdType":1101,"equipNo":"00000000",'))
|
||||||
|
|
||||||
|
|
||||||
class TestSecretKey(unittest.TestCase):
|
class TestSecretKey(unittest.TestCase):
|
||||||
def test_real_sn_key(self):
|
def test_real_sn_key(self):
|
||||||
self.assertEqual(generate_secret_key(SN), "1c")
|
self.assertEqual(generate_secret_key(SN), "1c")
|
||||||
|
|
||||||
def test_key_radix_is_32(self):
|
def test_key_radix_is_16(self):
|
||||||
self.assertEqual(DEFAULT_KEY_RADIX, 32)
|
self.assertEqual(DEFAULT_KEY_RADIX, 16)
|
||||||
self.assertEqual(int(generate_secret_key(SN), 32), 44)
|
self.assertEqual(int(generate_secret_key(SN), 16), 28)
|
||||||
|
|
||||||
def test_empty_sn_key(self):
|
def test_empty_sn_key(self):
|
||||||
self.assertEqual(generate_secret_key(""), "0")
|
self.assertEqual(generate_secret_key(""), "0")
|
||||||
@@ -61,30 +84,28 @@ class TestEnvelope(unittest.TestCase):
|
|||||||
"crc", "dataArea"],
|
"crc", "dataArea"],
|
||||||
)
|
)
|
||||||
self.assertNotIn("data", d)
|
self.assertNotIn("data", d)
|
||||||
|
# socket payload is always seeded with the operation type first
|
||||||
|
self.assertEqual(d["dataArea"], {"opt": 0, "update": 2})
|
||||||
|
|
||||||
def test_no_data_means_seven_keys(self):
|
def test_no_data_still_carries_opt(self):
|
||||||
env = direct_connect_request(CommunicationCmd.BMS, SN, data=None)
|
env = direct_connect_request(CommunicationCmd.BMS, SN, data=None)
|
||||||
d = env.to_dict()
|
d = env.to_dict()
|
||||||
self.assertEqual(
|
self.assertIn("dataArea", d)
|
||||||
list(d),
|
self.assertEqual(d["dataArea"], {"opt": 0})
|
||||||
["cmdType", "equipNo", "type", "timeStamp", "snno", "len", "crc"],
|
self.assertEqual(env.length, len(b'{"opt":0}'))
|
||||||
)
|
self.assertTrue(env.crc)
|
||||||
self.assertEqual(env.length, 0)
|
|
||||||
self.assertEqual(env.crc, "")
|
|
||||||
|
|
||||||
def test_empty_map_means_no_data_area(self):
|
def test_empty_map_still_carries_opt(self):
|
||||||
env = direct_connect_request(CommunicationCmd.BMS, SN, data={})
|
env = direct_connect_request(CommunicationCmd.BMS, SN, data={})
|
||||||
self.assertNotIn("dataArea", env.to_dict())
|
self.assertEqual(env.to_dict()["dataArea"], {"opt": 0})
|
||||||
self.assertEqual(env.length, 0)
|
|
||||||
self.assertEqual(env.crc, "")
|
|
||||||
|
|
||||||
def test_len_crc_auto_uppercase(self):
|
def test_len_crc_auto_uppercase(self):
|
||||||
env = direct_connect_request(
|
env = direct_connect_request(
|
||||||
CommunicationCmd.DEVICE_APOWER_INFO, SN, data={"update": 2})
|
CommunicationCmd.DEVICE_APOWER_INFO, SN, data={"update": 2})
|
||||||
self.assertEqual(env.length, len(b'{"update":2}'))
|
self.assertEqual(env.length, len(b'{"opt":0,"update":2}'))
|
||||||
import zlib
|
import zlib
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
env.crc, format(zlib.crc32(b'{"update":2}'), "X"))
|
env.crc, format(zlib.crc32(b'{"opt":0,"update":2}'), "X"))
|
||||||
self.assertEqual(env.crc, env.crc.upper())
|
self.assertEqual(env.crc, env.crc.upper())
|
||||||
|
|
||||||
def test_int_fields(self):
|
def test_int_fields(self):
|
||||||
@@ -126,11 +147,11 @@ class TestCipher(unittest.TestCase):
|
|||||||
|
|
||||||
def test_header_len_matches(self):
|
def test_header_len_matches(self):
|
||||||
a, _ = split_envelope(_apower_json())
|
a, _ = split_envelope(_apower_json())
|
||||||
self.assertEqual(header_len_for(0x19, SN), len(a))
|
self.assertEqual(header_len_for(1307, SN), len(a))
|
||||||
|
|
||||||
def test_stream_is_absolute_index_plus_n(self):
|
def test_stream_is_absolute_index_plus_n(self):
|
||||||
a, b = split_envelope(_apower_json())
|
a, b = split_envelope(_apower_json())
|
||||||
n = int(generate_secret_key(SN), 32)
|
n = int(generate_secret_key(SN), 16)
|
||||||
wire = encrypt_object(_apower_json(), generate_secret_key(SN))
|
wire = encrypt_object(_apower_json(), generate_secret_key(SN))
|
||||||
tail = wire[len(a):]
|
tail = wire[len(a):]
|
||||||
for i, byte in enumerate(b):
|
for i, byte in enumerate(b):
|
||||||
@@ -141,20 +162,14 @@ class TestCipher(unittest.TestCase):
|
|||||||
wire = encrypt_object(body, generate_secret_key(SN))
|
wire = encrypt_object(body, generate_secret_key(SN))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
decrypt_request(wire, generate_secret_key(SN),
|
decrypt_request(wire, generate_secret_key(SN),
|
||||||
cmd_type=0x19, equip_no=SN),
|
cmd_type=1307, equip_no=SN),
|
||||||
body,
|
body,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_decrypt_response_roundtrip(self):
|
def test_wire_roundtrip(self):
|
||||||
body = _apower_json()
|
body = _apower_json()
|
||||||
cut = body.index('"type"')
|
wire = encrypt_object(body, generate_secret_key(SN))
|
||||||
s, tail = body[:cut - 1], body[cut:]
|
self.assertEqual(decrypt_wire(wire), body)
|
||||||
n = int(generate_secret_key(SN), 32)
|
|
||||||
prefix_len = len((s + ",").encode("utf-8"))
|
|
||||||
enc = bytes((byte + k + prefix_len + n) & 0xFF
|
|
||||||
for k, byte in enumerate(tail.encode("utf-8")))
|
|
||||||
resp = s.encode("utf-8") + bytes((MARKER,)) + enc
|
|
||||||
self.assertEqual(decrypt_data(resp), body)
|
|
||||||
|
|
||||||
def test_decrypt_data_failures_are_empty(self):
|
def test_decrypt_data_failures_are_empty(self):
|
||||||
self.assertEqual(decrypt_data(b""), "")
|
self.assertEqual(decrypt_data(b""), "")
|
||||||
@@ -171,22 +186,107 @@ class TestSocketFraming(unittest.TestCase):
|
|||||||
self.assertEqual(frame_socket_payload(payload), payload)
|
self.assertEqual(frame_socket_payload(payload), payload)
|
||||||
|
|
||||||
def test_decoder_accumulates_until_json(self):
|
def test_decoder_accumulates_until_json(self):
|
||||||
body = _apower_json()
|
wire = open(os.path.join(FIXTURES, "discovery_req.bin"),
|
||||||
cut = body.index('"type"')
|
"rb").read()
|
||||||
s, tail = body[:cut - 1], body[cut:]
|
|
||||||
n = int(generate_secret_key(SN), 32)
|
|
||||||
prefix_len = len((s + ",").encode("utf-8"))
|
|
||||||
enc = bytes((byte + k + prefix_len + n) & 0xFF
|
|
||||||
for k, byte in enumerate(tail.encode("utf-8")))
|
|
||||||
resp = s.encode("utf-8") + bytes((MARKER,)) + enc
|
|
||||||
dec = SocketFrameDecoder()
|
dec = SocketFrameDecoder()
|
||||||
# first half: incomplete -> nothing yet
|
# first half: incomplete -> nothing yet
|
||||||
self.assertEqual(dec.feed(resp[:len(resp) // 2]), [])
|
self.assertEqual(dec.feed(wire[:len(wire) // 2]), [])
|
||||||
# rest: one complete JSON string
|
# rest: one complete JSON string
|
||||||
out = dec.feed(resp[len(resp) // 2:])
|
out = dec.feed(wire[len(wire) // 2:])
|
||||||
self.assertEqual(len(out), 1)
|
self.assertEqual(len(out), 1)
|
||||||
self.assertEqual(json.loads(out[0])["equipNo"], SN)
|
self.assertEqual(json.loads(out[0])["equipNo"], "00000000")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveCaptures(unittest.TestCase):
|
||||||
|
"""Golden vectors: two real frames (app request + device reply)."""
|
||||||
|
|
||||||
|
def test_key_radix_is_16(self):
|
||||||
|
self.assertEqual(DEFAULT_KEY_RADIX, 16)
|
||||||
|
self.assertEqual(generate_secret_key("00000000"), "80")
|
||||||
|
self.assertEqual(int("80", 16), 128)
|
||||||
|
self.assertEqual(int(generate_secret_key(SN), 16), 28)
|
||||||
|
|
||||||
|
def test_discovery_request_vector(self):
|
||||||
|
wire = open(os.path.join(FIXTURES, "discovery_req.bin"), "rb").read()
|
||||||
|
self.assertEqual(len(wire), 153)
|
||||||
|
a, b = split_wire(wire)
|
||||||
|
self.assertEqual(a, b'{"cmdType":1101,"equipNo":"00000000",')
|
||||||
|
self.assertEqual(
|
||||||
|
decrypt_wire(wire),
|
||||||
|
'{"cmdType":1101,"equipNo":"00000000","type":0,'
|
||||||
|
'"timeStamp":1792117904,"snno":1,"len":37,"crc":"A474C16F",'
|
||||||
|
'"dataArea":{"opt":0,"minProtocolVer":"V1.00.00"}}',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_device_info_reply_vector(self):
|
||||||
|
wire = open(os.path.join(FIXTURES, "device_info_reply.bin"),
|
||||||
|
"rb").read()
|
||||||
|
self.assertEqual(len(wire), 1154)
|
||||||
|
a, b = split_wire(wire)
|
||||||
|
self.assertEqual(
|
||||||
|
a, b'{"cmdType":1102,"equipNo":"10060005A03X25160132",')
|
||||||
|
reply = json.loads(decrypt_wire(wire))
|
||||||
|
self.assertEqual(reply["cmdType"], 1102)
|
||||||
|
self.assertEqual(reply["equipNo"], "10060005A03X25160132")
|
||||||
|
self.assertEqual(reply["len"], 1024)
|
||||||
|
data = reply["dataArea"]
|
||||||
|
self.assertEqual(data["opt"], 0)
|
||||||
|
self.assertEqual(data["result"], 0)
|
||||||
|
self.assertEqual(data["IBG_SN"], "10060005A03X25160132")
|
||||||
|
self.assertEqual(data["FHP_NUM"], 2)
|
||||||
|
self.assertEqual(len(data["FHP_SN"]), 2)
|
||||||
|
self.assertEqual(len(data["BMS_SN"]), 2)
|
||||||
|
# len/crc integrity, as the device checks them
|
||||||
|
import zlib
|
||||||
|
compact = json.dumps(data, separators=(",", ":"),
|
||||||
|
ensure_ascii=False)
|
||||||
|
self.assertEqual(reply["len"], len(compact.encode("utf-8")))
|
||||||
|
self.assertEqual(
|
||||||
|
reply["crc"], format(zlib.crc32(compact.encode()), "X"))
|
||||||
|
|
||||||
|
def test_split_wire_errors(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
split_wire(b"no commas here")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
split_wire(b'{"cmdType":1101')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
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