canbus: reverse-engineer OneControl IDS-CAN bus (read fully mapped, write auth-gated)

Tapped the X180T's CAN bus via CANable 2.0 at the monitor panel's terminator
port. The bus is NOT RV-C — it's Lippert's proprietary IDS-CAN (250k, 11-bit
IDs, (page<<8)|node, 1 Hz broadcasts).

Read side fully mapped from live captures:
- device classes (page-2 type byte: 0x0A tank, 0x1E switched load, 0x21 motor)
- node map for this rig (Catalina 263BHSCK): tanks 27/E2/7D/FE, lights 2A/F8,
  heater 95, pump 61, awning 75 (+ direction & live motor current)
- battery voltage on 29-bit extended frames

Write side: commands are DLC-0 ext frames 0006<node><op>, but auth-gated by a
rolling challenge-response (page 42/43). Replay confirmed dead (spoofed cansend
did not actuate). Not the BLE TEA cypher. response=f(challenge) is deterministic
(no session state) so crackable offline later — seeded 42 pairs in
sniff/2A-auth-pairs.txt.

Includes raw captures (sniff/*.log, force-added past *.log ignore), a read-only
esp32_can ESPHome skeleton, and the log-can.sh sniff helper. Full writeup in
canbus/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-06-11 23:20:49 -04:00
co-authored by Claude Fable 5
parent 34155fd7f9
commit b97401fec8
11 changed files with 43218 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# ESPHome build cache + real secrets (keep secrets.yaml.example tracked)
/.esphome/
/secrets.yaml
+175
View File
@@ -0,0 +1,175 @@
# OneControl RV-C CANbus 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.
#
# 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
# (`esphome run onecontrol-canbus.yaml`), OTA thereafter.
substitutions:
name: onecontrol-canbus
friendly_name: OneControl CANbus
# 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
rx_pin: GPIO4
esphome:
name: ${name}
friendly_name: ${friendly_name}
esp32:
board: esp32dev # classic WROOM-32
framework:
type: esp-idf
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "OneControl-CAN Fallback"
password: !secret fallback_ap_password
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.
api:
encryption:
key: !secret api_key
ota:
- platform: esphome
# ---------------------------------------------------------------------------
# CAN bus: ESP32 native TWAI controller + SN65HVD230 transceiver
# ---------------------------------------------------------------------------
canbus:
- platform: esp32_can
id: rvc_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
bit_rate: 250kbps # RV-C is always 250k
can_id: 0 # our own TX id (only matters when we send)
use_extended_id: true # RV-C uses 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
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;
// 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);
// ================= 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;
}
}
// ---- 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);
}
// ---- 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;
}
}
# ---------------------------------------------------------------------------
# Read-back sensors (published by the dispatcher above)
# ---------------------------------------------------------------------------
sensor:
- platform: template
name: "Battery Voltage"
id: battery_voltage
unit_of_measurement: "V"
device_class: voltage
state_class: measurement
accuracy_decimals: 2
- platform: template
name: "Fresh Water Tank"
id: fresh_tank
unit_of_measurement: "%"
accuracy_decimals: 0
- platform: template
name: "Black Tank"
id: black_tank
unit_of_measurement: "%"
accuracy_decimals: 0
- platform: template
name: "Grey Tank 1"
id: grey_tank_1
unit_of_measurement: "%"
accuracy_decimals: 0
- platform: template
name: "Grey Tank 2"
id: grey_tank_2
unit_of_measurement: "%"
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.
# ---------------------------------------------------------------------------
switch:
- platform: template
name: "Interior Lights"
id: interior_lights
optimistic: true # flip to false once read-back is wired
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]
turn_off_action:
- canbus.send:
canbus_id: rvc_bus
use_extended_id: true
can_id: 0x18FEDB80 # TODO
data: [0x00, 0x00, 0x00, 0x03] # TODO
# Duplicate the block above for: exterior_lights, water_pump, gas_water_heater
# once their command instances are known.
+7
View File
@@ -0,0 +1,7 @@
# Copy to secrets.yaml (gitignored) and fill in. Mirrors the gazebo-fan-proxy
# secrets so the same campsite WiFi + an ESPHome API key are used.
wifi_ssid: "OmnissiahsReach"
wifi_password: "REDACTED"
fallback_ap_password: "REDACTED"
# 32-byte base64 ESPHome API key (generate: `openssl rand -base64 32`)
api_key: "REDACTED"