Compare commits
22
Commits
34155fd7f9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c6721953a | ||
|
|
a56f550636 | ||
|
|
6733a79390 | ||
|
|
5b9198878c | ||
|
|
38c4ba485d | ||
|
|
2dd884af3c | ||
|
|
257064289c | ||
|
|
4bf74a6194 | ||
|
|
63034e17ce | ||
|
|
9d6999a5bc | ||
|
|
e179f1f1fd | ||
|
|
9a5ee30db9 | ||
|
|
e8b2447d62 | ||
|
|
81bd58206d | ||
|
|
cda537da29 | ||
|
|
f05203b9e3 | ||
|
|
d57c2b21d2 | ||
|
|
f3073f180f | ||
|
|
742ef49c8a | ||
|
|
840cfaf5fc | ||
|
|
85455e8631 | ||
|
|
b97401fec8 |
@@ -0,0 +1,212 @@
|
||||
# Handoff — finish & flash the OneControl IDS-CAN node
|
||||
|
||||
**Goal:** complete and flash an ESP32 firmware that puts our RV's Lippert
|
||||
OneControl system (tanks, lights, switches) into Home Assistant as native
|
||||
entities, talking directly to the OneControl CAN network. This replaces the
|
||||
slower Bluetooth integration in `../src/` + `../custom_components/`.
|
||||
|
||||
Read `README.md` first — it has the full message-format documentation, the node
|
||||
map for this coach, and the wiring procedure. This file is just the current
|
||||
state + what's left.
|
||||
|
||||
## FLASHED & READ-VERIFIED (2026-06-12, afternoon)
|
||||
|
||||
The node is built, flashed over USB, on the bus, and on WiFi
|
||||
(`192.168.69.18`, hostname `onecontrol-canbus`, adoptable via the API key in
|
||||
`secrets.yaml`). **All 11 read entities verified live over the native API**:
|
||||
battery 13.27 V, tanks fresh 0 / black 33 / grey1 33 / grey2 100 %, interior
|
||||
lights on, exterior/heater off, both fault flags clear, awning idle.
|
||||
|
||||
What changed this session:
|
||||
- `secrets.yaml` filled (WiFi + fallback AP from the shared gazebo proxy creds).
|
||||
- **Battery voltage decode finished** (remaining-work item 4): matches the
|
||||
page-`0x11` `00 2B …` telemetry frame, `b2..b3` BE / 256 — reads 13.27 V.
|
||||
- **Toolchain fixes** (ESPHome moved since the last build; it no longer compiled):
|
||||
the `g_node_type` array global's bare `{}` initializer was ambiguous (named the
|
||||
type), and the awning cover enum is `COVER_OPERATION_OPENING/CLOSING` (no `IS_`).
|
||||
- **Dual `on_frame` trigger** — this build filters `on_frame` by frame type, so a
|
||||
single `use_extended_id: true` trigger only saw the 29-bit frames; the 11-bit
|
||||
read broadcasts (tanks/lights/heater/awning) were silently dropped. Added a
|
||||
second `use_extended_id: false` trigger sharing the same lambda via a YAML
|
||||
anchor. (This was the exact risk the bus-config note called out.)
|
||||
- **Logger** — raised the `canbus` component to INFO and silenced the per-frame
|
||||
dump; at DEBUG it logged ~50 frames/s and saturated the 115200 serial link.
|
||||
|
||||
Still TODO (needs the operator in the loop — see "Remaining work" items 5–6):
|
||||
command-path verification (toggle a light) and the attended awning test.
|
||||
|
||||
## Current status (2026-06-12)
|
||||
|
||||
- **Message format documented and confirmed against live captures.** The bus is
|
||||
Lippert **IDS-CAN** (250 kbit/s, 11-bit IDs), not RV-C. Every tank, light,
|
||||
pump, heater, the awning, and battery voltage is decoded — see the node map in
|
||||
`README.md`.
|
||||
- **Read path:** modules broadcast their state continuously, no authentication.
|
||||
- **Command path:** each command is preceded by a short challenge/response
|
||||
authentication exchange (the same one the OEM app uses). Implemented in
|
||||
`ids_can_auth.py` (reference) and `esphome/ids_can_auth.h` (the firmware copy,
|
||||
verified **bit-exact** against the Python and against captured/live values).
|
||||
- **Proven working end to end.** `idscan_cmd.py` ran the full exchange over a USB
|
||||
CAN adapter and operated the interior lights (node `F8`) on/off/on, each
|
||||
answering a distinct fresh challenge, with the module's own status broadcast
|
||||
confirming the result. A bare command with no exchange is ignored.
|
||||
- **Firmware compiled successfully** last night (`esphome/.esphome/build/…`).
|
||||
|
||||
## Hardware (assembly-ready)
|
||||
|
||||
- ESP32 WROOM devboard (`esp32dev`) + Waveshare **SN65HVD230** transceiver
|
||||
(3.3 V logic, onboard 120 Ω terminator → this node is the bus-END node).
|
||||
- **Power:** buck converter dialed to **5 V**, tapped from the panel's 12 V
|
||||
supply (common ground with the bus — good). Feed the ESP32 5 V pin.
|
||||
- **Connection:** Molex Mini-Fit Jr. pigtail into the panel's CAN **data** port
|
||||
(the one with the terminator), per `README.md` → "Physical connection".
|
||||
⚠️ Meter the port first: data idles ~2.5 V, power reads ~12 V; 12 V on CAN-H/L
|
||||
destroys the transceiver.
|
||||
- **Pigtail wiring (as crimped):** green = CAN-L, blue = CAN-H.
|
||||
- **GPIO:** transceiver `CTX/D` ← ESP32 `GPIO5` (tx_pin), `CRX/R` → `GPIO4`
|
||||
(rx_pin). Adjust `substitutions:` in the YAML if you wire differently.
|
||||
|
||||
## Remaining work (in order)
|
||||
|
||||
1. ~~**`secrets.yaml`**~~ — DONE. WiFi + fallback-AP creds filled from the shared
|
||||
gazebo proxy; the `api_key` was already set.
|
||||
2. ~~**First flash over USB**~~ — DONE (`/dev/ttyUSB0`). OTA from here on.
|
||||
Note: this laptop's user isn't in the `uucp` group, so the serial node needed
|
||||
`chmod 666 /dev/ttyUSB0` (resets on replug) — irrelevant for OTA.
|
||||
3. ~~**Confirm the read entities populate**~~ — DONE. All 11 verified live over the
|
||||
native API (see status block above). NB: ESPHome 2026.5 doesn't echo
|
||||
`publish_state` at DEBUG over serial, so a quiet serial log is normal — read the
|
||||
states over the API, not the console.
|
||||
4. **Open read items in the YAML lambda:**
|
||||
- ~~**Battery voltage**~~ — DONE; reads 13.27 V.
|
||||
- Optionally add **water pump (`61`)** and **water heater (`95`)** — both are
|
||||
ordinary switched loads, same decode + command path as the lights. (Heater is
|
||||
already exposed; pump stays command-blocked.)
|
||||
5. ~~**Verify the command path**~~ — DONE (Interior + Exterior lights, on & off,
|
||||
each a confirmed challenge→response→opcode exchange on the serial log, with the
|
||||
read-back flipping to match). Switches are now `optimistic:false` so HA shows
|
||||
ground truth. **Confirmed module behaviour:** after a successful session the
|
||||
module enforces a **~2 s cooldown** — a second command to the same load sooner
|
||||
gets no challenge and is dropped (opcode-independent, module-side, not a bug).
|
||||
The arm retry was widened to 8×150 ms to absorb the odd dropped frame on the
|
||||
busy bus. Heater is wired the same way but wasn't actuated (avoid cycling gas
|
||||
ignition casually); it should behave identically.
|
||||
6. **Attended awning test (motor — watch it move).** The Awning `cover` is wired
|
||||
(open/close/stop). On the first run, confirm: (a) which direction open/close
|
||||
actually go, (b) whether one command runs to the travel limit or only moves
|
||||
while commands stream. If it under-travels, change `send_load_command` to
|
||||
stream the opcode (repeat until Stop) — only after watching it. The single-shot
|
||||
default can't run the motor away.
|
||||
7. ~~**Surface at the campsite HA**~~ — DONE (2026-06-12). Added to the campsite
|
||||
HAOS Pi (`192.168.69.10`) as ESPHome config entry "OneControl CAN"; all 11
|
||||
entities (`*.onecontrol_can_*`) live. The old BLE integration is **fully torn
|
||||
out**: config entry deleted, `custom_components/lippert_onecontrol` removed
|
||||
from the Pi, 11 stale `campsite_onecontrol_*` MQTT registry orphans purged
|
||||
(home broker had no retained discovery topics — they were registry-only). The
|
||||
camper dashboard (`lovelace.dashboard_camper`) was rewritten to the new
|
||||
entities; the water-pump tile was dropped (pump is panel-only by design) and
|
||||
awning + both fault sensors added.
|
||||
8. ~~**Bridge home via MQTT**~~ — DONE (2026-06-12 evening). The Pi package
|
||||
`/config/packages/mqtt_bridge.yaml` was rewritten against the CAN entities
|
||||
(repo copy: `canbus/ha/mqtt_bridge_onecontrol.yaml`); it publishes MQTT
|
||||
Discovery + state to cyrion's Mosquitto (192.168.88.69) and relays commands
|
||||
back, same pattern as the gazebo bridge. Same `unique_id`s kept for surviving
|
||||
loads, so home HA entity ids + history carried over (`*.campsite_onecontrol_*`);
|
||||
water pump / cover_2 / cover_3 retained topics cleared (entities auto-removed
|
||||
on home); awning + both fault sensors added, plus an availability topic
|
||||
(`campsite/onecontrol/availability`) the old bridge lacked. Home Overview
|
||||
"Camper" view updated to match. Command round-trip verified from home HA.
|
||||
|
||||
The DSI fault is already decoded and wired in (see below) — no capture needed.
|
||||
|
||||
## File map
|
||||
|
||||
| File | What it is |
|
||||
|------|-----------|
|
||||
| `esphome/onecontrol-canbus.yaml` | the ESP32 firmware (read dispatch + command path) — the thing to finish & flash |
|
||||
| `esphome/ids_can_auth.h` | command-authentication response, used by the YAML lambda |
|
||||
| `esphome/secrets.yaml.example` | template for the git-ignored secrets |
|
||||
| `ids_can_auth.py` | Python reference for the same authentication + 51/51 self-test |
|
||||
| `idscan_cmd.py` | desktop tool that proved the command path over a USB CAN adapter |
|
||||
| `captures/` | raw bus logs + the challenge/response pairs + `analyze_auth.py` |
|
||||
| `captures/log-can.sh` | bring up the USB CAN adapter and log frames |
|
||||
| `README.md` | full message-format documentation + node map + wiring |
|
||||
|
||||
## Safety notes
|
||||
|
||||
- **SLIDES, JACKS, and the WATER PUMP ARE PANEL/APP ONLY — never over CAN. This
|
||||
is a hard rule, do not weaken it.** `esphome/command_guard.h`
|
||||
(`command_blocked()`) is the single source of truth: it refuses the slide/jack
|
||||
nodes (6A/7F/9C, effective even before a node's identity is heard), the water
|
||||
pump (61, winterizing-only), and — generally — any motor-class (0x21) node other
|
||||
than the awning. It's enforced in two independent places (the command-entry
|
||||
script and the actual transmit point), so loosening one does not open the other.
|
||||
Wiring a switch for a blocked node cannot actuate it; the gate drops the command
|
||||
before any frame goes out.
|
||||
- **Exposed (controllable) loads:** exterior lights (2A), interior lights (F8),
|
||||
water heater (95) as `switch`es, and the awning (75) as a `cover`. To expose
|
||||
another *permitted* switched load, add its node to the layer-2 allowlist in
|
||||
`send_load_command` and add a `switch` entity; never add a slide/jack/pump.
|
||||
- **The awning is a motor — its first actuation must be attended** (see
|
||||
remaining-work item 6). The wired commands are single-shot, which can't run the
|
||||
motor away, but direction and latch-vs-stream behavior need a live check.
|
||||
- The physical connection is fully reversible: unplug, re-seat the terminator.
|
||||
- One transceiver = one bus-end terminator. Never add a terminated node in the
|
||||
middle of the bus (would make three terminators).
|
||||
|
||||
## Presence & departure failsafes (2026-06-12)
|
||||
|
||||
Occupancy truth = the **water heater switch** (always on while camping, off on
|
||||
departure). Architecture: GPS persons/zones live on home HA; the Pi computes
|
||||
occupancy + (pending) WiFi presence and bridges them home; failsafes run on
|
||||
home HA against the bridged controls; all alerts go through the Pi's Octavia
|
||||
webhook via the MQTT relay topic `campsite/octavia/say`.
|
||||
|
||||
- `canbus/ha/campsite_presence.yaml` (Pi package): `binary_sensor.rv_occupied`
|
||||
(heater on), `binary_sensor.phones_on_campsite_wifi` (unavailable until the
|
||||
MikroTik step below), MQTT bridging for both, a local notify-only dead-man
|
||||
(heater on + no phones on WiFi 3 h → Octavia), and **disabled pre-staged lot-
|
||||
light automations** for the future shed Shelly (`switch.lot_lights`
|
||||
placeholder — fix the entity id, remove `initial_state: false`, and reload
|
||||
when the relay is installed; on-at-sunset is occupancy-gated, off-at-sunrise
|
||||
unconditional).
|
||||
- Home HA (UI-managed, via API): `automation.campsite_departure_failsafe_quick`
|
||||
(both out of zone 25 min + RV occupied → gazebo fans/lights + interior lights
|
||||
off, Octavia + push, only pings if something was actually on) and
|
||||
`automation.campsite_water_heater_failsafe_2h` (2 h + heater on → heater off
|
||||
with 60 s bridge round-trip confirmation; distinct failure message if
|
||||
unconfirmed). Both fired and verified live 2026-06-12 (fans physically cycled,
|
||||
states restored after).
|
||||
- `zone.our_campsite` radius enlarged 50→150 m (GPS wobble safety).
|
||||
- **MikroTik WiFi presence — router + integration DONE (2026-06-12):** Chateau
|
||||
("manifold-002", 192.168.69.1) API service locked to `192.168.69.10/32`,
|
||||
read-only user `homeassistant` (random password; it lives only in the Pi's
|
||||
mikrotik config entry — to rotate, reset via Chateau admin and re-add the
|
||||
integration). Runs in **force_dhcp + arp_ping** mode (detection 120 s) — the
|
||||
registration-table default missed clients on the rvlink roof AP (same SSID,
|
||||
bridged); DHCP+ARP covers both APs. New clients register as
|
||||
disabled-by-default `device_tracker`s keyed by MAC.
|
||||
**Wes's phone WIRED AND VERIFIED:** `device_tracker.wes_phone_wifi`
|
||||
(MAC `22:1E:E1:E0:1E:3B`, the ".155 iPhone" lease — identified by join-time
|
||||
correlation) → `phones_on_campsite_wifi` reads `on`, bridged home `on`.
|
||||
**Remaining:** when Lindsey's iPhone next associates, a new disabled
|
||||
tracker appears — enable + rename to `device_tracker.lindsey_iphone_wifi`
|
||||
(the sensor template already references that id). Ignore the other
|
||||
auto-registered disabled trackers (TVs/cameras/laptop).
|
||||
- **Phone GPS checklist (manual, Wes's phone):** companion app background
|
||||
location unrestricted, high-accuracy on, battery optimization off — his
|
||||
tracker was >1 h stale at survey time; Lindsey's iPhone reports fine.
|
||||
|
||||
## DSI fault — DECODED (2026-06-12)
|
||||
|
||||
Forced a real lockout and captured it (`captures/dsi-fault-*.log`). Already wired
|
||||
into the YAML as two `binary_sensor`s; nothing left to do here.
|
||||
|
||||
- **Water Heater DSI Fault** = node `95` page-3 `b0` bit5 (`0x20`). Healthy heater
|
||||
= `0x80` off / `0x81` running; lockout = `0xA0`.
|
||||
- **Discord alert wired (2026-06-12):** `canbus/ha/dsi_fault_alert.yaml` (on the
|
||||
Pi as `packages/dsi_fault_alert.yaml`) pings the server-alerts channel via
|
||||
webhook (`!secret discord_server_alerts_webhook`, same one Gatus uses) when
|
||||
the fault latches for 10 s, plus a cleared message. Test-fired OK.
|
||||
- **OneControl System Fault** = page-0 `b0` bit0 (`0x01`), a bus-wide
|
||||
"fault exists somewhere" flag (read from node `95`'s page 0 in the lambda).
|
||||
@@ -0,0 +1,341 @@
|
||||
# OneControl CAN integration (Lippert IDS-CAN)
|
||||
|
||||
A local Home Assistant integration for my own RV's Lippert OneControl
|
||||
(UNITY **X180T**) system, talking to it directly over its **CAN network**
|
||||
instead of through the Bluetooth gateway. The Bluetooth path in this repo's
|
||||
`src/` + `custom_components/` works but is laggy and brittle (connection-based
|
||||
GATT, ~30 s idle timeout, a per-reconnect handshake, a single shared Pi radio,
|
||||
fragile pairing). The OneControl panel is just a gateway sitting on a CAN
|
||||
backbone, so connecting to the bus directly gives instant latency, no
|
||||
connection/timeout churn, and visibility into every signal the modules
|
||||
broadcast — including ones the Bluetooth API never surfaced, like the
|
||||
water-heater DSI fault.
|
||||
|
||||
This file documents the on-wire message format so the ESP32 node can present the
|
||||
coach's tanks, lights, switches, and awning as native HA entities. Everything
|
||||
below comes from live bus captures of my own coach in `captures/*.log`.
|
||||
|
||||
**Status:** Despite Lippert's "RV-C" branding, the bus is **not** RV-C. It runs
|
||||
Lippert's own **IDS-CAN**: 250 kbit/s, **11-bit standard IDs** (plus a handful of
|
||||
29-bit frames for telemetry and directed messages). The **read path is fully
|
||||
mapped**, and the **command path is implemented and confirmed by live actuation
|
||||
(2026-06-12)** — see below.
|
||||
|
||||
---
|
||||
|
||||
## Protocol notes (captures: `captures/baseline-*.log`, `captures/toggletest-*.log`)
|
||||
|
||||
### Frame structure
|
||||
|
||||
11-bit ID = **`(page << 8) | node_addr`**. Every node broadcasts its pages at
|
||||
**1 Hz** (plus an immediate rebroadcast on change). Pages observed:
|
||||
|
||||
| Page | Content |
|
||||
|------|---------|
|
||||
| 0 | Node status: `b0` flags (bit2 = "state changing" transient), b1.. static (`14 00 00 00 1C 38 DF` common) |
|
||||
| 1 | All-zero (4 bytes) for ordinary nodes |
|
||||
| 2 | Identity: `00 A3 FE <type> 00 <b5> <b6> <b7>` — **`<type>` = device class** |
|
||||
| 3 | **The live value** — layout depends on device class (see below) |
|
||||
| 6 | Single byte, only on special nodes `01`/`FC`/`FE` |
|
||||
| 7 | Only `7FE`: byte3 = 1 Hz incrementing counter (uptime/heartbeat) |
|
||||
|
||||
### Device classes (page-2 `type` byte)
|
||||
|
||||
- **`0x0A` = tank.** Page 3 = **1 byte, level in percent** (0x42 = 66%, 0x21 = 33%).
|
||||
- **`0x1E` = switched load** (lights/pump/heater). Page 3 = 6 bytes:
|
||||
`b0` bit0 = **ON/OFF**, `b2..b3` (BE) = live **current/level reading** that
|
||||
soft-ramps on switch-on and decays on switch-off (interior lights ramped
|
||||
0x0001→0x028A over ~1 s).
|
||||
- **`0x21` = H-bridge/movement** (slide/awning/jacks). Page 3 = 6 bytes:
|
||||
`b0` = `0xC0` idle, **`0xC2` = extending (out), `0xC3` = retracting (in)**
|
||||
(confirmed twice: wall-jog order + app commands 2026-06-11); `b2..b3` (BE) =
|
||||
**live motor current** (~0x500–0x620 while running, settles to 0 at stop).
|
||||
- **`0x27`, `0x2B`** = unknown (nodes `AE`, `FC`).
|
||||
|
||||
### Node map (this rig — Catalina 263BHSCK, panel 28475)
|
||||
|
||||
| Node | Device | Evidence |
|
||||
|------|--------|----------|
|
||||
| `01` | controller (X180T?) | special pages; `301` status bit flickers at idle |
|
||||
| `27` | **grey tank 1** | type 0x0A, page3 = 0x21 = 33% ✓ |
|
||||
| `7D` | **grey tank 2** | type 0x0A; stayed 66% when black was drained |
|
||||
| `FE` | **black tank** | type 0x0A; 66%→33% on drain (2026-06-11) ✓; also owns the 7FE counter |
|
||||
| `E2` | **fresh tank** | type 0x0A, page3 = 0x00 = 0% ✓ |
|
||||
| `2A` | **exterior lights** | type 0x1E; toggle test t≈69–76 s |
|
||||
| `F8` | **interior lights** | type 0x1E; toggle test t≈51–61 s; **operated live 2026-06-12** ✓ |
|
||||
| `95` | **water heater** | type 0x1E; toggle test t≈85–94 s |
|
||||
| `61` | **water pump** | type 0x1E; toggle test 2026-06-11 (on 13.5s / off 23.8s) ✓ |
|
||||
| `89` | **furnace** (read-only) | type 0x1E; thermostat-controlled, not a Lippert load — present only to report DSI state. Same page-3 encoding as the heater: b0 bit0 = running, bit5 = DSI lockout. Stayed 0x80 (off) all bench captures; running/fault bytes inferred from node 95, confirm on first burn |
|
||||
| `75` | **awning** | type 0x21; jog test 2026-06-11 — b0 C0→C3 (in?) →C0→C2 (out?) with motor current on b2-3 |
|
||||
| `6A`, `7F`, `9C` | slide / jacks / movement class | type 0x21, untested |
|
||||
| `AE` | unknown (type 0x27, page3=0x00) | LP gas sensor? |
|
||||
| `FC` | special node (type 0x2B, page 6) | panel/BLE gateway? |
|
||||
|
||||
### 29-bit extended frames (directed messages)
|
||||
|
||||
Extended ID = **`(src_node << 18) | (dir << 16) | (dest_node << 8) | page`**,
|
||||
where `dir` = 0 for a `01`→node message and 1 for a node→`01` message
|
||||
(verified: pump event `0185FC42` = src `61` → dest `FC`; awning `01D5FC42` =
|
||||
src `75` → dest `FC`; replies `03F0<node>43` = src `FC` → dest node, page 43).
|
||||
|
||||
- `01F5FC11` (src `7D` → `FC`) / `02B90111` (src `AE` → `01`) — periodic,
|
||||
payload `00 2B 0D 4x <rolling>`: `b2..b3` ≈ 0x0D46–47 → /256 = **13.27 V ⇒
|
||||
battery voltage**, last byte looks like a checksum. (Bluetooth read 13.09 V the
|
||||
same day; charger float plausible.) The *source* being `7D`/`AE` suggests those
|
||||
modules carry the battery-sense wire, not the controller.
|
||||
- On every state change: a burst of `xxxxFC02` IDs (every node → dest `FC`)
|
||||
flip a `55`↔`AA` marker (a state-change announce/sync broadcast), plus a
|
||||
per-event handshake pair (src-node→`FC` page 42 / `FC`→node page 43) — not
|
||||
needed for sensing.
|
||||
|
||||
### Command messages (captures: `captures/app-commands-*.log`)
|
||||
|
||||
A command is a **zero-payload (DLC 0) 29-bit frame** `0x0006<node><op>`
|
||||
(`op`: `01`=on, `00`=off/stop, `02`=movement-retract). The app's button presses
|
||||
appear on the bus as exactly these, ~300 ms before the page-3 state updates.
|
||||
|
||||
Each command is preceded by a short **challenge-response authentication
|
||||
exchange** — the module won't act on a bare opcode:
|
||||
|
||||
```
|
||||
01 → node page42 "00 04" # controller requests a challenge
|
||||
node → 01 page42 "00 04 <CC CC CC CC>" # module returns a 4-byte challenge
|
||||
01 → node page43 "00 04 <RR RR RR RR>" # controller returns the matching response
|
||||
node → 01 page43 "00 04" # module acknowledges
|
||||
01 → node 0x0006<node><op> ×3 # command (now acted on)
|
||||
01 → node page45 / node → 01 page45 # post-status (00, then 0E)
|
||||
```
|
||||
|
||||
The challenge is **fresh on every press** (interior lights returned `F7 74 0A 20`
|
||||
then `ED C9 28 1A` on two consecutive presses → different responses), so a
|
||||
previously captured exchange can't be re-used. Confirmed: re-sending a captured
|
||||
opcode on its own — `cansend can0 00062A00#` ×3 with no live exchange — reaches
|
||||
the bus (TX echoed back) but the module ignores it. The integration therefore
|
||||
performs the same handshake the OEM app does.
|
||||
|
||||
The authentication uses a **different key** from the Bluetooth side
|
||||
(`tea(612643285, 0x21CA0C06) = 0x87AC5CBD ≠` the observed `0xCC18366B`) but the
|
||||
**same algorithm family** — a 32-round TEA/XTEA transform. Lippert applies a
|
||||
second, independently-keyed authentication on the CAN command path.
|
||||
|
||||
**Reference dataset:** `captures/2A-auth-pairs.txt` (42 challenge/response pairs,
|
||||
node `2A`) + `captures/auth-pairs-multinode-2026-06-11.txt` (9 more across nodes
|
||||
`61`/`75`/`F8`, +2 on `2A`) — **51 pairs across 4 nodes**, captured 2026-06-11
|
||||
from app-driven commands. `captures/analyze_auth.py` characterizes
|
||||
`response = f(challenge)`: a keyed nonlinear transform (not GF(2)-affine — the 51
|
||||
input-differences span the full 32-dim space yet contradict a linear fit; not
|
||||
affine over Z/2³²; full byte diffusion; balanced bits), consistent with the
|
||||
TEA/XTEA family.
|
||||
|
||||
### Authentication implementation — `ids_can_auth.py` (2026-06-12)
|
||||
|
||||
`response = Encrypt(challenge, session_key)`, both 32-bit **big-endian** (the 4
|
||||
payload bytes after the `00 04` prefix). The transform is a **32-round TEA/XTEA
|
||||
Feistel** (delta `0x9E3779B9`) with baked-in round constants, keyed by a
|
||||
per-**session** 32-bit value the protocol calls the "Cypher". The protocol
|
||||
defines five session keys (the memorable hex values are the protocol's own
|
||||
constants):
|
||||
|
||||
| Session | Key | Use |
|
||||
|---------|--------|-----|
|
||||
| MANUFACTURING | `0xB16BA115` | factory features |
|
||||
| DIAGNOSTIC | `0xBABECAFE` | diagnostic tool (← likely the path that carries the DSI fault) |
|
||||
| REPROGRAMMING | `0xDEADBEEF` | firmware reflash |
|
||||
| **REMOTE_CONTROL** | **`0xB16B00B5`** | **on/off/move — this is the command-path key** |
|
||||
| DAQ | `0x0B00B135` | data acquisition |
|
||||
|
||||
`remote_control_response(challenge)` returns the value the module expects.
|
||||
**Validated against all 51 captured pairs** across four nodes (2A 44/44, 61 2/2,
|
||||
75 3/3, F8 2/2): REMOTE_CONTROL is the unique session key that matches every pair
|
||||
(the other four miss all 51), and it's **one global key, shared by all nodes**.
|
||||
So to operate a load: read the module's page-42 challenge, compute the response,
|
||||
send it on page-43, then send the opcode. Reference implementation + self-test in
|
||||
`ids_can_auth.py` (`python3 ids_can_auth.py <challenge_hex>` prints a response;
|
||||
`python3 ids_can_auth.py` runs the 51/51 self-test).
|
||||
|
||||
### Confirmed by live actuation (2026-06-12) — `idscan_cmd.py`
|
||||
|
||||
`idscan_cmd.py` drives the whole exchange end-to-end over socketcan (raw AF_CAN,
|
||||
stdlib only). Tested on node **`F8` (interior lights)**: three consecutive
|
||||
operations (**on → off → on**), each answering a **distinct fresh challenge**
|
||||
(`660E04A0`, `0BF53691`, `10FAEEA8`), with the module's page-3 broadcast read
|
||||
back before and after to confirm the result each time — `b0` bit0 tracked the
|
||||
command (1→1, 1→0, 0→1) and the level byte ramped accordingly. The command path
|
||||
works.
|
||||
|
||||
```sh
|
||||
python3 idscan_cmd.py F8 on # node_hex on|off ; needs can0 up
|
||||
```
|
||||
|
||||
Movement nodes (awning `75`, slides, jacks) use the **same** authentication —
|
||||
the app-driven awning commands in `captures/app-commands-*.log` show the identical
|
||||
page-42/43 exchange.
|
||||
|
||||
**Awning motion is HOLD-TO-RUN, and auto-retract is live (2026-07-01).** A single
|
||||
authenticated opcode runs the motor only ~1s (it's a "keep the button held"
|
||||
signal, not a latch). The OEM app sustains motion by **streaming after one auth**:
|
||||
opcode `03F2<node>02` every ~110ms **plus** a page-44 keepalive `03F0<node>44`
|
||||
payload `00 04` every ~510ms — **no per-command re-auth during the run** (a cold
|
||||
opcode with no session is still ignored; the auth just opens the motion session).
|
||||
Our ESPHome node reproduces this with its own controller identity (opcode
|
||||
`0006 75 02`, keepalive `0004 75 44`) — proven to sustain ~6.5s of continuous
|
||||
retract on 2026-07-01.
|
||||
|
||||
The awning has no position feedback, but the **motor current** rides page-3 b2-3
|
||||
(BE, raw counts): ~<1550 running incl. inrush, tapering to ~350–500 near closed,
|
||||
then a sharp ramp to a **~4200 plateau at the fully-closed hard stop** (captures
|
||||
`awning-fullretract-2026-07-01_*.log`). The node's **auto-retract** (cover CLOSE)
|
||||
streams the retract, watches current at 20Hz, and cuts at `cur>2500` for 3 frames
|
||||
(~150ms) — firing ~0.3s into the stall ramp, before the motor sits hard-stalled —
|
||||
then marks the cover CLOSED (the one direction with a real end-stop). Backstops:
|
||||
70s timeout, motion-lost detector, and stop-streaming-stops-the-motor (hold-to-run
|
||||
fail-safe). OPEN stays a single ~1s jog (no safe end-stop for extend). See
|
||||
`esphome/onecontrol-canbus.yaml` (`awning_auto_retract` script + streamer
|
||||
`interval` + the case `0x75` stall gate).
|
||||
|
||||
**Bottom line: read is fully open** (all sensors + states from broadcasts, no
|
||||
authentication) **and command is implemented and proven** (`ids_can_auth.py` +
|
||||
`idscan_cmd.py`). The CAN path can both sense and operate the system, so the
|
||||
Bluetooth integration is no longer needed for control. The challenge-response is
|
||||
folded into the ESPHome node's `switch`/`light`/`cover` actions (opcode preceded
|
||||
by the page-42/43 exchange), and the awning cover does authenticated streaming
|
||||
auto-retract — see the awning section above.
|
||||
|
||||
Other app-session traffic (not control): `701` = controller heartbeat during a
|
||||
Bluetooth session; src `01` → node pages `30/31` = paged descriptor/table reads
|
||||
the app uses to build its UI.
|
||||
|
||||
**Open read-side items:** identify node `89` (last unmapped 0x1E load) and
|
||||
`6A`/`7F`/`9C` (movement — slide?), and find the battery SoC / "4 green lights"
|
||||
source.
|
||||
|
||||
### DSI fault — decoded (2026-06-12, `captures/dsi-fault-*.log`)
|
||||
|
||||
Forced a real lockout (propane valve closed, heater run on gas until it gave up)
|
||||
and diffed against the healthy baseline. Two signals:
|
||||
|
||||
- **Water-heater DSI fault = node `95` page-3 `b0` bit5 (`0x20`).** Healthy heater
|
||||
reads `0x80` (off) or `0x81` (running); during the lockout it read **`0xA0`**
|
||||
(bit0 cleared, bit5 set) for every sample. Bit5 never appears healthy → it's
|
||||
the gas-ignition lockout flag. (`b1` stays `0xFF` and `node AE` stays `0x00` —
|
||||
the two earlier suspects were both wrong.)
|
||||
- **Bus-wide "system fault present" = page-0 `b0` bit0 (`0x01`).** *Every* node's
|
||||
page-0 `b0` flipped `0x02`→`0x03` during the fault, so any node carries a
|
||||
generic "a fault exists somewhere" flag.
|
||||
|
||||
Both are wired into the ESPHome node as `binary_sensor`s (`device_class:
|
||||
problem`) — the DSI fault the Bluetooth app never exposed. Reset = reopen valve,
|
||||
re-light.
|
||||
|
||||
---
|
||||
|
||||
## Hardware (BOM)
|
||||
|
||||
| Item | Notes |
|
||||
|------|-------|
|
||||
| **CANable 2.0** USB-CAN | Bus capture/bring-up from xarl → socketcan (`can0`). |
|
||||
| **Waveshare SN65HVD230** transceiver | 3.3 V, **onboard 120 Ω terminator** → use as the bus-END node. |
|
||||
| **ESP32** devboard (`esp32dev` WROOM) | Native TWAI/CAN peripheral; ESPHome `esp32_can`. Spare from the gazebo build. |
|
||||
| **Molex Mini-Fit Jr.** 2-pin pigtail (female) | Mates the panel's spare CAN **data** port. ~$20 assortment pack, not the $30 Lippert #331111. |
|
||||
|
||||
## System facts (from `lippert_control_panel_specs.pdf`, doc CCD-0004084, + web)
|
||||
|
||||
- **Controller:** UNITY **X180T**. Lippert brands it "RV-C", but the bus actually
|
||||
runs **IDS-CAN** (proprietary): 250 kbit/s, 11-bit IDs — see protocol notes above.
|
||||
- **Topology:** daisy-chain; each module has **two 2-pin CAN data ports**.
|
||||
**CAN data = 2-wire pair (CAN-H/CAN-L)** on a **Molex Mini-Fit Jr.** (4.2 mm)
|
||||
connector; Lippert's data pair is **red/black**. Power is a SEPARATE 2-pin
|
||||
harness. Bus terminated at **both ends** by a 2-pin terminator plug (120 Ω H↔L).
|
||||
- **Tank senders wire directly into the X180T** (DSI/FRESH/BLACK/GRAY/GRAY2
|
||||
terminal block) → the controller reads resistive senders and broadcasts levels
|
||||
on CAN from its own source address (not separate tank modules).
|
||||
|
||||
## Physical connection (4 screws, fully reversible)
|
||||
|
||||
1. Pull the monitor panel (4 screws).
|
||||
2. Find the **data** port — the one with the **terminating resistor** plugged in
|
||||
(and/or where the controller's data harness lands). **NOT** the look-alike
|
||||
2-pin **power** connector.
|
||||
- ⚠️ **Multimeter check first:** data idles **~2.5 V** (recessive) across the
|
||||
pins; **power reads ~12 V**. 12 V into CAN-H/L kills the SN65HVD230/CANable.
|
||||
3. Unplug the **terminator**, plug your Mini-Fit Jr. pigtail into that port.
|
||||
4. Land the two wires on the transceiver's CAN-H / CAN-L. The transceiver's
|
||||
onboard 120 Ω re-terminates that end (keeps exactly 2 terminators: controller
|
||||
+ your node). **Never** add a terminated node in the *middle* of the bus.
|
||||
- **This rig's pigtail (as crimped):** **green = CAN-L, blue = CAN-H.**
|
||||
5. Revert = unplug, re-seat the terminator.
|
||||
|
||||
CAN-H vs CAN-L: harmless if swapped — the bus just goes silent; flip the two
|
||||
wires (or pop the Mini-Fit Jr. terminals and reorder).
|
||||
|
||||
---
|
||||
|
||||
## Capturing bus traffic
|
||||
|
||||
On xarl with the CANable (see `captures/log-can.sh` for an `up`/`rec`/`watch`/`down`
|
||||
helper):
|
||||
|
||||
```sh
|
||||
paru -S can-utils # AUR on Arch (not in the repos)
|
||||
sudo slcand -o -s5 /dev/ttyACMx can0 # this CANable shipped with slcan fw
|
||||
sudo ip link set can0 up
|
||||
candump -ta -x can0 | tee captures/$(date +%F)-idle.log # timestamped raw log
|
||||
cansniffer -c can0 # color diff view — operate a load, watch which bytes move
|
||||
```
|
||||
|
||||
> **CANable firmware note:** this unit enumerates as a serial device
|
||||
> (`/dev/ttyACM*`), so it needs `slcand` to bridge it to a `can0` socketcan
|
||||
> interface (`-s5` = 250 kbit/s). If it ever re-enumerates to a different
|
||||
> `ttyACM` number, restart `slcand` against the new path. A candleLight/gs_usb
|
||||
> reflash would instead give a native `can0` via
|
||||
> `ip link set can0 up type can bitrate 250000`.
|
||||
|
||||
**Mapping method** (easy, because everything is broadcast and unauthenticated):
|
||||
operate ONE physical load (or watch ONE tank), see which **node + page + byte**
|
||||
changes, record it in the node map above. Repeat per device.
|
||||
|
||||
Decode each 11-bit ID as:
|
||||
```
|
||||
page = (id >> 8) & 0x7 # message page
|
||||
node = id & 0xFF # node address (which module)
|
||||
```
|
||||
|
||||
### Device inventory (from the Bluetooth notes — what to look for on the bus)
|
||||
|
||||
| Bluetooth DevID | Component |
|
||||
|-----------------|-----------|
|
||||
| 4 | water pump |
|
||||
| 5 | gas water heater |
|
||||
| 6 | exterior lights |
|
||||
| 7 | interior lights |
|
||||
| 8 | grey tank 2 |
|
||||
| 9 | grey tank 1 |
|
||||
| 10 | black tank |
|
||||
| 11 | fresh water tank |
|
||||
| 2, 3 | slide / awning |
|
||||
| — | battery voltage |
|
||||
|
||||
> The Bluetooth DevID numbers do **not** map to IDS-CAN node addresses — the
|
||||
> table is just the checklist of loads to identify on the bus (all now found; see
|
||||
> the node map).
|
||||
|
||||
---
|
||||
|
||||
## ESPHome node
|
||||
|
||||
`esphome/onecontrol-canbus.yaml` — ESP32 `esp32_can` listener (catch-all
|
||||
`on_frame` → IDS-CAN dispatcher → template sensors/switches). Mirrors the
|
||||
`gazebo-fan-proxy` pattern: USB flash once, OTA after; native HA entities on the
|
||||
campsite Pi over the ESPHome API. During bring-up it logs every decoded frame at
|
||||
`DEBUG` so the ESP can double as a monitor. Fill in the node/byte math in the
|
||||
lambda from the node map; wire the command path (page-42/43 exchange +
|
||||
`ids_can_auth` response, then the opcode) into the `switch`/`light`/`cover`
|
||||
actions last.
|
||||
|
||||
## References
|
||||
|
||||
- `ids_can_auth.py` — IDS-CAN command authentication (response computation + self-test)
|
||||
- `idscan_cmd.py` — socketcan command tool (the full exchange, proven on node F8)
|
||||
- Lippert OneControl: <https://www.lippert.com/brands/onecontrol>
|
||||
- RV-C background (for contrast — this bus is **not** RV-C): <https://www.rv-c.com/>
|
||||
- Bluetooth-side protocol notes (this repo): `../docs/PROTOCOL_FINDINGS.md`
|
||||
@@ -0,0 +1,192 @@
|
||||
# Awning "Closed" Sensor — Spec (Option 1: standalone WiFi reed node)
|
||||
|
||||
**Goal:** give the campsite awning a *true* stowed/not-stowed state instead of the
|
||||
`assumed_state` the CAN cover currently reports. A magnetic reed switch at the
|
||||
fully-retracted position, read by a small ESP running ESPHome over the existing
|
||||
`OmnissiahsReach` WiFi — **no Zigbee mesh, no coordinator, no new ecosystem.**
|
||||
|
||||
This is "Option 1" (standalone node). Option 2 (wire the reed back to a spare GPIO
|
||||
on the CAN node `192.168.69.18` so the cover entity itself becomes truthful) is the
|
||||
more-integrated alternative but needs a 2-wire run from the awning to the control
|
||||
panel; it's noted at the end. Pairs with the current-sensing auto-retract already
|
||||
in `esphome/onecontrol-canbus.yaml` (README "awning" section): current does the
|
||||
*dynamic* stop during retract, the reed gives *persistent* truth afterward.
|
||||
|
||||
## What it delivers (and what it doesn't)
|
||||
|
||||
- **Reliable CLOSED (stowed) detection.** Awnings extend to an arbitrary spot, so
|
||||
there's no meaningful "fully open" endpoint — but the fully-retracted position is
|
||||
hard and repeatable. That's the state that matters (don't drive off / don't let
|
||||
it flap): a single reed there answers *stowed vs. not*.
|
||||
- It does **not** measure extension %. If you ever want an "opening/closing"
|
||||
animation you already get that from the CAN motion byte; the reed just pins down
|
||||
the resting truth.
|
||||
|
||||
## Bill of materials (~$15–20)
|
||||
|
||||
| Part | Notes |
|
||||
|------|-------|
|
||||
| **ESP32-C3 SuperMini** (or Wemos D1 mini / ESP8266) | Tiny, WiFi, 3.3 V logic. C3 recommended (native ESP-IDF, cheap, plenty of GPIO). |
|
||||
| **Weatherproof reed switch** | RV-compartment / garage-door style, **potted + leaded**, normally-open. Get a wide-gap one (pull-in ≥ 15–20 mm) so alignment is forgiving. |
|
||||
| **Bar/block magnet** | Match/exceed the reed's rated gap. Rare-earth block for margin. |
|
||||
| **12 V → 5 V buck** (MP1584 / mini360 or an automotive USB buck) | Tap the awning motor's 12 V; always-on beats battery/deep-sleep for instant state and no maintenance. |
|
||||
| **Small IP65 enclosure + gland** | Mount the ESP + buck out of the weather. |
|
||||
| Fused 12 V tap (inline 1 A) | Protect the tap off the motor harness. |
|
||||
|
||||
## Wiring
|
||||
|
||||
```
|
||||
awning 12V harness --[inline 1A fuse]--> buck IN+ buck OUT+ (5V) --> ESP 5V
|
||||
chassis GND -----> buck IN- ----buck OUT- ------> ESP GND
|
||||
|
||||
reed leg A --> ESP GPIO4
|
||||
reed leg B --> ESP GND
|
||||
```
|
||||
|
||||
- Reed to GND with the **internal pull-up** enabled: pin idles HIGH (not stowed),
|
||||
goes LOW when the magnet is present (stowed). The ESPHome config below inverts
|
||||
that so the sensor reads ON = stowed.
|
||||
- No external resistor needed. An optional 0.1 µF across the reed helps debounce,
|
||||
but the software `delayed_on/off` below is enough.
|
||||
- Avoid the C3 strapping pins (GPIO2, 8, 9); GPIO4 is safe. GPIO8 has the onboard
|
||||
LED if you want a status blink.
|
||||
|
||||
## Mounting
|
||||
|
||||
- **Magnet on the moving part** (the lead rail / roller endcap that seats when
|
||||
stowed); **reed on the fixed part** (mounting rail or a bracket on the coach).
|
||||
- Aim for the magnet to land within the reed's pull-in gap when the awning is
|
||||
pulled in tight — the same "closed" position the current-stall stop lands on.
|
||||
Expect a test-fit: mark where the rail seats, mount, confirm the sensor flips.
|
||||
- Outdoor: use the potted reed, point the gland down, silicone the entry, and
|
||||
strain-relief the lead so awning motion doesn't fatigue it.
|
||||
|
||||
## ESPHome config
|
||||
|
||||
New device `awning-sensor` (mirror the conventions in `onecontrol-canbus.yaml` /
|
||||
`gazebo-fan-proxy.yaml`: `secrets.yaml` for WiFi + API key, OTA after first USB
|
||||
flash, fallback AP + captive portal). DHCP on `OmnissiahsReach`.
|
||||
|
||||
```yaml
|
||||
substitutions:
|
||||
name: awning-sensor
|
||||
friendly_name: Awning Sensor
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
friendly_name: ${friendly_name}
|
||||
|
||||
esp32:
|
||||
board: esp32-c3-devkitm-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid # OmnissiahsReach
|
||||
password: !secret wifi_password
|
||||
ap:
|
||||
ssid: "Awning-Sensor Fallback"
|
||||
password: !secret fallback_ap_password
|
||||
captive_portal:
|
||||
|
||||
binary_sensor:
|
||||
- platform: gpio
|
||||
name: "Awning Stowed"
|
||||
id: awning_stowed
|
||||
pin:
|
||||
number: GPIO4
|
||||
mode:
|
||||
input: true
|
||||
pullup: true
|
||||
inverted: true # reed->GND: magnet present = LOW = ON (stowed)
|
||||
filters:
|
||||
- delay_on: 200ms # debounce the rail seating
|
||||
- delay_off: 200ms
|
||||
# ON = awning pulled in tight (stowed / closed)
|
||||
# OFF = not stowed (extended, or mid-travel)
|
||||
```
|
||||
|
||||
`secrets.yaml` needs `wifi_ssid`, `wifi_password`, `fallback_ap_password`,
|
||||
`api_key` (generate with `esphome`). First flash over USB (`esphome run ... `),
|
||||
thereafter OTA — same as the other campsite nodes.
|
||||
|
||||
## HA integration
|
||||
|
||||
The reed is a separate device from the CAN node, so it lands as its own entity:
|
||||
|
||||
1. **Campsite Pi** auto-discovers it via the ESPHome integration →
|
||||
`binary_sensor.awning_sensor_awning_stowed` (rename to
|
||||
`binary_sensor.awning_stowed`). ON = stowed.
|
||||
2. **Bridge to home HA** — add to `/config/packages/mqtt_bridge.yaml` (repo:
|
||||
`canbus/ha/mqtt_bridge_onecontrol.yaml`) next to the other campsite entities:
|
||||
a discovery block (`homeassistant/binary_sensor/campsite/awning_stowed/config`,
|
||||
device `campsite_onecontrol`, `device_class: "opening"` reads on=open/off=closed,
|
||||
or leave classless for a plain stowed/clear), plus a state-forward trigger on
|
||||
`binary_sensor.awning_stowed` → `campsite/binary_sensor/awning_stowed/state`.
|
||||
Appears on home as `binary_sensor.campsite_onecontrol_awning_stowed`.
|
||||
3. Add the tile to home HA **Overview → Camper → OneControl** section.
|
||||
|
||||
### Optional: make the cover *truthful* (template cover)
|
||||
|
||||
Right now `cover.onecontrol_can_awning` is `assumed_state`. With the reed you can
|
||||
wrap it in a template cover on the Pi whose closed state is the *real* reed, while
|
||||
commands still hit the CAN cover:
|
||||
|
||||
```yaml
|
||||
cover:
|
||||
- platform: template
|
||||
covers:
|
||||
awning_true:
|
||||
friendly_name: "Awning"
|
||||
device_class: awning
|
||||
value_template: "{{ 'closed' if is_state('binary_sensor.awning_stowed','on') else 'open' }}"
|
||||
close_cover:
|
||||
- action: cover.close_cover # -> CAN cover auto-retract
|
||||
target: { entity_id: cover.onecontrol_can_awning }
|
||||
stop_cover:
|
||||
- action: cover.stop_cover
|
||||
target: { entity_id: cover.onecontrol_can_awning }
|
||||
```
|
||||
|
||||
Bridge `cover.awning_true` to home instead of the raw CAN cover, and you get a
|
||||
cover that shows genuine open/closed. (Keep `payload_open` disabled as today.)
|
||||
|
||||
### Automations this unlocks
|
||||
|
||||
- **Left-it-out alarm:** awning `not stowed` + (leaving geofence / wind gust > X /
|
||||
rain) → Discord ping, or auto-fire `cover.close_cover`.
|
||||
- **Confirm the retract actually seated:** after an auto-retract, if the current
|
||||
stall fired but the reed is still OFF a few seconds later → alert (mis-seat).
|
||||
|
||||
> **Option-1 limitation:** because the reed is on a *separate* device from the CAN
|
||||
> node, the auto-retract's stall gate can't consume it on-device — any
|
||||
> reed↔retract logic is a cross-device HA automation, not firmware. If you want
|
||||
> the ESP to stop the motor *on the reed itself* (belt-and-suspenders with the
|
||||
> current stall), that's Option 2 (wire the reed to a spare GPIO on the CAN node
|
||||
> `192.168.69.18`; the cover entity then reads it directly and becomes truthful
|
||||
> with no template cover).
|
||||
|
||||
## Touchpoints checklist (campsite ESPHome node)
|
||||
|
||||
- [ ] `secrets.yaml` (WiFi = OmnissiahsReach, API key, fallback AP pw)
|
||||
- [ ] USB flash once, confirm on `OmnissiahsReach` (DHCP `192.168.69.x`); OTA after
|
||||
- [ ] Adopt in campsite Pi HA (ESPHome integration), rename entity
|
||||
- [ ] Mount reed + magnet, verify it flips exactly at the stowed position
|
||||
- [ ] `mqtt_bridge.yaml`: discovery + state-forward → home HA; add dashboard tile
|
||||
- [ ] (optional) template cover `awning_true`; bridge it instead of the raw cover
|
||||
- [ ] (optional) automations: left-out alarm, retract-seat confirmation
|
||||
|
||||
## Validation
|
||||
|
||||
1. USB-flash, join WiFi, adopt in HA. Toggle by hand with a magnet → `awning_stowed`
|
||||
flips ON/OFF with the 200 ms debounce.
|
||||
2. Mount, retract via the dashboard (auto-retract): at full stow the reed should go
|
||||
ON right around when the current-stall stop fires.
|
||||
3. Extend at the OEM wall switch → reed OFF. Confirm home HA mirrors it.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Lippert IDS-CAN command-auth challenge/response pairs
|
||||
# node 2A (exterior lights), captured 2026-06-11, app-driven on/off x~20
|
||||
# format: <challenge_hex> <response_hex> (both 4 bytes, from page42/page43 payload bytes 3-6)
|
||||
9577DE9B C033B197
|
||||
1ADF97F8 F48604B6
|
||||
E09D5B01 44A5C15E
|
||||
DEA757A0 04DD9CBA
|
||||
8862C15F 29AA626F
|
||||
E14DB9AE 25373B94
|
||||
23A3ED5D 3250A48A
|
||||
F3A3B602 A6E58995
|
||||
FC5F3883 47780DAF
|
||||
16B2AE10 28FE5A23
|
||||
11C34FD9 6A1B9415
|
||||
2FFC637D 0A5CE05B
|
||||
DB284B0C 052D2687
|
||||
FA14432F 71EDD5F4
|
||||
B140D087 356CFD45
|
||||
20337B7E 81D3136A
|
||||
968F0543 F91AB4A3
|
||||
28DA207A 781A423E
|
||||
AB4040DE 5B652F23
|
||||
7A3CA469 48DF5FCF
|
||||
AFDFBC25 2E52F019
|
||||
B7394203 C62B24EE
|
||||
ECA9CD68 361924A5
|
||||
83198E6B 7E9DF4D9
|
||||
4D3F6429 86793E68
|
||||
0804DD8B 953137D5
|
||||
E1525A39 1091BB17
|
||||
C77CAA9B F9746AAC
|
||||
B98C209C 46385C66
|
||||
BBE34815 F717E80E
|
||||
2DAA8E0C BB1CF451
|
||||
D4AAC54D FD806835
|
||||
E1ECB36C FE26F014
|
||||
BF066519 4F23CAAF
|
||||
DA8EF86C ED575807
|
||||
0FD2C9B9 6B0EF761
|
||||
4B192BC2 0DC60551
|
||||
A0421A64 54A5AD6D
|
||||
2BE06274 BD49B339
|
||||
8FF0B6E5 C18D49F2
|
||||
45FE7D5E 0A44BB97
|
||||
BAA07C27 54E5C64B
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural analysis of the IDS-CAN command-auth challenge->response map.
|
||||
|
||||
Black-box: decide what the captured (challenge, response) pairs can and cannot
|
||||
tell us about f, where response = f(challenge). Pure stdlib.
|
||||
|
||||
Usage: ./analyze_auth.py [pairfile ...]
|
||||
Default: loads 2A-auth-pairs.txt + auth-pairs-multinode-2026-06-11.txt (51 pairs).
|
||||
|
||||
Pair-file format: lines of "<challenge_hex> <response_hex>", optionally prefixed
|
||||
with a node label ("<node> <challenge> <response>"); '#' comments ignored.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DEFAULTS = [
|
||||
os.path.join(HERE, "2A-auth-pairs.txt"),
|
||||
os.path.join(HERE, "auth-pairs-multinode-2026-06-11.txt"),
|
||||
]
|
||||
|
||||
|
||||
def load(paths):
|
||||
pairs = []
|
||||
for path in paths:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
tok = line.split()
|
||||
c, r = tok[-2], tok[-1] # tolerate optional node label
|
||||
pairs.append((int(c, 16), int(r, 16)))
|
||||
return pairs
|
||||
|
||||
|
||||
def reduce_vec(iv, ov, basis):
|
||||
for piv, bi, bo in basis:
|
||||
if (iv >> piv) & 1:
|
||||
iv ^= bi
|
||||
ov ^= bo
|
||||
return iv, ov
|
||||
|
||||
|
||||
def main():
|
||||
paths = sys.argv[1:] or DEFAULTS
|
||||
pairs = load(paths)
|
||||
n = len(pairs)
|
||||
print(f"loaded {n} pairs from {', '.join(os.path.basename(p) for p in paths)}\n")
|
||||
|
||||
# determinism is only testable if a challenge recurs (else the check is vacuous)
|
||||
chals = [c for c, _ in pairs]
|
||||
repeats = len(chals) - len(set(chals))
|
||||
print(f"[determinism] {len(set(chals))} distinct challenges, {repeats} repeated "
|
||||
f"-> {'testable' if repeats else 'NOT testable (all distinct; statelessness assumed)'}")
|
||||
|
||||
# TEST 1: GF(2)-affine. f(x)=M.x^k => f(Ci)^f(Cj)=M.(Ci^Cj) (constant cancels).
|
||||
# Reduce input-diffs against a growing basis; an input-diff that collapses to 0
|
||||
# while its output-diff does not => NOT affine. Full rank + consistent => solved.
|
||||
C0, R0 = pairs[0]
|
||||
basis, contradiction = [], None
|
||||
for c, r in pairs[1:]:
|
||||
iv, ov = reduce_vec(c ^ C0, r ^ R0, basis)
|
||||
if iv == 0:
|
||||
if ov != 0 and contradiction is None:
|
||||
contradiction = ov
|
||||
else:
|
||||
basis.append((iv.bit_length() - 1, iv, ov))
|
||||
rank = len(basis)
|
||||
print(f"\n[TEST 1: GF(2)-affine] input-difference rank = {rank}/32")
|
||||
if contradiction is not None:
|
||||
print(" -> NOT GF(2)-affine (linear fit contradicted despite full-rank data;"
|
||||
" obstacle is structure, not sample count)")
|
||||
elif rank == 32:
|
||||
applyM = lambda x: reduce_vec(x, 0, basis)[1]
|
||||
k = R0 ^ applyM(C0)
|
||||
bad = sum((applyM(c) ^ k) != r for c, r in pairs)
|
||||
print(f" -> AFFINE & SOLVED: k={k:#010x}, mismatches={bad}/{n}")
|
||||
else:
|
||||
print(f" -> consistent w/ affine but underdetermined ({rank}/32); need more pairs")
|
||||
|
||||
# TEST 2: affine over Z/2^32. R=a*C+b mod 2^32; derive a from one odd diff, verify.
|
||||
MOD = 1 << 32
|
||||
a = None
|
||||
for c, r in pairs[1:]:
|
||||
dc = (c - C0) % MOD
|
||||
if dc & 1:
|
||||
inv = 1
|
||||
for _ in range(6):
|
||||
inv = (inv * (2 - dc * inv)) % MOD
|
||||
a = ((r - R0) * inv) % MOD
|
||||
break
|
||||
print("\n[TEST 2: affine over Z/2^32 R=a*C+b]")
|
||||
if a is None:
|
||||
print(" -> no odd input-difference; skipped")
|
||||
else:
|
||||
b = (R0 - a * C0) % MOD
|
||||
bad = sum((a * c + b) % MOD != r for c, r in pairs)
|
||||
print(f" a={a:#010x} b={b:#010x} -> {bad}/{n} miss"
|
||||
f" -> {'SOLVED' if bad == 0 else 'not a ring-affine map'}")
|
||||
|
||||
# TEST 3: byte locality. Is out-byte p a pure function of a single in-byte q?
|
||||
byte = lambda v, i: (v >> (8 * (3 - i))) & 0xFF
|
||||
local = []
|
||||
for p in range(4):
|
||||
for q in range(4):
|
||||
groups = {}
|
||||
ok = True
|
||||
for c, r in pairs:
|
||||
key = byte(c, q)
|
||||
if key in groups and groups[key] != byte(r, p):
|
||||
ok = False
|
||||
break
|
||||
groups[key] = byte(r, p)
|
||||
if ok and any(sum(byte(c, q) == k for c, _ in pairs) > 1 for k in groups):
|
||||
local.append((p, q))
|
||||
print("\n[TEST 3: byte locality]",
|
||||
"out-byte/in-byte dependencies:" if local else
|
||||
"-> no out-byte is a function of any single in-byte (full diffusion)")
|
||||
for p, q in local:
|
||||
print(f" out-byte {p} may depend only on in-byte {q} (check w/ more data)")
|
||||
|
||||
# TEST 4: per-bit balance (informative, not decisive at this sample size)
|
||||
ones_in = [sum((c >> b) & 1 for c, _ in pairs) for b in range(32)]
|
||||
ones_out = [sum((r >> b) & 1 for _, r in pairs) for b in range(32)]
|
||||
print(f"\n[TEST 4: per-bit balance] (ideal 0.5) "
|
||||
f"challenge {min(ones_in)/n:.2f}-{max(ones_in)/n:.2f}, "
|
||||
f"response {min(ones_out)/n:.2f}-{max(ones_out)/n:.2f}")
|
||||
|
||||
print("\nVERDICT: keyed nonlinear cipher (TEA/XTEA-family), not recoverable from "
|
||||
"random known-plaintext pairs at any feasible count. SOLVED 2026-06-12 "
|
||||
"(REMOTE_CONTROL session key 0xB16B00B5), verified 51/51 here — see "
|
||||
"../ids_can_auth.py.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
# Lippert IDS-CAN command-auth challenge/response pairs — MULTI-NODE
|
||||
# Captured 2026-06-11, app-driven (phone app issued on/off/move commands).
|
||||
# Source log: app-commands-2026-06-11_230059.log (node 2A bulk set lives in 2A-auth-pairs.txt).
|
||||
#
|
||||
# Exchange (controller node 01 <-> module node), all 29-bit extended frames:
|
||||
# id = (src<<18) | (dir<<16) | (dest<<8) | page dir: 0 = 01->node, 1 = node->01
|
||||
# arm 01->node page42 DLC2 "00 04"
|
||||
# challenge node->01 page42 DLC6 "00 04 <CC CC CC CC>"
|
||||
# response 01->node page43 DLC6 "00 04 <RR RR RR RR>"
|
||||
# ack node->01 page43 DLC2 "00 04"
|
||||
# format below: <challenge_hex> <response_hex> (the 4-byte payload tails)
|
||||
#
|
||||
# Confirmed: the SAME handshake gates every node tested — switched loads AND the
|
||||
# awning (movement class). 51 pairs total across 4 nodes; response = f(challenge)
|
||||
# is nonlinear (not GF(2)-affine, not Z/2^32-affine, full byte diffusion).
|
||||
|
||||
# node 61 — water pump (type 0x1E switched load)
|
||||
61 FE06BF48 58AEA9BE
|
||||
61 D57BE45A A1FB45E1
|
||||
|
||||
# node 75 — awning (type 0x21 H-bridge/movement) — full nonce gate, same as loads
|
||||
75 A009E94C 5ADC2B0D
|
||||
75 5F8A7647 4CA89152
|
||||
75 6A873757 02592CF2
|
||||
|
||||
# node F8 — interior lights (type 0x1E switched load)
|
||||
F8 F7740A20 BDB16954
|
||||
F8 EDC9281A 87EFB3EF
|
||||
|
||||
# node 2A — exterior lights (type 0x1E) — 2 extra pairs from this session;
|
||||
# 42 more in 2A-auth-pairs.txt (extlight-authpairs-*.log). 21CA0C06 is the pair
|
||||
# the README used to rule out the BLE TEA key.
|
||||
2A 21CA0C06 CC18366B
|
||||
2A 4FC2C0FF 2B47861E
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bring up the CANable at IDS-CAN speed and log timestamped raw frames.
|
||||
#
|
||||
# Usage:
|
||||
# ./log-can.sh # bring up can0 @ 250k, live candump
|
||||
# ./log-can.sh rec NAME # also tee a timestamped log to NAME-<date>.log
|
||||
# ./log-can.sh watch # cansniffer color-diff view (operate a load, watch bytes)
|
||||
# ./log-can.sh down # take the interface down
|
||||
#
|
||||
# Requires: can-utils (paru -S can-utils on Arch). This CANable shipped with
|
||||
# slcan firmware (a /dev/ttyACM* serial device) — bridge it to can0 first with
|
||||
# sudo slcand -o -s5 /dev/ttyACMx can0
|
||||
# A candleLight/gs_usb reflash would instead give a native socketcan can0 and let
|
||||
# the up() path below set the bitrate directly.
|
||||
|
||||
set -euo pipefail
|
||||
IFACE="${IFACE:-can0}"
|
||||
BITRATE=250000 # IDS-CAN is 250k
|
||||
CMD="${1:-up}"
|
||||
|
||||
up() {
|
||||
if ! ip link show "$IFACE" &>/dev/null; then
|
||||
echo "No $IFACE — is the CANable plugged in? (dmesg | grep -i gs_usb)" >&2
|
||||
exit 1
|
||||
fi
|
||||
sudo ip link set "$IFACE" down 2>/dev/null || true
|
||||
sudo ip link set "$IFACE" up type can bitrate "$BITRATE"
|
||||
echo "$IFACE up @ ${BITRATE} bps"
|
||||
}
|
||||
|
||||
case "$CMD" in
|
||||
up) up; exec candump -ta -x "$IFACE" ;;
|
||||
rec) up
|
||||
name="${2:-onecontrol}"; out="$(dirname "$0")/${name}-$(date +%F_%H%M%S).log"
|
||||
echo "logging -> $out (Ctrl-C to stop)"
|
||||
exec candump -ta -x "$IFACE" | tee "$out" ;;
|
||||
watch) up; exec cansniffer -c "$IFACE" ;;
|
||||
down) sudo ip link set "$IFACE" down; echo "$IFACE down" ;;
|
||||
*) echo "usage: $0 {up|rec NAME|watch|down}" >&2; exit 2 ;;
|
||||
esac
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# ESPHome build cache + real secrets (keep secrets.yaml.example tracked)
|
||||
/.esphome/
|
||||
/secrets.yaml
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
// Command-path safety policy for the OneControl integration.
|
||||
//
|
||||
// POLICY: some loads are operated from the physical control panel / OEM app ONLY,
|
||||
// never over CAN. command_blocked() returns true for any node this firmware must
|
||||
// never send a command to. It is the single source of truth for that rule and is
|
||||
// checked in two independent places (the command-entry script and the actual
|
||||
// transmit point), so loosening one does not open the other.
|
||||
//
|
||||
// What it blocks:
|
||||
// * slides / jacks — never automate. Explicit denylist of this rig's nodes
|
||||
// (6A/7F/9C), always blocked, even before we've heard their identity;
|
||||
// * the water pump (61) — winterizing-only; operate it from the panel/app so it
|
||||
// can't be left running (e.g. dry) by accident;
|
||||
// * any movement/motor-class node (device class 0x21) other than the awning
|
||||
// (0x75) — generalizes the slide/jack rule to "any motor that isn't the awning".
|
||||
//
|
||||
// Permitted (NOT blocked here): lights, water heater, and the awning. Whether a
|
||||
// permitted node is actually exposed as an entity is a separate allowlist in the
|
||||
// command-entry script — this gate only enforces the never-allowed set.
|
||||
//
|
||||
// node_type is the page-2 identity byte (device class) for the node, or 0 if not
|
||||
// yet observed. The static denylist covers the not-yet-observed window.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace onecontrol {
|
||||
|
||||
inline bool command_blocked(uint8_t node, uint8_t node_type) {
|
||||
// Slide / jack / movement-class nodes on this rig — always refused.
|
||||
if (node == 0x6A || node == 0x7F || node == 0x9C) return true;
|
||||
// Water pump — panel/app only (winterizing).
|
||||
if (node == 0x61) return true;
|
||||
// Any movement-class (0x21) node except the awning (0x75).
|
||||
if (node_type == 0x21 && node != 0x75) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace onecontrol
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
// 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.
|
||||
// Verified bit-exact against ids_can_auth.py and 51 captured bus pairs.
|
||||
//
|
||||
// uint32_t arithmetic wraps mod 2^32 by definition in C++, so no masking needed.
|
||||
// ESP32 int is 32-bit (uint32_t == unsigned int): host g++ test is representative.
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
|
||||
namespace ids_can_auth {
|
||||
|
||||
// Per-session keys ("Cypher"). REMOTE_CONTROL is the session for on/off/move.
|
||||
enum SessionKey : uint32_t {
|
||||
MANUFACTURING = 0xB16BA115u,
|
||||
DIAGNOSTIC = 0xBABECAFEu,
|
||||
REPROGRAMMING = 0xDEADBEEFu,
|
||||
REMOTE_CONTROL = 0xB16B00B5u,
|
||||
DAQ = 0x0B00B135u,
|
||||
};
|
||||
|
||||
// response = Encrypt(challenge). seed = challenge word, cypher = session key.
|
||||
inline uint32_t encrypt(uint32_t seed, uint32_t cypher) {
|
||||
uint32_t num = cypher;
|
||||
uint32_t sum = 0x9E3779B9u; // TEA golden-ratio delta
|
||||
for (int rounds = 32;;) {
|
||||
seed += ((num << 4) + 1131376761u) ^ (num + sum) ^ ((num >> 5) + 1919510376u);
|
||||
if (--rounds <= 0) break;
|
||||
num += ((seed << 4) + 1948272964u) ^ (seed + sum) ^ ((seed >> 5) + 1400073827u);
|
||||
sum += 0x9E3779B9u;
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
inline uint32_t remote_control_response(uint32_t challenge) {
|
||||
return encrypt(challenge, REMOTE_CONTROL);
|
||||
}
|
||||
|
||||
// 4 big-endian challenge bytes (as they arrive in the page-42 payload, after the
|
||||
// "00 04" prefix) -> 4 big-endian response bytes (for the page-43 reply).
|
||||
inline void remote_control_response_bytes(const uint8_t challenge[4], uint8_t response[4]) {
|
||||
uint32_t c = (uint32_t)challenge[0] << 24 | (uint32_t)challenge[1] << 16 |
|
||||
(uint32_t)challenge[2] << 8 | (uint32_t)challenge[3];
|
||||
uint32_t r = remote_control_response(c);
|
||||
response[0] = (uint8_t)(r >> 24);
|
||||
response[1] = (uint8_t)(r >> 16);
|
||||
response[2] = (uint8_t)(r >> 8);
|
||||
response[3] = (uint8_t)(r);
|
||||
}
|
||||
|
||||
} // namespace ids_can_auth
|
||||
@@ -0,0 +1,593 @@
|
||||
# OneControl IDS-CAN node
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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 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
|
||||
rx_pin: GPIO4
|
||||
|
||||
esphome:
|
||||
name: ${name}
|
||||
friendly_name: ${friendly_name}
|
||||
# Command authentication (REMOTE_CONTROL session, key 0xB16B00B5) provides
|
||||
# ids_can_auth::remote_control_response_bytes(); command_guard.h provides
|
||||
# onecontrol::command_blocked() — the slides/jacks-are-panel-only safety rule.
|
||||
includes:
|
||||
- ids_can_auth.h
|
||||
- command_guard.h
|
||||
|
||||
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 # global DEBUG so entity "Sending state" publishes and
|
||||
# the command exchange (idscan) are visible during
|
||||
# bring-up. Drop the whole thing to INFO once happy.
|
||||
logs:
|
||||
canbus: INFO # the esp32_can component logs EVERY received frame at
|
||||
# DEBUG (~50/s) — that floods the 115200 serial link and
|
||||
# starves wifi/api/sensor logs. Silence it; our decode
|
||||
# is what we care about, not the raw component dump.
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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: can_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
bit_rate: 250kbps # IDS-CAN is 250k
|
||||
can_id: 0 # our own TX id (only matters when we send)
|
||||
use_extended_id: true # commands use 29-bit IDs
|
||||
# This ESPHome build filters on_frame by frame type, so a single trigger only
|
||||
# ever fires for one kind. We need BOTH: read broadcasts (tanks/lights/heater/
|
||||
# awning) are 11-bit *standard* frames; the command challenge + battery
|
||||
# telemetry are 29-bit *extended* frames. Two triggers, one shared lambda
|
||||
# (YAML anchor) — the lambda branches on the id itself, so it's correct for
|
||||
# either frame type.
|
||||
on_frame:
|
||||
- can_id: 0
|
||||
can_id_mask: 0 # accept every frame, dispatch in the lambda
|
||||
use_extended_id: true
|
||||
then: &decode_frame
|
||||
- lambda: |-
|
||||
// `can_id` and `x` (data bytes) are provided by the trigger.
|
||||
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) {
|
||||
// HARD SAFETY GATE (authoritative). This is the only place a
|
||||
// command is ever transmitted, so the slides/jacks-are-panel-
|
||||
// only rule is enforced here, right before TX, regardless of how
|
||||
// the command was queued. If blocked: send nothing — no
|
||||
// response, no opcode — and clear the pending command.
|
||||
uint8_t n = id(g_cmd_node);
|
||||
if (onecontrol::command_blocked(n, id(g_node_type)[n])) {
|
||||
ESP_LOGE("idscan", "SAFETY: refusing command to motor node %02X "
|
||||
"(slides/jacks are control-panel only)", n);
|
||||
id(g_cmd_pending) = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Track each node's device class from its page-2 identity broadcast
|
||||
// (x[3] = type byte). The safety gate uses this to refuse any
|
||||
// motor-class (0x21) node that isn't the awning.
|
||||
if (page == 2 && x.size() >= 4) {
|
||||
id(g_node_type)[node] = x[3];
|
||||
}
|
||||
|
||||
// Frame dump — confirmed both frame types decode (2026-06-12), now
|
||||
// silenced: at DEBUG it logs every frame (~50/s across both
|
||||
// triggers) and saturates the 115200 serial link. Uncomment to
|
||||
// re-map the bus.
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
// water heater (node 95): b0 bit0 = on, bit5 (0x20) = DSI/gas
|
||||
// ignition fault/lockout (healthy: 0x80 off / 0x81 running;
|
||||
// fault: 0xA0). Confirmed by a forced lockout 2026-06-12.
|
||||
// x[3] bit7 = burner ACTUALLY FIRING (vs merely enabled): idle
|
||||
// reads ...00 01, a live burn reads ...00 9X with the low nibble
|
||||
// climbing as it heats (captured 2026-06-11). The switch state
|
||||
// above is "enabled"; wh_heating is "actually making heat".
|
||||
case 0x95: id(water_heater).publish_state(x[0] & 0x01);
|
||||
id(dsi_fault).publish_state(x[0] & 0x20);
|
||||
if (x.size() >= 4) id(wh_heating).publish_state(x[3] & 0x80);
|
||||
break;
|
||||
// furnace (node 89): propane forced-air, THERMOSTAT-controlled
|
||||
// (not a Lippert load) — on the bus only to report DSI state, in
|
||||
// the same type-0x1E encoding as the water heater. b0 bit0 =
|
||||
// running, bit5 (0x20) = DSI ignition lockout. A lockout also
|
||||
// trips the bus-wide page-0 fault flag (system_fault). Only idle
|
||||
// 0x80 was captured (summer bench); 0x81 running / 0xA0 fault are
|
||||
// inferred by parallel to node 95 — confirm on the first real
|
||||
// burn cycle (or a forced lockout, propane off).
|
||||
case 0x89: id(furnace_running).publish_state(x[0] & 0x01);
|
||||
id(furnace_dsi_fault).publish_state(x[0] & 0x20); break;
|
||||
// awning H-bridge (node 75): b0 C0 idle / C2 extending (opening)
|
||||
// / C3 retracting (closing); b2-3 (BE) = motor current (raw
|
||||
// counts, ~<1550 running incl. inrush, ~4200 at the fully-closed
|
||||
// stall — captured 2026-07-01). Reflect motion onto the cover and
|
||||
// publish current; while an auto-retract session is active, watch
|
||||
// for the stall spike and cut the stream (see interval + script).
|
||||
case 0x75: {
|
||||
uint16_t cur = (x.size() >= 4) ? (uint16_t)(((uint16_t)x[2] << 8) | x[3]) : 0;
|
||||
if (x.size() >= 4) id(awning_current).publish_state(cur);
|
||||
|
||||
if (x[0] == 0xC2) id(awning).current_operation = esphome::cover::COVER_OPERATION_OPENING;
|
||||
else if (x[0] == 0xC3) id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING;
|
||||
else id(awning).current_operation = esphome::cover::COVER_OPERATION_IDLE;
|
||||
|
||||
// Stall gate: only while auto-retracting (C3), after the inrush
|
||||
// blanking window. cur>2500 for 3 consecutive frames (~150ms @
|
||||
// 20Hz) = fully closed → clear the session flag (interval stops
|
||||
// streaming → motor halts) and mark the cover CLOSED (the one
|
||||
// direction we get a real end-stop, so it's no longer assumed).
|
||||
if (x[0] == 0xC3) id(g_awn_last_c3_ms) = millis();
|
||||
if (id(g_awn_active) && x[0] == 0xC3 && x.size() >= 4) {
|
||||
if (millis() - id(g_awn_start_ms) > 1200) {
|
||||
if (cur > 2500) {
|
||||
if (++id(g_awn_stall_count) >= 3) {
|
||||
id(g_awn_active) = false;
|
||||
id(awning).position = esphome::cover::COVER_CLOSED;
|
||||
ESP_LOGI("awning", "auto-retract: stall %u -> stop (CLOSED)", cur);
|
||||
}
|
||||
} else {
|
||||
id(g_awn_stall_count) = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
id(awning).publish_state();
|
||||
break;
|
||||
}
|
||||
// (water pump 61 is command-blocked — read-only, not exposed.)
|
||||
}
|
||||
}
|
||||
|
||||
// page 0 b0 bit0 = a bus-wide "system fault present" flag (every
|
||||
// node mirrors it; healthy 0x02, fault 0x03). Read it from one node
|
||||
// (the heater) so we publish a single source, not 14 duplicates.
|
||||
if (page == 0 && node == 0x95 && x.size() >= 1) {
|
||||
id(system_fault).publish_state(x[0] & 0x01);
|
||||
}
|
||||
|
||||
// Battery voltage rides 29-bit telemetry frames (src 7D/AE, page
|
||||
// 0x11), payload 00 2B 0D 4x <rolling>; b2..b3 (BE) / 256 = volts.
|
||||
// The low byte of the 29-bit id is the page (0x11); the "00 2B"
|
||||
// prefix gates out any other page-0x11 traffic. Match on signature
|
||||
// rather than the exact source id so either telemetry module
|
||||
// (7D->FC or AE->01) feeds the same sensor. id > 0x7FF restricts the
|
||||
// match to 29-bit frames: this lambda also runs for 11-bit broadcasts
|
||||
// (second trigger), where the low byte is a NODE address — a node
|
||||
// 0x11 would otherwise spoof the battery reading.
|
||||
if (id > 0x7FFu && (id & 0xFFu) == 0x11u && x.size() >= 4 && x[0] == 0x00 && x[1] == 0x2B) {
|
||||
float volts = ((uint16_t) x[2] << 8 | x[3]) / 256.0f;
|
||||
id(battery_voltage).publish_state(volts);
|
||||
}
|
||||
# Second trigger: 11-bit standard frames (the read broadcasts). Same lambda.
|
||||
- can_id: 0
|
||||
can_id_mask: 0
|
||||
use_extended_id: false
|
||||
then: *decode_frame
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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'
|
||||
# Device class per node, learned from page-2 identity broadcasts (0 = not yet
|
||||
# seen). Feeds the motor-output safety gate in command_guard.h.
|
||||
- id: g_node_type
|
||||
type: 'std::array<uint8_t, 256>'
|
||||
# Name the type in the initializer: a bare '{}' is ambiguous between the
|
||||
# GlobalsComponent<T>(T) and (std::array<...>) constructors under the current
|
||||
# toolchain. zero-initialized -> every node starts as type 0 (not yet seen).
|
||||
initial_value: 'std::array<uint8_t, 256>{}'
|
||||
# --- Awning auto-retract session state -----------------------------------
|
||||
# Set true by awning_auto_retract; the 100ms interval streams the retract
|
||||
# opcode + page-44 keepalive while true, and the page-3 stall gate (or the
|
||||
# timeout) clears it. Cleared → streaming stops → motor halts within ~1s
|
||||
# (hold-to-run), so this bool is the master kill-switch for awning motion.
|
||||
- id: g_awn_active
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
- id: g_awn_start_ms
|
||||
type: uint32_t
|
||||
initial_value: '0'
|
||||
- id: g_awn_stall_count
|
||||
type: uint8_t
|
||||
initial_value: '0'
|
||||
- id: g_awn_ka_tick
|
||||
type: uint8_t
|
||||
initial_value: '0'
|
||||
# millis() of the last observed retracting (C3) frame — lets the interval end
|
||||
# the session ~1s after motion actually stops (whether the stall gate cut it,
|
||||
# the OEM controller cut at its own limit, or streaming failed to sustain).
|
||||
- id: g_awn_last_c3_ms
|
||||
type: uint32_t
|
||||
initial_value: '0'
|
||||
|
||||
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, layer 1 — motor-output gate. Slides/jacks are control-panel
|
||||
// only; refuse any motor node that isn't the awning. Same rule the
|
||||
// transmit point enforces (command_guard.h), checked here too so a
|
||||
// blocked command never even starts the exchange.
|
||||
if (onecontrol::command_blocked((uint8_t) node, id(g_node_type)[(uint8_t) node])) {
|
||||
ESP_LOGE("idscan", "SAFETY: refusing command to motor node %02X "
|
||||
"(slides/jacks are control-panel only)", (uint8_t) node);
|
||||
id(g_cmd_pending) = false;
|
||||
return;
|
||||
}
|
||||
// SAFETY, layer 2 — exposed-entity allowlist. Nodes wired to entities:
|
||||
// 2A ext lights, F8 int lights, 95 water heater, 75 awning (cover). Do
|
||||
// NOT add slide/jack/pump nodes — the gate above refuses them regardless.
|
||||
if (node != 0x2A && node != 0xF8 && node != 0x95 && node != 0x75) {
|
||||
ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X", 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 ~150 ms for the challenge; retry up to 8x
|
||||
# (~1.2 s window) to ride out an occasional dropped arm on the busy bus.
|
||||
# NOTE (confirmed live 2026-06-12): the module imposes a ~2 s cooldown after
|
||||
# a SUCCESSFUL session — it won't issue a new challenge during it, so a
|
||||
# second command to the same load <~2 s later is dropped regardless of opcode
|
||||
# (this is module-side, not a bug; normal HA toggles are spaced far enough).
|
||||
# The on_frame handler clears g_cmd_pending the moment it answers, so a
|
||||
# successful exchange short-circuits the remaining iterations.
|
||||
- repeat:
|
||||
count: 8
|
||||
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: 150ms
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(g_cmd_pending);'
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGW("idscan", "no page-42 challenge from node %02X after 8 tries; command dropped",
|
||||
id(g_cmd_node));
|
||||
id(g_cmd_pending) = false;
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Awning auto-retract — retract to the fully-closed hard stop, then stop by
|
||||
# sensing the motor-current stall spike (no position feedback on the bus).
|
||||
#
|
||||
# Movement is HOLD-TO-RUN: a single authenticated opcode runs the motor only
|
||||
# ~1s. The OEM app sustains motion by streaming the opcode (~110ms) plus a
|
||||
# page-44 keepalive (~510ms) after ONE auth (captured 2026-06-11). We do the
|
||||
# same: authenticate once via send_load_command, then the 100ms `interval`
|
||||
# below streams opcode+keepalive while g_awn_active. The page-3 stall gate
|
||||
# (case 0x75) clears g_awn_active at the closed stop; the interval's 70s
|
||||
# timeout is the backstop. Stop streaming = motor stops within ~1s, so this
|
||||
# fails safe on crash/Wi-Fi loss/stop-press.
|
||||
- id: awning_auto_retract
|
||||
mode: single # ignore re-press while a retract is already running
|
||||
then:
|
||||
- lambda: |-
|
||||
id(g_awn_stall_count) = 0;
|
||||
id(g_awn_ka_tick) = 0;
|
||||
id(g_awn_start_ms) = millis();
|
||||
id(g_awn_last_c3_ms) = millis();
|
||||
id(g_awn_active) = true;
|
||||
id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING;
|
||||
id(awning).publish_state();
|
||||
# Authenticate + start motion via the proven single-shot path; the
|
||||
# interval then keeps it moving until the stall gate or timeout fires.
|
||||
- script.execute: { id: send_load_command, node: 0x75, op: 2 }
|
||||
|
||||
- id: awning_stop
|
||||
then:
|
||||
- lambda: 'id(g_awn_active) = false;' # halt the stream (motor stops ~1s)
|
||||
- script.execute: { id: send_load_command, node: 0x75, op: 0 }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Awning motion streamer — while g_awn_active, refresh the hold-to-run opcode
|
||||
# every 100ms and the page-44 keepalive every ~500ms; enforce the safety
|
||||
# timeout. Idle (g_awn_active false) → returns immediately, transmits nothing.
|
||||
# ---------------------------------------------------------------------------
|
||||
interval:
|
||||
- interval: 100ms
|
||||
then:
|
||||
- lambda: |-
|
||||
if (!id(g_awn_active)) return;
|
||||
if (millis() - id(g_awn_start_ms) > 70000) {
|
||||
id(g_awn_active) = false;
|
||||
ESP_LOGW("awning", "auto-retract: 70s timeout -> stop");
|
||||
return;
|
||||
}
|
||||
// motion-lost: once past the auth/startup window, if no retracting (C3)
|
||||
// frame has arrived for >1s the motor isn't moving (stall gate/OEM
|
||||
// cutoff/failed-to-sustain) — end the session instead of streaming on.
|
||||
if (millis() - id(g_awn_start_ms) > 2500 &&
|
||||
millis() - id(g_awn_last_c3_ms) > 1000) {
|
||||
id(g_awn_active) = false;
|
||||
ESP_LOGI("awning", "auto-retract: motion ended -> stop");
|
||||
return;
|
||||
}
|
||||
// stream the retract opcode (0006 75 02, DLC 0) — refreshes hold-to-run
|
||||
uint32_t op_id = 0x00060000u | (0x75u << 8) | 0x02u;
|
||||
std::vector<uint8_t> op_empty; // DLC 0
|
||||
id(can_bus).send_data(op_id, true, op_empty);
|
||||
// page-44 keepalive (0004 75 44, payload 00 04) every ~500ms
|
||||
if (++id(g_awn_ka_tick) >= 5) {
|
||||
id(g_awn_ka_tick) = 0;
|
||||
uint32_t ka_id = 0x00040044u | (0x75u << 8);
|
||||
std::vector<uint8_t> ka = {0x00, 0x04};
|
||||
id(can_bus).send_data(ka_id, true, ka);
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# The raw value jitters at 1/256 V every telemetry frame; unfiltered, that's
|
||||
# constant state churn into both HA recorders + retained MQTT over the WG
|
||||
# tunnel. Pass real moves (>0.05 V) immediately, else at most one per minute.
|
||||
filters:
|
||||
- or:
|
||||
- delta: 0.05
|
||||
- throttle: 60s
|
||||
- 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
|
||||
- platform: template
|
||||
name: "Awning Motor Current"
|
||||
id: awning_current
|
||||
# Raw node-75 page-3 b2-3 counts (not amps): ~<1550 running incl. inrush,
|
||||
# ramps to a ~4200 plateau at the fully-closed hard stop. Only updates while
|
||||
# the motor moves (20Hz); holds last value at rest. Feeds the stall gate.
|
||||
unit_of_measurement: "raw"
|
||||
accuracy_decimals: 0
|
||||
state_class: measurement
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fault indicators (published by the dispatcher above)
|
||||
# ---------------------------------------------------------------------------
|
||||
binary_sensor:
|
||||
- platform: template
|
||||
name: "Water Heater DSI Fault"
|
||||
id: dsi_fault
|
||||
device_class: problem # node 95 page-3 b0 bit5 — gas ignition lockout
|
||||
- platform: template
|
||||
name: "Water Heater Heating"
|
||||
id: wh_heating
|
||||
device_class: running # node 95 page-3 x[3] bit7 — burner actively firing
|
||||
- platform: template
|
||||
name: "Furnace Running"
|
||||
id: furnace_running
|
||||
device_class: running # node 89 page-3 b0 bit0 — thermostat-driven burn
|
||||
- platform: template
|
||||
name: "Furnace DSI Fault"
|
||||
id: furnace_dsi_fault
|
||||
device_class: problem # node 89 page-3 b0 bit5 — gas ignition lockout
|
||||
- platform: template
|
||||
name: "OneControl System Fault"
|
||||
id: system_fault
|
||||
device_class: problem # page-0 b0 bit0 — bus-wide "a fault exists"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
#
|
||||
# Exposed switched loads: exterior lights (2A), interior lights (F8), water
|
||||
# heater (95). Each must be in the layer-2 allowlist in send_load_command.
|
||||
#
|
||||
# SLIDES, JACKS, and the WATER PUMP ARE PANEL/APP ONLY — never over CAN. Hard
|
||||
# policy in command_guard.h, enforced both here and at the transmit point, so even
|
||||
# adding a switch for one below cannot actuate it. The awning is permitted by the
|
||||
# gate but needs a proper cover entity + an attended first test before it's wired.
|
||||
# optimistic:false: the page-3 (b0 bit0) read-back above publishes true module
|
||||
# state (verified live 2026-06-12), so HA shows ground truth — a dropped command
|
||||
# self-corrects within ~1 s instead of an optimistic echo falsely reporting success.
|
||||
# ---------------------------------------------------------------------------
|
||||
# restore_mode: DISABLED on all three is load-bearing: ESPHome's default
|
||||
# restore "applies" the boot state BY EXECUTING THE SWITCH ACTION, which sent
|
||||
# a real authenticated OFF to the water heater on the 2026-06-12 OTA reboot
|
||||
# (send_load_command is mode:restart, so the last switch in setup order — the
|
||||
# heater — won the race). Boot must send nothing: the broadcasts repopulate
|
||||
# every state within ~1 s and are the only source of truth.
|
||||
switch:
|
||||
- platform: template
|
||||
name: "Exterior Lights"
|
||||
id: exterior_lights
|
||||
optimistic: false
|
||||
restore_mode: DISABLED
|
||||
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: false
|
||||
restore_mode: DISABLED
|
||||
turn_on_action:
|
||||
- script.execute: { id: send_load_command, node: 0xF8, op: 1 }
|
||||
turn_off_action:
|
||||
- script.execute: { id: send_load_command, node: 0xF8, op: 0 }
|
||||
|
||||
- platform: template
|
||||
name: "Water Heater"
|
||||
id: water_heater
|
||||
optimistic: false
|
||||
restore_mode: DISABLED
|
||||
turn_on_action:
|
||||
- script.execute: { id: send_load_command, node: 0x95, op: 1 }
|
||||
turn_off_action:
|
||||
- script.execute: { id: send_load_command, node: 0x95, op: 0 }
|
||||
|
||||
# Slides/jacks/pump are command-blocked and must never be wired here.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Awning (node 75) — open / close / stop cover.
|
||||
#
|
||||
# H-bridge motor: open = extend (op 01), close = retract (op 02), stop (op 00),
|
||||
# per the captures. assumed_state -> HA shows discrete open/close/stop controls
|
||||
# (no position slider); current_operation is published from the page-3 motion
|
||||
# byte in the dispatcher above.
|
||||
#
|
||||
# Motion is HOLD-TO-RUN (confirmed 2026-06-11 capture + 2026-07-01 live): one
|
||||
# opcode runs the motor ~1s. So:
|
||||
# CLOSE = auto-retract — stream to the fully-closed stall, stop on current
|
||||
# spike (awning_auto_retract script + interval + stall gate). This is
|
||||
# the one direction with a real end-stop, so it also marks CLOSED.
|
||||
# STOP = abort any active retract (clears g_awn_active) + send stop opcode.
|
||||
# NO open_action ON PURPOSE: a single opcode only jogs the motor ~1s (useless),
|
||||
# and there's no position/end-stop feedback to safely auto-extend on a timer.
|
||||
# Extend the awning at the OEM wall switch. Omitting open_action drops SUPPORT_OPEN
|
||||
# so HA shows only close/stop. assumed_state -> both stay always-pressable (no slider).
|
||||
# ---------------------------------------------------------------------------
|
||||
cover:
|
||||
- platform: template
|
||||
name: "Awning"
|
||||
id: awning
|
||||
device_class: awning
|
||||
assumed_state: true
|
||||
close_action:
|
||||
- script.execute: awning_auto_retract
|
||||
stop_action:
|
||||
- script.execute: awning_stop
|
||||
@@ -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"
|
||||
@@ -0,0 +1,201 @@
|
||||
# Campsite presence / occupancy package.
|
||||
#
|
||||
# Occupancy truth: the gas water heater — always on while camping, always
|
||||
# turned off when leaving (propane). WiFi presence (MikroTik Chateau
|
||||
# device_trackers) is the second, internet-independent signal; until that
|
||||
# integration is set up the wifi sensor reads `unavailable` and everything
|
||||
# downstream treats it as "no veto" (failsafes key on GPS, dead-man stays
|
||||
# quiet).
|
||||
#
|
||||
# Deploy to Pi: /config/packages/campsite_presence.yaml
|
||||
# Repo copy: canbus/ha/campsite_presence.yaml
|
||||
#
|
||||
# ⚠️ FIRST DEPLOY: `template:` may be a new domain on this instance — full
|
||||
# core restart, then confirm the sensors exist (see dsi_fault_alert.yaml for
|
||||
# the reload_all trap).
|
||||
|
||||
template:
|
||||
- binary_sensor:
|
||||
- name: "RV Occupied"
|
||||
unique_id: campsite_rv_occupied
|
||||
device_class: occupancy
|
||||
icon: mdi:campfire
|
||||
# unavailable/unknown heater (node down) -> sensor unavailable, not a
|
||||
# false "left": availability gates it.
|
||||
availability: >-
|
||||
{{ states('switch.onecontrol_can_water_heater')
|
||||
not in ['unavailable', 'unknown'] }}
|
||||
state: "{{ is_state('switch.onecontrol_can_water_heater', 'on') }}"
|
||||
|
||||
- name: "Phones On Campsite WiFi"
|
||||
unique_id: campsite_phones_on_wifi
|
||||
device_class: presence
|
||||
icon: mdi:wifi-marker
|
||||
# MikroTik trackers (renamed to these ids after the integration is
|
||||
# added). While they don't exist the sensor is unavailable — that is
|
||||
# load-bearing: the dead-man automation below triggers on a sustained
|
||||
# 'off', which unavailable never satisfies.
|
||||
availability: >-
|
||||
{{ states('device_tracker.wes_phone_wifi')
|
||||
not in ['unavailable', 'unknown']
|
||||
or states('device_tracker.lindsey_iphone_wifi')
|
||||
not in ['unavailable', 'unknown'] }}
|
||||
state: >-
|
||||
{{ is_state('device_tracker.wes_phone_wifi', 'home')
|
||||
or is_state('device_tracker.lindsey_iphone_wifi', 'home') }}
|
||||
|
||||
automation:
|
||||
# --- Bridge both presence sensors home (discovery + state) -------------
|
||||
- id: campsite_presence_mqtt_discovery
|
||||
alias: "Presence: Publish MQTT Discovery"
|
||||
triggers:
|
||||
- trigger: homeassistant
|
||||
event: start
|
||||
- trigger: event
|
||||
event_type: automation_reloaded
|
||||
- trigger: time_pattern
|
||||
hours: "/6"
|
||||
actions:
|
||||
- delay: "00:00:07"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/binary_sensor/campsite/rv_occupied/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "RV Occupied",
|
||||
"unique_id": "campsite_rv_occupied",
|
||||
"state_topic": "campsite/binary_sensor/rv_occupied/state",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"device_class": "occupancy",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/binary_sensor/campsite/phones_on_campsite_wifi/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Phones On Campsite WiFi",
|
||||
"unique_id": "campsite_phones_on_wifi",
|
||||
"state_topic": "campsite/binary_sensor/phones_on_campsite_wifi/state",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"payload_available": "online", "payload_not_available": "offline",
|
||||
"availability_topic": "campsite/presence/wifi_availability",
|
||||
"device_class": "presence",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- delay: "00:00:02"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/binary_sensor/rv_occupied/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('binary_sensor.rv_occupied', 'on') else 'OFF' }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/presence/wifi_availability"
|
||||
retain: true
|
||||
payload: >-
|
||||
{{ 'offline' if states('binary_sensor.phones_on_campsite_wifi')
|
||||
in ['unavailable', 'unknown'] else 'online' }}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/binary_sensor/phones_on_campsite_wifi/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('binary_sensor.phones_on_campsite_wifi', 'on') else 'OFF' }}"
|
||||
|
||||
- id: campsite_presence_forward_states
|
||||
alias: "Presence: Forward States"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id:
|
||||
- binary_sensor.rv_occupied
|
||||
- binary_sensor.phones_on_campsite_wifi
|
||||
actions:
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/binary_sensor/{{ trigger.to_state.object_id }}/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if trigger.to_state.state == 'on' else 'OFF' }}"
|
||||
- if:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.to_state.object_id == 'phones_on_campsite_wifi' }}"
|
||||
then:
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/presence/wifi_availability"
|
||||
retain: true
|
||||
payload: >-
|
||||
{{ 'offline' if trigger.to_state.state
|
||||
in ['unavailable', 'unknown'] else 'online' }}
|
||||
|
||||
# --- Local dead-man notify (works with the WG tunnel down) -------------
|
||||
# Heater on but neither phone seen on campsite WiFi for 3 h. Notify-only:
|
||||
# WiFi absence alone is not proof of departure (pool afternoon, dead
|
||||
# battery), so the auto-off lives home-side where GPS can confirm.
|
||||
- id: campsite_deadman_heater_notify
|
||||
alias: "Presence: Dead-man heater notify (local)"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.phones_on_campsite_wifi
|
||||
to: "off"
|
||||
for: "03:00:00"
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: binary_sensor.rv_occupied
|
||||
state: "on"
|
||||
actions:
|
||||
- action: rest_command.discord_server_alerts
|
||||
data:
|
||||
message: >-
|
||||
<@321798967669030912> 🤨 The water heater has been on for 3+ hours
|
||||
with neither of your phones on the campsite WiFi. If you actually
|
||||
left, you forgot the shutdown ritual again — kill the heater from
|
||||
the dashboard. If you're at the pool, carry on.
|
||||
mode: single
|
||||
|
||||
# --- Lot lights (Shelly) — PRE-STAGED, DISABLED until the relay exists --
|
||||
# Enable + fix the entity id once the Shelly is installed in the shed
|
||||
# outlet box. Mirrors the camper exterior-lights sunset/sunrise pattern
|
||||
# (retry because nothing should be trusted fire-and-forget), gated on
|
||||
# occupancy so the lot doesn't light up for an empty site.
|
||||
- id: campsite_lot_lights_sunset
|
||||
alias: "Lot lights on at sunset (occupied only)"
|
||||
initial_state: false
|
||||
triggers:
|
||||
- trigger: sun
|
||||
event: sunset
|
||||
offset: 0
|
||||
conditions:
|
||||
- condition: state
|
||||
entity_id: binary_sensor.rv_occupied
|
||||
state: "on"
|
||||
actions:
|
||||
- repeat:
|
||||
sequence:
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.lot_lights
|
||||
- delay: "00:00:05"
|
||||
until:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ is_state('switch.lot_lights', 'on') or repeat.index >= 3 }}
|
||||
mode: single
|
||||
|
||||
- id: campsite_lot_lights_sunrise
|
||||
alias: "Lot lights off at sunrise"
|
||||
initial_state: false
|
||||
triggers:
|
||||
- trigger: sun
|
||||
event: sunrise
|
||||
offset: 0
|
||||
actions:
|
||||
- repeat:
|
||||
sequence:
|
||||
- action: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.lot_lights
|
||||
- delay: "00:00:05"
|
||||
until:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ is_state('switch.lot_lights', 'off') or repeat.index >= 3 }}
|
||||
mode: single
|
||||
@@ -0,0 +1,126 @@
|
||||
# Water-heater DSI fault → Discord ping in the server-alerts channel.
|
||||
#
|
||||
# The CAN node decodes the heater's DSI lockout (node 95 page-3 b0 bit5 —
|
||||
# see canbus/README.md); when the burner fails to light (usually an empty
|
||||
# propane tank) the module latches the fault and this fires the webhook.
|
||||
#
|
||||
# Deploy to Pi: /config/packages/dsi_fault_alert.yaml
|
||||
# Repo copy: canbus/ha/dsi_fault_alert.yaml
|
||||
#
|
||||
# ⚠️ FIRST DEPLOY NEEDS A FULL CORE RESTART. reload_all only re-reads config
|
||||
# for integrations that are already set up; rest_command was a brand-new domain
|
||||
# on this instance, so until the restart the service didn't exist and every
|
||||
# automation fire died with "Action rest_command.discord_server_alerts not
|
||||
# found" — while check_config said valid. Verify with:
|
||||
# curl .../api/services | jq '[.[].domain] | index("rest_command")'
|
||||
# Secret: discord_server_alerts_webhook in the Pi's /config/secrets.yaml
|
||||
# (NOT the Gatus webhook — that one posts somewhere Wes doesn't
|
||||
# read; this one is confirmed delivering to the alerts channel)
|
||||
|
||||
rest_command:
|
||||
discord_server_alerts:
|
||||
url: !secret discord_server_alerts_webhook
|
||||
method: POST
|
||||
content_type: "application/json; charset=utf-8"
|
||||
payload: >-
|
||||
{"username": "Octavia",
|
||||
"content": {{ message | tojson }},
|
||||
"allowed_mentions": {"users": ["321798967669030912"]}}
|
||||
|
||||
automation:
|
||||
# --- Octavia relay: anything on the broker can speak through the one
|
||||
# verified webhook path. Home HA's failsafe automations publish here
|
||||
# (campsite/octavia/say, payload = the message) since home has no
|
||||
# file/packages access for its own rest_command. ---
|
||||
- id: campsite_octavia_relay
|
||||
alias: "Octavia: MQTT relay"
|
||||
triggers:
|
||||
- trigger: mqtt
|
||||
topic: "campsite/octavia/say"
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.payload | length > 0 and trigger.payload | length < 1900 }}"
|
||||
actions:
|
||||
- action: rest_command.discord_server_alerts
|
||||
data:
|
||||
message: "{{ trigger.payload }}"
|
||||
mode: queued
|
||||
max: 5
|
||||
|
||||
- id: camper_dsi_fault_discord
|
||||
alias: "Camper: DSI fault → Discord"
|
||||
description: >-
|
||||
10s 'for' debounces a single glitched broadcast frame; a real lockout
|
||||
is latched by the module so it easily survives the wait.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.onecontrol_can_water_heater_dsi_fault
|
||||
to: "on"
|
||||
for: "00:00:10"
|
||||
actions:
|
||||
- action: rest_command.discord_server_alerts
|
||||
data:
|
||||
message: >-
|
||||
<@321798967669030912> 🔥 **WATER HEATER DSI FAULT** — hey dumbass,
|
||||
you're out of propane again. (Or the igniter finally died, but let's be honest,
|
||||
it's the propane.) The heater is in lockout — swap the tank, then
|
||||
cycle the heater off/on to relight.
|
||||
mode: single
|
||||
|
||||
- id: camper_dsi_fault_cleared_discord
|
||||
alias: "Camper: DSI fault cleared → Discord"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.onecontrol_can_water_heater_dsi_fault
|
||||
from: "on"
|
||||
to: "off"
|
||||
for: "00:00:10"
|
||||
actions:
|
||||
- action: rest_command.discord_server_alerts
|
||||
data:
|
||||
message: >-
|
||||
✅ DSI fault cleared — the water heater is lighting again. Crisis
|
||||
averted. Try to remember this feeling next time you eyeball the
|
||||
tank gauge and say "eh, it's fine."
|
||||
mode: single
|
||||
|
||||
# --- Furnace DSI fault (node 89). Same lockout scheme as the water heater
|
||||
# (page-3 b0 bit5); see canbus/README.md. A furnace lockout means no forced-air
|
||||
# heat — more urgent than the water heater on a cold night. ---
|
||||
- id: camper_furnace_dsi_fault_discord
|
||||
alias: "Camper: Furnace DSI fault → Discord"
|
||||
description: >-
|
||||
10s 'for' debounces a single glitched broadcast frame; a real lockout
|
||||
is latched by the module so it easily survives the wait.
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.onecontrol_can_furnace_dsi_fault
|
||||
to: "on"
|
||||
for: "00:00:10"
|
||||
actions:
|
||||
- action: rest_command.discord_server_alerts
|
||||
data:
|
||||
message: >-
|
||||
<@321798967669030912> 🥶 **FURNACE DSI FAULT** — the furnace tried to
|
||||
light and gave up. That means no heat. Same usual suspect: you're out
|
||||
of propane (or the igniter died, but c'mon). It's in lockout — swap the
|
||||
tank, then cycle the thermostat to relight before everything inside
|
||||
hits ambient.
|
||||
mode: single
|
||||
|
||||
- id: camper_furnace_dsi_fault_cleared_discord
|
||||
alias: "Camper: Furnace DSI fault cleared → Discord"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: binary_sensor.onecontrol_can_furnace_dsi_fault
|
||||
from: "on"
|
||||
to: "off"
|
||||
for: "00:00:10"
|
||||
actions:
|
||||
- action: rest_command.discord_server_alerts
|
||||
data:
|
||||
message: >-
|
||||
✅ Furnace fault cleared — it's lighting again and making heat. Enjoy
|
||||
the warmth, and maybe glance at the propane gauge once in a while like
|
||||
a functioning adult.
|
||||
mode: single
|
||||
@@ -0,0 +1,425 @@
|
||||
# Campsite MQTT Bridge — publishes the OneControl CAN entities to home
|
||||
# Mosquitto (cyrion, 192.168.88.69) via MQTT Discovery so they auto-appear
|
||||
# on the home HA (192.168.88.40) as device "Campsite OneControl".
|
||||
#
|
||||
# Source side is the ESPHome IDS-CAN node ("OneControl CAN", entities
|
||||
# *.onecontrol_can_*) — NOT the retired BLE integration.
|
||||
# No water pump and no slide covers on purpose: those are panel-only
|
||||
# (see canbus/HANDOFF.md safety notes).
|
||||
#
|
||||
# Deploy to Pi: /config/packages/mqtt_bridge.yaml
|
||||
# Repo copy: canbus/ha/mqtt_bridge_onecontrol.yaml
|
||||
|
||||
automation:
|
||||
# --- MQTT Discovery + state republish on startup / reload / every 15 min ---
|
||||
# The 15-min cadence (was 6h) heals stale retained switch states when an
|
||||
# on-change forward publish is lost during a roof-AP/MQTT link flap — the
|
||||
# weak-link failure that left home showing Exterior off while the camper
|
||||
# was on (2026-06-14). Config republishes are idempotent (HA dedupes).
|
||||
- id: campsite_mqtt_discovery
|
||||
alias: "MQTT Bridge: Publish Discovery"
|
||||
triggers:
|
||||
- trigger: homeassistant
|
||||
event: start
|
||||
- trigger: event
|
||||
event_type: automation_reloaded
|
||||
- trigger: time_pattern
|
||||
minutes: "/15"
|
||||
actions:
|
||||
- delay: "00:00:05"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/onecontrol/availability"
|
||||
retain: true
|
||||
payload: >-
|
||||
{{ 'offline' if states('sensor.onecontrol_can_battery_voltage')
|
||||
in ['unavailable', 'unknown'] else 'online' }}
|
||||
# Switches
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/switch/campsite/gas_water_heater/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Water Heater",
|
||||
"unique_id": "campsite_gas_water_heater",
|
||||
"state_topic": "campsite/switch/water_heater/state",
|
||||
"command_topic": "campsite/switch/water_heater/set",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"state_on": "ON", "state_off": "OFF",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"],
|
||||
"name": "Campsite OneControl",
|
||||
"manufacturer": "Lippert",
|
||||
"model": "Chateau 22QB (IDS-CAN)"}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/switch/campsite/exterior_lights/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Exterior Lights",
|
||||
"unique_id": "campsite_exterior_lights",
|
||||
"state_topic": "campsite/switch/exterior_lights/state",
|
||||
"command_topic": "campsite/switch/exterior_lights/set",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"state_on": "ON", "state_off": "OFF",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/switch/campsite/interior_lights/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Interior Lights",
|
||||
"unique_id": "campsite_interior_lights",
|
||||
"state_topic": "campsite/switch/interior_lights/state",
|
||||
"command_topic": "campsite/switch/interior_lights/set",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"state_on": "ON", "state_off": "OFF",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
# Sensors
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/sensor/campsite/battery_voltage/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Battery Voltage",
|
||||
"unique_id": "campsite_battery_voltage",
|
||||
"state_topic": "campsite/sensor/battery_voltage/state",
|
||||
"unit_of_measurement": "V",
|
||||
"device_class": "voltage",
|
||||
"state_class": "measurement",
|
||||
"suggested_display_precision": 2,
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/sensor/campsite/fresh_water_tank/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Fresh Water Tank",
|
||||
"unique_id": "campsite_fresh_water_tank",
|
||||
"state_topic": "campsite/sensor/fresh_water_tank/state",
|
||||
"unit_of_measurement": "%",
|
||||
"state_class": "measurement",
|
||||
"icon": "mdi:water",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/sensor/campsite/grey_tank_1/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Grey Tank 1",
|
||||
"unique_id": "campsite_grey_tank_1",
|
||||
"state_topic": "campsite/sensor/grey_tank_1/state",
|
||||
"unit_of_measurement": "%",
|
||||
"state_class": "measurement",
|
||||
"icon": "mdi:water-opacity",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/sensor/campsite/grey_tank_2/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Grey Tank 2",
|
||||
"unique_id": "campsite_grey_tank_2",
|
||||
"state_topic": "campsite/sensor/grey_tank_2/state",
|
||||
"unit_of_measurement": "%",
|
||||
"state_class": "measurement",
|
||||
"icon": "mdi:water-opacity",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/sensor/campsite/black_tank/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Black Tank",
|
||||
"unique_id": "campsite_black_tank",
|
||||
"state_topic": "campsite/sensor/black_tank/state",
|
||||
"unit_of_measurement": "%",
|
||||
"state_class": "measurement",
|
||||
"icon": "mdi:water-alert",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
# Fault binary sensors
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/binary_sensor/campsite/water_heater_dsi_fault/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Water Heater DSI Fault",
|
||||
"unique_id": "campsite_water_heater_dsi_fault",
|
||||
"state_topic": "campsite/binary_sensor/water_heater_dsi_fault/state",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"device_class": "problem",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/binary_sensor/campsite/onecontrol_system_fault/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "System Fault",
|
||||
"unique_id": "campsite_onecontrol_system_fault",
|
||||
"state_topic": "campsite/binary_sensor/onecontrol_system_fault/state",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"device_class": "problem",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/binary_sensor/campsite/furnace_dsi_fault/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Furnace DSI Fault",
|
||||
"unique_id": "campsite_furnace_dsi_fault",
|
||||
"state_topic": "campsite/binary_sensor/furnace_dsi_fault/state",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"device_class": "problem",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
# Running/heating status binary sensors
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/binary_sensor/campsite/water_heater_heating/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Water Heater Heating",
|
||||
"unique_id": "campsite_water_heater_heating",
|
||||
"state_topic": "campsite/binary_sensor/water_heater_heating/state",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"device_class": "running",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/binary_sensor/campsite/furnace_running/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Furnace Running",
|
||||
"unique_id": "campsite_furnace_running",
|
||||
"state_topic": "campsite/binary_sensor/furnace_running/state",
|
||||
"payload_on": "ON", "payload_off": "OFF",
|
||||
"device_class": "running",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
# Awning
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "homeassistant/cover/campsite/awning/config"
|
||||
retain: true
|
||||
payload: >-
|
||||
{"name": "Awning",
|
||||
"unique_id": "campsite_awning",
|
||||
"state_topic": "campsite/cover/awning/state",
|
||||
"command_topic": "campsite/cover/awning/set",
|
||||
"optimistic": true,
|
||||
"payload_open": null,
|
||||
"device_class": "awning",
|
||||
"availability_topic": "campsite/onecontrol/availability",
|
||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||
# Publish current states immediately after discovery
|
||||
- delay: "00:00:02"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/switch/water_heater/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('switch.onecontrol_can_water_heater', 'on') else 'OFF' }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/switch/exterior_lights/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('switch.onecontrol_can_exterior_lights', 'on') else 'OFF' }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/switch/interior_lights/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('switch.onecontrol_can_interior_lights', 'on') else 'OFF' }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/sensor/battery_voltage/state"
|
||||
retain: true
|
||||
payload: "{{ states('sensor.onecontrol_can_battery_voltage') }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/sensor/fresh_water_tank/state"
|
||||
retain: true
|
||||
payload: "{{ states('sensor.onecontrol_can_fresh_water_tank') }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/sensor/grey_tank_1/state"
|
||||
retain: true
|
||||
payload: "{{ states('sensor.onecontrol_can_grey_tank_1') }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/sensor/grey_tank_2/state"
|
||||
retain: true
|
||||
payload: "{{ states('sensor.onecontrol_can_grey_tank_2') }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/sensor/black_tank/state"
|
||||
retain: true
|
||||
payload: "{{ states('sensor.onecontrol_can_black_tank') }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/binary_sensor/water_heater_dsi_fault/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_water_heater_dsi_fault', 'on') else 'OFF' }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/binary_sensor/onecontrol_system_fault/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_onecontrol_system_fault', 'on') else 'OFF' }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/binary_sensor/furnace_dsi_fault/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_furnace_dsi_fault', 'on') else 'OFF' }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/binary_sensor/water_heater_heating/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_water_heater_heating', 'on') else 'OFF' }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/binary_sensor/furnace_running/state"
|
||||
retain: true
|
||||
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_furnace_running', 'on') else 'OFF' }}"
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/cover/awning/state"
|
||||
retain: true
|
||||
payload: "{{ states('cover.onecontrol_can_awning') }}"
|
||||
|
||||
# --- Availability tracking (CAN node up/down) ---
|
||||
- id: campsite_forward_availability
|
||||
alias: "MQTT Bridge: Forward Availability"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: sensor.onecontrol_can_battery_voltage
|
||||
to: "unavailable"
|
||||
- trigger: state
|
||||
entity_id: sensor.onecontrol_can_battery_voltage
|
||||
from: "unavailable"
|
||||
actions:
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/onecontrol/availability"
|
||||
retain: true
|
||||
payload: >-
|
||||
{{ 'offline' if trigger.to_state.state == 'unavailable' else 'online' }}
|
||||
|
||||
# --- State change forwarding ---
|
||||
- id: campsite_forward_switch_states
|
||||
alias: "MQTT Bridge: Forward Switch States"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id:
|
||||
- switch.onecontrol_can_water_heater
|
||||
- switch.onecontrol_can_exterior_lights
|
||||
- switch.onecontrol_can_interior_lights
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.to_state.state in ['on', 'off'] }}"
|
||||
actions:
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/switch/{{ trigger.to_state.object_id | replace('onecontrol_can_', '') }}/state"
|
||||
retain: true
|
||||
payload: "{{ trigger.to_state.state | upper }}"
|
||||
|
||||
- id: campsite_forward_sensor_states
|
||||
alias: "MQTT Bridge: Forward Sensor States"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id:
|
||||
- sensor.onecontrol_can_battery_voltage
|
||||
- sensor.onecontrol_can_fresh_water_tank
|
||||
- sensor.onecontrol_can_grey_tank_1
|
||||
- sensor.onecontrol_can_grey_tank_2
|
||||
- sensor.onecontrol_can_black_tank
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}"
|
||||
actions:
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/sensor/{{ trigger.to_state.object_id | replace('onecontrol_can_', '') }}/state"
|
||||
retain: true
|
||||
payload: "{{ trigger.to_state.state }}"
|
||||
|
||||
- id: campsite_forward_binary_sensor_states
|
||||
alias: "MQTT Bridge: Forward Fault States"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id:
|
||||
- binary_sensor.onecontrol_can_water_heater_dsi_fault
|
||||
- binary_sensor.onecontrol_can_onecontrol_system_fault
|
||||
- binary_sensor.onecontrol_can_furnace_dsi_fault
|
||||
- binary_sensor.onecontrol_can_water_heater_heating
|
||||
- binary_sensor.onecontrol_can_furnace_running
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.to_state.state in ['on', 'off'] }}"
|
||||
actions:
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/binary_sensor/{{ trigger.to_state.object_id | replace('onecontrol_can_', '') }}/state"
|
||||
retain: true
|
||||
payload: "{{ trigger.to_state.state | upper }}"
|
||||
|
||||
- id: campsite_forward_cover_states
|
||||
alias: "MQTT Bridge: Forward Cover States"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id:
|
||||
- cover.onecontrol_can_awning
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}"
|
||||
actions:
|
||||
- action: mqtt.publish
|
||||
data:
|
||||
topic: "campsite/cover/{{ trigger.to_state.object_id | replace('onecontrol_can_', '') }}/state"
|
||||
retain: true
|
||||
payload: "{{ trigger.to_state.state }}"
|
||||
|
||||
# --- Incoming command handling ---
|
||||
# Explicit allowlists — the wildcard subscription must never widen the bridge
|
||||
# beyond the 3 switches + awning it intends to expose. A future debug/test
|
||||
# entity on the ESPHome node must NOT become remotely controllable for free
|
||||
# (the firmware command_guard is the backstop, not the only gate).
|
||||
- id: campsite_handle_switch_commands
|
||||
alias: "MQTT Bridge: Handle Switch Commands"
|
||||
triggers:
|
||||
- trigger: mqtt
|
||||
topic: "campsite/switch/+/set"
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ trigger.topic.split('/')[2] in
|
||||
['water_heater', 'exterior_lights', 'interior_lights'] }}
|
||||
- condition: template
|
||||
value_template: "{{ trigger.payload | lower in ['on', 'off'] }}"
|
||||
actions:
|
||||
- action: "switch.turn_{{ trigger.payload | lower }}"
|
||||
target:
|
||||
entity_id: "switch.onecontrol_can_{{ trigger.topic.split('/')[2] }}"
|
||||
|
||||
- id: campsite_handle_cover_commands
|
||||
alias: "MQTT Bridge: Handle Cover Commands"
|
||||
triggers:
|
||||
- trigger: mqtt
|
||||
topic: "campsite/cover/+/set"
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.topic.split('/')[2] == 'awning' }}"
|
||||
- condition: template
|
||||
value_template: "{{ trigger.payload | lower in ['open', 'close', 'stop'] }}"
|
||||
actions:
|
||||
- action: "cover.{{ trigger.payload | lower }}_cover"
|
||||
target:
|
||||
entity_id: "cover.onecontrol_can_{{ trigger.topic.split('/')[2] }}"
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""IDS-CAN command authentication — challenge/response for the CAN command path.
|
||||
|
||||
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). 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. 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 session for on/off/move commands.
|
||||
|
||||
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 (01=on,00=off,02=retract)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
MASK = 0xFFFFFFFF
|
||||
DELTA = 0x9E3779B9 # 2654435769 — the TEA golden-ratio delta
|
||||
|
||||
# Per-session key constants ("Cypher")
|
||||
SESSION_CYPHER = {
|
||||
"MANUFACTURING": 0xB16BA115,
|
||||
"DIAGNOSTIC": 0xBABECAFE,
|
||||
"REPROGRAMMING": 0xDEADBEEF,
|
||||
"REMOTE_CONTROL": 0xB16B00B5,
|
||||
"DAQ": 0x0B00B135,
|
||||
}
|
||||
|
||||
|
||||
def encrypt(seed: int, cypher: int) -> int:
|
||||
"""32-round TEA-family Feistel. seed=challenge, cypher=session key."""
|
||||
num = cypher & MASK
|
||||
seed &= MASK
|
||||
sum_ = DELTA
|
||||
rounds = 32
|
||||
while True:
|
||||
seed = (seed + ((((num << 4) & MASK) + 1131376761)
|
||||
^ ((num + sum_) & MASK)
|
||||
^ (((num >> 5) + 1919510376) & MASK))) & MASK
|
||||
rounds -= 1
|
||||
if rounds <= 0:
|
||||
break
|
||||
num = (num + ((((seed << 4) & MASK) + 1948272964)
|
||||
^ ((seed + sum_) & MASK)
|
||||
^ (((seed >> 5) + 1400073827) & MASK))) & MASK
|
||||
sum_ = (sum_ + DELTA) & MASK
|
||||
return seed
|
||||
|
||||
|
||||
def remote_control_response(challenge: int) -> int:
|
||||
"""Response uint for a REMOTE_CONTROL (on/off/move) command challenge."""
|
||||
return encrypt(challenge, SESSION_CYPHER["REMOTE_CONTROL"])
|
||||
|
||||
|
||||
def response_bytes(challenge: bytes, session: str = "REMOTE_CONTROL") -> bytes:
|
||||
"""4 challenge bytes (big-endian, as on the wire) -> 4 response bytes."""
|
||||
if len(challenge) != 4:
|
||||
raise ValueError("challenge must be 4 bytes")
|
||||
r = encrypt(int.from_bytes(challenge, "big"), SESSION_CYPHER[session])
|
||||
return r.to_bytes(4, "big")
|
||||
|
||||
|
||||
def _selftest() -> int:
|
||||
import os
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
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):
|
||||
continue
|
||||
for ln in open(path):
|
||||
ln = ln.strip()
|
||||
if not ln or ln.startswith("#"):
|
||||
continue
|
||||
tok = ln.split()
|
||||
c, r = int(tok[-2], 16), int(tok[-1], 16)
|
||||
total += 1
|
||||
if remote_control_response(c) != r:
|
||||
bad += 1
|
||||
print(f" MISS {c:08X} -> got {remote_control_response(c):08X}, want {r:08X}")
|
||||
print(f"self-test: {total - bad}/{total} pairs verified"
|
||||
f" — {'PASS' if bad == 0 and total else 'FAIL'}")
|
||||
return 0 if bad == 0 and total else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) == 2: # one-shot: compute response for a hex challenge
|
||||
ch = int(sys.argv[1], 16)
|
||||
print(f"{remote_control_response(ch):08X}")
|
||||
else:
|
||||
sys.exit(_selftest())
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Operate an IDS-CAN switched load over socketcan, with the auth exchange.
|
||||
|
||||
Acts as controller node 01: request a challenge -> read the module's fresh
|
||||
challenge -> return remote_control_response(challenge) -> send the opcode x3.
|
||||
Pure stdlib (raw AF_CAN socket); needs can0 already up (slcand).
|
||||
|
||||
Usage: idscan_cmd.py <node_hex> <on|off> e.g. idscan_cmd.py F8 on
|
||||
"""
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
from ids_can_auth import remote_control_response
|
||||
|
||||
CAN_EFF_FLAG = 0x80000000
|
||||
FMT = "=IB3x8s" # can_id, can_dlc, pad/res, data[8]
|
||||
ARM_PAGE, RESP_PAGE, CHAL_PAGE = 0x42, 0x43, 0x42
|
||||
|
||||
|
||||
def eff(i):
|
||||
return i | CAN_EFF_FLAG
|
||||
|
||||
|
||||
def pack(can_id, data=b""):
|
||||
return struct.pack(FMT, eff(can_id), len(data), data.ljust(8, b"\x00"))
|
||||
|
||||
|
||||
def unpack(frame):
|
||||
cid, dlc, data = struct.unpack(FMT, frame)
|
||||
is_eff = bool(cid & CAN_EFF_FLAG)
|
||||
return is_eff, cid & 0x1FFFFFFF, dlc, data[:dlc]
|
||||
|
||||
|
||||
def status_id(node): # 11-bit page-3 live-value broadcast
|
||||
return (3 << 8) | node
|
||||
|
||||
|
||||
def read_status(s, node, timeout=2.0):
|
||||
want = status_id(node)
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
s.settimeout(max(0.01, end - time.time()))
|
||||
try:
|
||||
is_eff, cid, dlc, data = unpack(s.recv(16))
|
||||
except socket.timeout:
|
||||
break
|
||||
if not is_eff and cid == want:
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def actuate(node, op):
|
||||
arm_id = (0x01 << 18) | (0 << 16) | (node << 8) | ARM_PAGE # 01->node p42
|
||||
resp_id = (0x01 << 18) | (0 << 16) | (node << 8) | RESP_PAGE # 01->node p43
|
||||
chal_id = (node << 18) | (1 << 16) | (0x01 << 8) | CHAL_PAGE # node->01 p42
|
||||
opcode_id = (0x0006 << 16) | (node << 8) | op # 0006<node><op>
|
||||
|
||||
s = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW)
|
||||
s.bind(("can0",))
|
||||
|
||||
before = read_status(s, node, 1.5)
|
||||
print(f" before: page3 = {before.hex(' ') if before else '(none)'}")
|
||||
|
||||
s.send(pack(arm_id, b"\x00\x04")) # arm
|
||||
# await the module's fresh challenge
|
||||
chal = None
|
||||
s.settimeout(0.3)
|
||||
end = time.time() + 0.3
|
||||
while time.time() < end:
|
||||
try:
|
||||
is_eff, cid, dlc, data = unpack(s.recv(16))
|
||||
except socket.timeout:
|
||||
break
|
||||
if is_eff and cid == chal_id and dlc == 6 and data[:2] == b"\x00\x04":
|
||||
chal = data[2:6]
|
||||
break
|
||||
if chal is None:
|
||||
print(" !! no challenge from module — aborting (load not actuated)")
|
||||
return False
|
||||
|
||||
ch_int = int.from_bytes(chal, "big")
|
||||
rr = remote_control_response(ch_int)
|
||||
print(f" challenge {ch_int:08X} -> response {rr:08X}")
|
||||
s.send(pack(resp_id, b"\x00\x04" + rr.to_bytes(4, "big"))) # response
|
||||
time.sleep(0.004)
|
||||
for _ in range(3): # opcode x3
|
||||
s.send(pack(opcode_id))
|
||||
time.sleep(0.006)
|
||||
|
||||
time.sleep(0.3)
|
||||
after = read_status(s, node, 1.5)
|
||||
print(f" after: page3 = {after.hex(' ') if after else '(none)'}")
|
||||
s.close()
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3 or sys.argv[2] not in ("on", "off"):
|
||||
sys.exit(__doc__)
|
||||
node = int(sys.argv[1], 16)
|
||||
op = 0x01 if sys.argv[2] == "on" else 0x00
|
||||
print(f"node {node:02X} -> {sys.argv[2]}")
|
||||
actuate(node, op)
|
||||
Reference in New Issue
Block a user