Socket-transport cmd space (off_14 ids) from live capture: 1101/1307/1705/1707 + host_probe3
This commit is contained in:
1 parent
2cdc274fe3
commit
213a0518d6
7 files changed
+401
-60
No files matched your search
+38
-12
@@ -110,6 +110,8 @@ Null/empty payload ⇒ `len == 0`, `crc == ""`, no `dataArea` key.
|
|||||||
|
|
||||||
## 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 +134,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
|
||||||
@@ -167,6 +180,15 @@ So requests are `S1 || transform(S2)` (split at the computable header
|
|||||||
length) while responses are `s || 0x58 || transform(tail)` (split at the
|
length) while responses are `s || 0x58 || transform(tail)` (split at the
|
||||||
second `0x58` marker). Both halves share the one additive stream.
|
second `0x58` marker). Both halves share the one additive stream.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
- **CRC16** (`utils/crc16.dart`): init `0xFFFF`, poly `0xA001` (reflected), i.e.
|
- **CRC16** (`utils/crc16.dart`): init `0xFFFF`, poly `0xA001` (reflected), i.e.
|
||||||
@@ -237,22 +259,26 @@ 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-15) — socket cmd space + live capture
|
||||||
- cleartext HTTP `GET` (`47 45` = `17735`) hangs forever (read timeout).
|
|
||||||
- Earlier probes are superseded: they used an int16LE length prefix (the
|
All pre-2026-10-15 probes used `off_8` ids (`25/0/29/30`) and were dropped
|
||||||
socket layer sends **raw**), the old `transform(b) || a` order (correct
|
silently — the socket transport speaks the `off_14` id space (§4), so the
|
||||||
is `a || transform(b)` — §5), a truncated `equipNo`, and default radix
|
device saw unknown commands. A PCAPdroid capture of the working app shows
|
||||||
16. All are corrected in the current PoC; see `BATTERY_POC.md` for the
|
real socket frames: `{"cmdType":1101,…}` (queryDeviceInfo, empty SN) and
|
||||||
host probe recipe with the true SN.
|
`{"cmdType":1102,"equipNo":"1006005A03X25160132",…}` (922 B). Open:
|
||||||
|
dst IP:port + direction per frame (need capture metadata / full `.pcap`),
|
||||||
|
the meaning of `1102` (no pool cmd maps to `0x44E`), and the 17-char
|
||||||
|
`equipNo` (sub-device? battery unit? reply convention?). Current probes
|
||||||
|
use socket-space ids (`1101/1307/1705/1707`) via `scripts/host_probe3.py`.
|
||||||
|
|
||||||
## 9. Bluetooth (BLE) transport — `communication_center/bluetooth`
|
## 9. Bluetooth (BLE) transport — `communication_center/bluetooth`
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -35,8 +41,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,
|
||||||
@@ -54,6 +64,8 @@ __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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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).
|
||||||
@@ -180,12 +180,14 @@ def direct_connect_request(
|
|||||||
|
|
||||||
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). Note ``snno``
|
||||||
|
is assigned by ``sendObject`` (see :class:`SequenceNumber`); pass the
|
||||||
|
issued value when re-serialising.
|
||||||
"""
|
"""
|
||||||
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=CommunicationOperate.QUERY,
|
||||||
snno=snno,
|
snno=snno,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Host probe v3 — socket-transport command ids (stdlib only).
|
||||||
|
|
||||||
|
A PCAPdroid capture of the working app showed ``{"cmdType":1101, ...}`` on
|
||||||
|
the wire: ``CommunicationCmd::cmd`` returns the *second* id field for
|
||||||
|
socket mode (QUERY_DEVICE_INFO 1101, APOWER 1307, BMS 1705, EMS 1707).
|
||||||
|
Earlier probes used the small ids (25/0/29/30) and were dropped silently.
|
||||||
|
|
||||||
|
Run on the SoftAP-joined host:
|
||||||
|
python3 host_probe3.py --sn A03X2516013210060005 --ports 9000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
# Wire cmdType per CommunicationCmd::cmd (socket mode).
|
||||||
|
APOWER, QUERY_INFO, BMS, EMS = 0x51B, 0x44D, 0x6A9, 0x6AB
|
||||||
|
SNNO = [0]
|
||||||
|
|
||||||
|
|
||||||
|
def secret_key(sn):
|
||||||
|
return format(sum(sn.encode()) & 0xFF, "x")
|
||||||
|
|
||||||
|
|
||||||
|
def build_envelope(sn, cmd, data=None, snno=None, ts=None, equip=None):
|
||||||
|
SNNO[0] = (SNNO[0] % 9999) + 1
|
||||||
|
env = {"cmdType": cmd, "equipNo": equip if equip is not None else sn,
|
||||||
|
"type": 0,
|
||||||
|
"timeStamp": ts if ts is not None else int(time.time()),
|
||||||
|
"snno": snno if snno is not None else SNNO[0], "len": 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 decrypt_data(wire, radix=32):
|
||||||
|
try:
|
||||||
|
i = wire.find(b"\x58")
|
||||||
|
if i < 0:
|
||||||
|
return ""
|
||||||
|
j = wire.find(b"\x58", i + 1)
|
||||||
|
if j < 0:
|
||||||
|
return ""
|
||||||
|
s = wire[:j].decode()
|
||||||
|
probe = json.loads(s + "}")
|
||||||
|
if not isinstance(probe, dict):
|
||||||
|
return ""
|
||||||
|
n = int(secret_key(str(probe.get("equipNo", ""))), radix)
|
||||||
|
prefix = s + ","
|
||||||
|
tail = wire[j + 1:]
|
||||||
|
pl = len(prefix.encode())
|
||||||
|
plain = bytes((t - k - pl - n) & 0xFF for k, t in enumerate(tail))
|
||||||
|
return prefix + plain.decode()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def recv_all(sock, idle=6.0):
|
||||||
|
out = bytearray()
|
||||||
|
sock.settimeout(idle)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = sock.recv(65536)
|
||||||
|
except (socket.timeout, TimeoutError):
|
||||||
|
break
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.extend(chunk)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def run(label, host, port, tx, timeout, pre_read=0.0):
|
||||||
|
rx_pre, rx, err, reply = b"", b"", None, None
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port),
|
||||||
|
timeout=timeout) as sock:
|
||||||
|
if pre_read:
|
||||||
|
rx_pre = recv_all(sock, idle=pre_read)
|
||||||
|
if tx:
|
||||||
|
sock.sendall(tx)
|
||||||
|
rx = recv_all(sock)
|
||||||
|
if rx:
|
||||||
|
dec = decrypt_data(rx)
|
||||||
|
reply = json.loads(dec) if dec else {"_raw": rx.hex()}
|
||||||
|
except (ConnectionError, socket.error, TimeoutError) as exc:
|
||||||
|
err = "%s: %s" % (type(exc).__name__, exc)
|
||||||
|
print("--- %s -> %s:%d" % (label, host, port))
|
||||||
|
print(" tx (%d B): %s" % (len(tx), tx.hex() or "(none)"))
|
||||||
|
if rx_pre:
|
||||||
|
print(" rx_pre (%d B): %s" % (len(rx_pre), rx_pre.hex()))
|
||||||
|
print(" rx (%d B): %s" % (len(rx), rx.hex() or "(empty)"))
|
||||||
|
if reply is not None:
|
||||||
|
print(" reply: " + json.dumps(reply, default=str)[:2000])
|
||||||
|
if err:
|
||||||
|
print(" error: %s" % err)
|
||||||
|
return rx
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
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()
|
||||||
|
key = secret_key(args.sn)
|
||||||
|
print("secret key for %s: %s (radix 32)" % (args.sn, key))
|
||||||
|
variants = [
|
||||||
|
("queryInfo/1101+SN", QUERY_INFO, None, None),
|
||||||
|
("queryInfo/1101+empty-SN", QUERY_INFO, None, ""),
|
||||||
|
("apower/1307+update2", APOWER, {"update": 2}, None),
|
||||||
|
("apower/1307+none", APOWER, None, None),
|
||||||
|
("bms/1705", BMS, None, None),
|
||||||
|
("ems/1707", EMS, None, None),
|
||||||
|
]
|
||||||
|
for port in [int(p) for p in args.ports.split(",") if p.strip()]:
|
||||||
|
for label, cmd, data, equip in variants:
|
||||||
|
tx = encrypt(build_envelope(
|
||||||
|
args.sn, cmd, data,
|
||||||
|
equip=args.sn if equip is None else equip or "00000000",
|
||||||
|
), key)
|
||||||
|
if run(label, args.host, port, tx, args.timeout,
|
||||||
|
pre_read=4.0 if label.startswith("queryInfo/1101+SN")
|
||||||
|
else 0.0):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+22
-2
@@ -13,6 +13,7 @@ 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,
|
||||||
@@ -38,6 +39,25 @@ 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")
|
||||||
@@ -126,7 +146,7 @@ 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())
|
||||||
@@ -141,7 +161,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user