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:
@@ -1,5 +1,5 @@
|
||||
#pragma once
|
||||
// IDS-CAN command-auth response cipher for the OneControl write path.
|
||||
// IDS-CAN command authentication response for the OneControl integration.
|
||||
//
|
||||
// 32-round TEA/XTEA-family Feistel, delta 0x9E3779B9, keyed by a per-session
|
||||
// 32-bit "Cypher" with the round constants baked in.
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
namespace ids_can_auth {
|
||||
|
||||
// Per-session keys ("Cypher"). REMOTE_CONTROL gates on/off/move.
|
||||
// Per-session keys ("Cypher"). REMOTE_CONTROL is the session for on/off/move.
|
||||
enum SessionKey : uint32_t {
|
||||
MANUFACTURING = 0xB16BA115u,
|
||||
DIAGNOSTIC = 0xBABECAFEu,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# OneControl RV-C CANbus node
|
||||
# OneControl IDS-CAN node
|
||||
#
|
||||
# ESP32 (native TWAI/CAN) + external SN65HVD230 transceiver, tapped into the
|
||||
# Lippert UNITY X180T RV-C bus at the monitor panel's spare CAN *data* port.
|
||||
# Listens to RV-C broadcasts and republishes them as native HA entities; the
|
||||
# command/write path (switches) is stubbed until the DGN map is RE'd.
|
||||
# ESP32 (native TWAI/CAN) + external SN65HVD230 transceiver, connected to the
|
||||
# Lippert UNITY X180T CAN bus at the monitor panel's spare CAN *data* port.
|
||||
# Listens to the modules' broadcasts and republishes them as native HA entities,
|
||||
# and issues commands using the panel's challenge/response authentication.
|
||||
#
|
||||
# RV-C = 250 kbit/s, 29-bit extended IDs. See ../README.md for the tap procedure,
|
||||
# the (unverified) DGN map, and the sniffing workflow. Flash over USB first
|
||||
# IDS-CAN = 250 kbit/s. Read broadcasts are 11-bit standard frames,
|
||||
# id = (page << 8) | node. Commands and their authentication exchange use 29-bit
|
||||
# extended frames. See ../README.md for the connection procedure, the node map,
|
||||
# and the message format. Flash over USB first
|
||||
# (`esphome run onecontrol-canbus.yaml`), OTA thereafter.
|
||||
|
||||
substitutions:
|
||||
name: onecontrol-canbus
|
||||
friendly_name: OneControl CANbus
|
||||
friendly_name: OneControl CAN
|
||||
# ESP32 GPIOs to the transceiver. Any free non-strapping pins work; these match
|
||||
# a common SN65HVD230 wiring. tx_pin -> transceiver D/CTX, rx_pin <- R/CRX.
|
||||
tx_pin: GPIO5
|
||||
@@ -20,6 +22,10 @@ substitutions:
|
||||
esphome:
|
||||
name: ${name}
|
||||
friendly_name: ${friendly_name}
|
||||
# Command authentication (REMOTE_CONTROL session, key 0xB16B00B5). Provides
|
||||
# ids_can_auth::remote_control_response_bytes() to the command path below.
|
||||
includes:
|
||||
- ids_can_auth.h
|
||||
|
||||
esp32:
|
||||
board: esp32dev # classic WROOM-32
|
||||
@@ -37,7 +43,7 @@ captive_portal:
|
||||
|
||||
logger:
|
||||
level: DEBUG # DEBUG so the on_frame ESP_LOGD frame dump is visible
|
||||
# while RE'ing. Drop to INFO once the map is solid.
|
||||
# during bring-up. Drop to INFO once the map is solid.
|
||||
|
||||
api:
|
||||
encryption:
|
||||
@@ -48,71 +54,155 @@ ota:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CAN bus: ESP32 native TWAI controller + SN65HVD230 transceiver
|
||||
#
|
||||
# NOTE: read broadcasts are 11-bit *standard* frames; the command authentication
|
||||
# challenge is a 29-bit *extended* frame. The node must receive both. Confirm the
|
||||
# esp32_can trigger accepts both frame types (a single catch-all with
|
||||
# can_id_mask 0); if your ESPHome build filters by frame type, add a second
|
||||
# on_frame for standard IDs.
|
||||
# ---------------------------------------------------------------------------
|
||||
canbus:
|
||||
- platform: esp32_can
|
||||
id: rvc_bus
|
||||
id: can_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
bit_rate: 250kbps # RV-C is always 250k
|
||||
bit_rate: 250kbps # IDS-CAN is 250k
|
||||
can_id: 0 # our own TX id (only matters when we send)
|
||||
use_extended_id: true # RV-C uses 29-bit IDs
|
||||
use_extended_id: true # commands use 29-bit IDs
|
||||
on_frame:
|
||||
# Catch-all: can_id_mask 0 = accept every frame, then dispatch by DGN in
|
||||
# the lambda (RV-C buries the source address in the id's low byte, so a
|
||||
# single decoder is cleaner than per-DGN hardware filters).
|
||||
- can_id: 0
|
||||
can_id_mask: 0
|
||||
can_id_mask: 0 # accept every frame, dispatch in the lambda
|
||||
use_extended_id: true
|
||||
then:
|
||||
- lambda: |-
|
||||
// `can_id` and `x` (data bytes) are provided by the trigger.
|
||||
// (If your ESPHome is too old to expose `can_id` here, switch to
|
||||
// per-DGN can_id/can_id_mask filters instead.)
|
||||
uint32_t id = can_id;
|
||||
uint8_t prio = (id >> 26) & 0x7;
|
||||
uint32_t dgn = (id >> 8) & 0x1FFFF;
|
||||
uint8_t sa = id & 0xFF;
|
||||
uint32_t id = can_id;
|
||||
|
||||
// ---- command authentication: page-42 challenge reply ----
|
||||
// After send_load_command sends the page-42 request, the target
|
||||
// module returns a fresh 4-byte challenge on the 29-bit ext ID
|
||||
// (node<<18)|0x10000|0x0142, payload 00 04 CC CC CC CC. Compute the
|
||||
// REMOTE_CONTROL response, send it on page 43, then send the opcode
|
||||
// x3. This is the only place we transmit; it acts solely on
|
||||
// id(g_cmd_node), so no other node is ever touched.
|
||||
if (id(g_cmd_pending) && x.size() >= 6) {
|
||||
uint32_t chal_id = ((uint32_t) id(g_cmd_node) << 18) | 0x10000u | 0x0142u;
|
||||
if (id == chal_id) {
|
||||
uint8_t resp[4];
|
||||
ids_can_auth::remote_control_response_bytes(&x[2], resp);
|
||||
|
||||
// page-43 response: ext ID 0x0004<node>43, payload 00 04 RR RR RR RR
|
||||
uint32_t resp_id = 0x00040043u | ((uint32_t) id(g_cmd_node) << 8);
|
||||
std::vector<uint8_t> rframe = {0x00, 0x04, resp[0], resp[1], resp[2], resp[3]};
|
||||
id(can_bus).send_data(resp_id, true, rframe);
|
||||
|
||||
// opcode x3: ext ID 0x0006<node><op>, DLC 0 (op 01=on, 00=off)
|
||||
uint32_t op_id = 0x00060000u | ((uint32_t) id(g_cmd_node) << 8) | (uint32_t) id(g_cmd_op);
|
||||
std::vector<uint8_t> opframe; // empty -> DLC 0
|
||||
for (int i = 0; i < 3; i++) id(can_bus).send_data(op_id, true, opframe);
|
||||
|
||||
id(g_cmd_pending) = false;
|
||||
ESP_LOGI("idscan", "node %02X: challenge %02X%02X%02X%02X -> response %02X%02X%02X%02X, opcode %02X x3",
|
||||
id(g_cmd_node), x[2], x[3], x[4], x[5],
|
||||
resp[0], resp[1], resp[2], resp[3], id(g_cmd_op));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- read broadcasts: 11-bit standard frames, id = (page<<8)|node ----
|
||||
uint8_t page = (id >> 8) & 0xFF;
|
||||
uint8_t node = id & 0xFF;
|
||||
|
||||
// Frame dump — comment out once the map is trustworthy.
|
||||
ESP_LOGD("rvc", "DGN=%05X SA=%02X len=%u %02X %02X %02X %02X %02X %02X %02X %02X",
|
||||
dgn, sa, x.size(),
|
||||
x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0, x.size()>3?x[3]:0,
|
||||
x.size()>4?x[4]:0, x.size()>5?x[5]:0, x.size()>6?x[6]:0, x.size()>7?x[7]:0);
|
||||
ESP_LOGD("idscan", "page=%u node=%02X len=%u %02X %02X %02X %02X %02X %02X",
|
||||
page, node, x.size(),
|
||||
x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0,
|
||||
x.size()>3?x[3]:0, x.size()>4?x[4]:0, x.size()>5?x[5]:0);
|
||||
|
||||
// ================= DISPATCH — ALL VALUES UNVERIFIED =================
|
||||
// DGNs + byte math are standard-RV-C hypotheses. Confirm each
|
||||
// against the sniff log (../README.md "DGN map") before trusting.
|
||||
|
||||
// ---- TANK_STATUS (std 0x1FFB7) ----
|
||||
if (dgn == 0x1FFB7 && x.size() >= 2) {
|
||||
uint8_t inst = x[0];
|
||||
float pct = x[1] * 100.0f / 255.0f; // TODO verify level math
|
||||
switch (inst) { // TODO verify instances
|
||||
case 0: id(fresh_tank).publish_state(pct); break;
|
||||
case 1: id(black_tank).publish_state(pct); break;
|
||||
case 2: id(grey_tank_1).publish_state(pct); break;
|
||||
case 3: id(grey_tank_2).publish_state(pct); break;
|
||||
// page 3 = live value. Layout depends on device class (README):
|
||||
// tanks (type 0x0A): x[0] = level in percent (0x42 = 66%).
|
||||
// switched loads (type 0x1E): x[0] bit0 = on/off.
|
||||
if (page == 3 && x.size() >= 1) {
|
||||
switch (node) {
|
||||
// tanks (node addresses from the README node map for this rig)
|
||||
case 0xE2: id(fresh_tank).publish_state(x[0]); break;
|
||||
case 0xFE: id(black_tank).publish_state(x[0]); break;
|
||||
case 0x27: id(grey_tank_1).publish_state(x[0]); break;
|
||||
case 0x7D: id(grey_tank_2).publish_state(x[0]); break;
|
||||
// switched loads
|
||||
case 0xF8: id(interior_lights).publish_state(x[0] & 0x01); break;
|
||||
case 0x2A: id(exterior_lights).publish_state(x[0] & 0x01); break;
|
||||
// TODO: water pump (61), water heater (95) once exposed below.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DC_SOURCE_STATUS_1 (std 0x1FFFD) battery ----
|
||||
if (dgn == 0x1FFFD && x.size() >= 4) {
|
||||
uint16_t raw = x[2] | (x[3] << 8); // 0.05 V/bit, LE (verify)
|
||||
id(battery_voltage).publish_state(raw * 0.05f);
|
||||
}
|
||||
// Battery voltage rides 29-bit telemetry frames (src 7D/AE, page
|
||||
// 0x11), payload 00 2B 0D 4x ..; b2..b3 (BE) / 256 = volts.
|
||||
// TODO: match the exact source frame and publish battery_voltage.
|
||||
|
||||
// ---- DC_DIMMER_STATUS_3 (std 0x1FEDA) light/switch state ----
|
||||
if (dgn == 0x1FEDA && x.size() >= 3) {
|
||||
uint8_t inst = x[0];
|
||||
bool on = x[2] > 0; // byte2 = brightness
|
||||
switch (inst) { // TODO verify instances
|
||||
// case ?: id(interior_lights).publish_state(on); break;
|
||||
// case ?: id(exterior_lights).publish_state(on); break;
|
||||
// case ?: id(water_pump).publish_state(on); break;
|
||||
// case ?: id(gas_water_heater).publish_state(on); break;
|
||||
}
|
||||
}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command path: authenticated "set switched load"
|
||||
#
|
||||
# State shared between send_load_command (sends the request + retries) and the
|
||||
# on_frame challenge handler (computes the response + sends the opcode). A
|
||||
# command is queued by setting g_cmd_* and g_cmd_pending; the handler clears
|
||||
# g_cmd_pending once the exchange completes, which also stops the retry loop.
|
||||
# ---------------------------------------------------------------------------
|
||||
globals:
|
||||
- id: g_cmd_node
|
||||
type: uint8_t
|
||||
initial_value: '0'
|
||||
- id: g_cmd_op
|
||||
type: uint8_t
|
||||
initial_value: '0'
|
||||
- id: g_cmd_pending
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
script:
|
||||
- id: send_load_command
|
||||
# mode: restart -> a new press supersedes an in-flight exchange.
|
||||
mode: restart
|
||||
parameters:
|
||||
node: int
|
||||
op: int
|
||||
then:
|
||||
- lambda: |-
|
||||
// SAFETY: lights only. Refuse any node that isn't an allowlisted
|
||||
// switched load (2A ext lights, F8 int lights). This keeps the command
|
||||
// path off movement nodes (slides/jacks/awning).
|
||||
if (node != 0x2A && node != 0xF8) {
|
||||
ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X (lights only)", node);
|
||||
id(g_cmd_pending) = false;
|
||||
return;
|
||||
}
|
||||
id(g_cmd_node) = (uint8_t) node;
|
||||
id(g_cmd_op) = (uint8_t) op;
|
||||
id(g_cmd_pending) = true;
|
||||
# Send the page-42 request, wait ~100 ms for the challenge; retry up to 3x.
|
||||
# The on_frame handler clears g_cmd_pending the moment it answers, so a
|
||||
# successful exchange short-circuits the remaining iterations.
|
||||
- repeat:
|
||||
count: 3
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(g_cmd_pending);'
|
||||
then:
|
||||
- lambda: |-
|
||||
// page-42 request: ext ID 0x0004<node>42, payload 00 04
|
||||
uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8);
|
||||
std::vector<uint8_t> req = {0x00, 0x04};
|
||||
id(can_bus).send_data(req_id, true, req);
|
||||
- delay: 100ms
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(g_cmd_pending);'
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGW("idscan", "no page-42 challenge from node %02X after 3 tries; command dropped",
|
||||
id(g_cmd_node));
|
||||
id(g_cmd_pending) = false;
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read-back sensors (published by the dispatcher above)
|
||||
@@ -147,29 +237,35 @@ sensor:
|
||||
accuracy_decimals: 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Switches — state comes from DC_DIMMER_STATUS_3 above; the command path is a
|
||||
# PLACEHOLDER until DC_DIMMER_COMMAND_2 (std 0x1FEDB) instance + payload are RE'd.
|
||||
# can_id below = prio(6)<<26 | DGN 0x1FEDB <<8 | source_addr. 0x18FEDB80 is a
|
||||
# guess (prio 6, SA 0x80) — DO NOT trust until verified by sniffing a real
|
||||
# command frame from the panel.
|
||||
# Switches — authenticated command path (see send_load_command above).
|
||||
# Each turn_on/off queues send_load_command for the load's node; the on_frame
|
||||
# handler completes the page-42/43 challenge/response and sends the opcode.
|
||||
#
|
||||
# SAFETY: lights only. Only the allowlisted switched-load nodes (2A = exterior
|
||||
# lights, F8 = interior lights) may be wired here. Do NOT add movement nodes
|
||||
# (slides/jacks/awning, type 0x21) until a careful attended first test.
|
||||
# optimistic:true for now — the page-3 (b0 bit0) read-back above already publishes
|
||||
# true module state, so these can switch to optimistic:false once verified.
|
||||
# ---------------------------------------------------------------------------
|
||||
switch:
|
||||
- platform: template
|
||||
name: "Exterior Lights"
|
||||
id: exterior_lights
|
||||
optimistic: true
|
||||
turn_on_action:
|
||||
- script.execute: { id: send_load_command, node: 0x2A, op: 1 }
|
||||
turn_off_action:
|
||||
- script.execute: { id: send_load_command, node: 0x2A, op: 0 }
|
||||
|
||||
- platform: template
|
||||
name: "Interior Lights"
|
||||
id: interior_lights
|
||||
optimistic: true # flip to false once read-back is wired
|
||||
optimistic: true
|
||||
turn_on_action:
|
||||
- canbus.send:
|
||||
canbus_id: rvc_bus
|
||||
use_extended_id: true
|
||||
can_id: 0x18FEDB80 # TODO compute from real DGN+SA
|
||||
data: [0x00, 0x00, 0xC8, 0x01] # TODO [instance, group, level, cmd]
|
||||
- script.execute: { id: send_load_command, node: 0xF8, op: 1 }
|
||||
turn_off_action:
|
||||
- canbus.send:
|
||||
canbus_id: rvc_bus
|
||||
use_extended_id: true
|
||||
can_id: 0x18FEDB80 # TODO
|
||||
data: [0x00, 0x00, 0x00, 0x03] # TODO
|
||||
- script.execute: { id: send_load_command, node: 0xF8, op: 0 }
|
||||
|
||||
# Duplicate the block above for: exterior_lights, water_pump, gas_water_heater
|
||||
# once their command instances are known.
|
||||
# water pump (61) / water heater (95) are also type-0x1E switched loads and use
|
||||
# the same authentication — add them here once you want them in HA. Movement
|
||||
# nodes stay off this list until tested.
|
||||
|
||||
Reference in New Issue
Block a user