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:
wes
2026-06-12 11:20:12 -04:00
co-authored by Claude Opus 4.8
parent 840cfaf5fc
commit 742ef49c8a
14 changed files with 470 additions and 245 deletions
+18 -14
View File
@@ -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):