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:
@@ -0,0 +1,3 @@
|
||||
# ESPHome build cache + real secrets (keep secrets.yaml.example tracked)
|
||||
/.esphome/
|
||||
/secrets.yaml
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user