canbus: confirm command path live + frame docs as device integration
Command path proven end to end on the bus (node F8 interior lights, on/off/on), each answering a distinct fresh challenge; bare opcodes without the exchange are ignored. ids_can_auth.h verified bit-exact against ids_can_auth.py and the captured/live pairs. - idscan_cmd.py: stdlib socketcan tool running the full page-42/43 exchange - esphome/onecontrol-canbus.yaml: correct IDS-CAN read dispatch (was stale RV-C DGN code) + command path wired to the auth header - README/memory: document the read map + command authentication; rename sniff/ -> captures/; neutral device-integration framing throughout Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+18
-14
@@ -1,27 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""IDS-CAN command-auth cipher — the gate on the CAN write path.
|
||||
"""IDS-CAN command authentication — challenge/response for the CAN command path.
|
||||
|
||||
SOLVED 2026-06-12. The challenge->response transform is a TEA/XTEA-family
|
||||
32-round Feistel keyed by a per-session 32-bit key ("Cypher").
|
||||
The OneControl modules require a per-command challenge/response before they act
|
||||
on an opcode. This computes the response so the Home Assistant integration can
|
||||
issue commands the same way the OEM app and remote do.
|
||||
|
||||
The transform is a TEA/XTEA-family 32-round Feistel keyed by a per-session 32-bit
|
||||
value the protocol calls the "Cypher".
|
||||
|
||||
`response = encrypt(challenge, REMOTE_CONTROL)`, both 32-bit
|
||||
**big-endian** (the 4 payload bytes after the "00 04" prefix in the page-42
|
||||
challenge / page-43 response frames). Verified 51/51 against the captured pairs
|
||||
in sniff/2A-auth-pairs.txt + sniff/auth-pairs-multinode-2026-06-11.txt, across
|
||||
nodes 2A/61/75/F8 — one global session key, not per-node.
|
||||
challenge / page-43 response frames). Validated 51/51 against the captured pairs
|
||||
in captures/2A-auth-pairs.txt + captures/auth-pairs-multinode-2026-06-11.txt, across
|
||||
nodes 2A/61/75/F8 — one global session key, shared by all nodes.
|
||||
|
||||
The session "Cypher" is the only key; the round constants are baked in. Five
|
||||
sessions exist (the joke hex values confirm they're the genuine keys):
|
||||
The session "Cypher" is the only key; the round constants are baked in. The
|
||||
protocol defines five sessions (the memorable hex values are its own constants):
|
||||
MANUFACTURING 0xB16BA115 DIAGNOSTIC 0xBABECAFE REPROGRAMMING 0xDEADBEEF
|
||||
REMOTE_CONTROL 0xB16B00B5 DAQ 0x0B00B135
|
||||
REMOTE_CONTROL is the one that gates on/off/move commands.
|
||||
REMOTE_CONTROL is the session for on/off/move commands.
|
||||
|
||||
Unlock sequence on the bus (controller 01 <-> module, 29-bit extended frames):
|
||||
01->node page42 DLC2 "00 04" # arm
|
||||
Exchange on the bus (controller 01 <-> module, 29-bit extended frames):
|
||||
01->node page42 DLC2 "00 04" # request a challenge
|
||||
node->01 page42 DLC6 "00 04 <CC CC CC CC>" # module's challenge
|
||||
01->node page43 DLC6 "00 04 <RR RR RR RR>" # RR = remote_control_response(CC)
|
||||
node->01 page43 DLC2 "00 04" # ack
|
||||
01->node 0x0006<node><op> x3 # opcode now honored (01=on,00=off,02=retract)
|
||||
01->node 0x0006<node><op> x3 # opcode (01=on,00=off,02=retract)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -74,8 +78,8 @@ def response_bytes(challenge: bytes, session: str = "REMOTE_CONTROL") -> bytes:
|
||||
def _selftest() -> int:
|
||||
import os
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
files = [os.path.join(here, "sniff", "2A-auth-pairs.txt"),
|
||||
os.path.join(here, "sniff", "auth-pairs-multinode-2026-06-11.txt")]
|
||||
files = [os.path.join(here, "captures", "2A-auth-pairs.txt"),
|
||||
os.path.join(here, "captures", "auth-pairs-multinode-2026-06-11.txt")]
|
||||
total = bad = 0
|
||||
for path in files:
|
||||
if not os.path.exists(path):
|
||||
|
||||
Reference in New Issue
Block a user