Compare commits

..
24 Commits
Author SHA1 Message Date
wesandClaude Opus 4.8 0c6721953a docs(awning): spec for standalone WiFi reed "closed" sensor (Option 1)
ESP32-C3 + weatherproof reed on OmnissiahsReach WiFi (no Zigbee mesh) for true
stowed/not-stowed state: BOM, wiring, ESPHome config, HA/bridge integration,
optional truthful template cover, and the Option-2 (wire-to-CAN-node) note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:24:51 -04:00
wesandClaude Opus 4.8 a56f550636 awning: current-sensing auto-retract + true CLOSED state; drop useless OPEN jog
Motion is hold-to-run (one authed opcode runs the motor ~1s). Reproduce the OEM
movement session -- auth once, then stream the opcode @110ms + a page-44
keepalive @510ms -- to sustain continuous retract, and watch node-75 page-3
motor current at 20Hz to stop at the fully-closed stall (~4200 vs ~<1550
running), then mark the cover CLOSED. Backstops: 70s timeout, motion-lost
detector, hold-to-run stop-on-silence. Proven live at the camper 2026-07-01.

- esphome: awning_auto_retract script + 100ms streamer interval + case-0x75
  stall gate + "Awning Motor Current" sensor. Cover CLOSE=auto-retract,
  STOP=abort. Removed open_action (1s jog is useless, no safe timed auto-open).
- bridge: optimistic:true keeps the home HA cover assumed-state so retract is
  always pressable (was greyed when closed); payload_open:null drops OPEN on the
  home (primary) dashboard. Also synced the 6h->15min discovery-cadence drift.
- captures + README: full-retract stall profile and the live auto-retract test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:18:15 -04:00
wesandClaude Opus 4.8 6733a79390 canbus: furnace + water-heater status sensors (running + DSI fault)
Decode node 89 (furnace) read-only: Furnace Running (page-3 b0 bit0) and
Furnace DSI Fault (b0 bit5), same type-0x1E DSI encoding as the water
heater. The furnace is thermostat-controlled, not a Lippert load — it
sits on the bus only to report ignition state. Captures only ever showed
0x80 (off), so the 0x81 running / 0xA0 fault bytes are inferred by
parallel to node 95; confirm on the first real burn.

Add Water Heater Heating (node 95 page-3 x[3] bit7 = burner actively
firing) — distinct from the on/off switch, which only reflects "enabled".

Carry all three through the MQTT bridge to home HA, add the furnace
DSI-fault Octavia alert mirroring the water heater, and update the README
node map (89 was "unknown switched load"). Campsite + home Camper
dashboards updated live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:19:46 -04:00
wesandClaude Fable 5 5b9198878c canbus: WiFi presence live — Wes's phone tracked, DHCP+ARP mode
Registration-table mode missed roof-AP clients (same SSID, bridged);
switched the mikrotik integration to force_dhcp + arp_ping so both APs
are covered. wes_phone_wifi verified end-to-end (tracker home → Pi
sensor on → bridged home on). Lindsey's tracker pending her next
association.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:12:37 -04:00
wesandClaude Fable 5 38c4ba485d canbus: MikroTik WiFi presence — router + integration wired
Chateau API bound to the Pi only, read-only homeassistant user,
mikrotik integration live. Phone trackers get enabled+renamed on first
association (documented in HANDOFF).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:05:05 -04:00
wesandClaude Fable 5 2dd884af3c canbus: presence layer + departure failsafes
Water heater = occupancy truth. New Pi package campsite_presence.yaml:
rv_occupied + phones_on_campsite_wifi template sensors (wifi one stays
unavailable until the MikroTik integration lands — load-bearing for the
dead-man trigger), MQTT bridging home, a local notify-only dead-man
(heater on, no phones on WiFi 3h), and disabled pre-staged lot-light
automations for the future shed Shelly.

dsi_fault_alert.yaml gains the Octavia MQTT relay (campsite/octavia/say)
so home HA — which has no file access — can speak through the verified
webhook path.

Home-side (UI-managed, not in repo): quick failsafe (both out of the
enlarged 150m campsite zone 25 min → fans/lights off + summary ping) and
heater failsafe (2h → heater off with bridge round-trip confirmation).
Both verified live; fan states snapshot-restored after the test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:56:30 -04:00
wesandClaude Fable 5 257064289c canbus: document the first-deploy restart trap for rest_command
The alert package looked deployed but the rest_command domain was never
set up — reload_all only reloads already-loaded integrations, so all
four "verified" test fires died with Action-not-found while
check_config said valid. Needs one full core restart on first deploy;
header now says so and gives the verification command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:14:15 -04:00
wesandClaude Fable 5 4bf74a6194 canbus: DSI alerts post as "Octavia"
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:09:06 -04:00
wesandClaude Fable 5 63034e17ce canbus: point DSI alert at the right Discord webhook
The Gatus webhook delivered to a channel nobody reads; the secret on the
Pi now holds the confirmed server-alerts webhook (URL stays out of the
repo as before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:08:09 -04:00
wesandClaude Fable 5 9d6999a5bc canbus: DSI alert pings Wes directly instead of @here
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:51:29 -04:00
wesandClaude Fable 5 e179f1f1fd canbus: Discord alert when the water-heater DSI fault trips
New Pi package (repo copy canbus/ha/dsi_fault_alert.yaml): when the CAN
node's DSI-lockout binary sensor holds 'on' for 10s, fire the server-
alerts Discord webhook (@here, appropriately rude), with a cleared
message when it recovers. Webhook URL lives in the Pi's HA secrets.yaml
(!secret discord_server_alerts_webhook), not the repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:49:47 -04:00
wesandClaude Fable 5 9a5ee30db9 canbus: replace BLE integration with CAN node in HA + MQTT bridge home
The campsite HAOS Pi now runs the ESPHome CAN node natively ("OneControl
CAN", *.onecontrol_can_*); the BLE integration is fully torn out (config
entry, custom_components on the Pi, stale MQTT registry orphans) and the
camper dashboard rewritten — water-pump tile dropped on purpose (pump is
panel-only), awning + fault sensors added.

New canbus/ha/mqtt_bridge_onecontrol.yaml (deployed to the Pi as
packages/mqtt_bridge.yaml) bridges the CAN entities to the home broker
via MQTT Discovery, same pattern as the gazebo bridge. Kept the old
unique_ids so home entity ids and recorder history carried over; added
an availability topic and explicit command-topic allowlists (3 switches
+ awning — a future debug entity must not become remotely controllable
for free). Round-trip verified from home HA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:32:12 -04:00
wesandClaude Fable 5 e8b2447d62 canbus: battery decode, dual on_frame triggers, ground-truth switches
Flash-session firmware work, live on the node since 2026-06-12:
- battery voltage decode (29-bit page-0x11 telemetry, b2..b3 BE / 256),
  gated to extended frames so an 11-bit node 0x11 can never spoof it,
  with a delta/throttle filter to stop 1/256-V jitter churning both
  HA recorders and the MQTT bridge
- second on_frame trigger (use_extended_id: false) sharing the decode
  lambda via YAML anchor — this esp32_can build filters triggers by
  frame type, so a single trigger silently dropped the 11-bit reads
- switches optimistic:false now the page-3 read-back is verified live
- arm retry widened to 8x150ms; module-side ~2s post-success cooldown
  documented
- canbus component logs to INFO (per-frame DEBUG dump saturated serial)
- toolchain fixes: named std::array initializer, namespaced cover enums

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:32:12 -04:00
wesandClaude Opus 4.8 81bd58206d canbus: note pigtail wiring colors (green=CAN-L, blue=CAN-H)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:42:21 -04:00
wesandClaude Opus 4.8 cda537da29 canbus: expose water heater + awning; block water pump
Per updated policy:
- Water heater (node 95): new switch entity + on/off state read-back.
- Awning (node 75): new cover entity (open=op01 / close=op02 / stop=op00), with
  current_operation published from the page-3 motion byte (C2/C3/C0). First
  actuation must be attended; single-shot commands can't run the motor away.
- Water pump (node 61): added to command_guard denylist (winterizing-only,
  panel/app only) alongside slides/jacks. Guard re-tested 8/8 (host g++).

Switch/cover comments + HANDOFF safety notes and remaining-work updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:40:54 -04:00
wesandClaude Opus 4.8 f05203b9e3 canbus: hard safety gate — slides/jacks are control-panel only, never over CAN
Per policy, the firmware must never command a slide or jack. command_guard.h adds
command_blocked(node, type): refuses any motor-class (0x21) node except the
awning (0x75), with an explicit slide/jack denylist (6A/7F/9C) that holds even
before a node's page-2 identity is observed.

Enforced in two independent places — the command-entry script (send_load_command)
and the actual transmit point (on_frame, right before TX) — so loosening one
can't open the other. Adding a slide/jack switch entity cannot actuate it. Node
device class is learned from page-2 broadcasts (g_node_type). Predicate
unit-tested 9/9 (host g++). Switch comments + HANDOFF safety notes updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:34:21 -04:00
wesandClaude Opus 4.8 d57c2b21d2 canbus: decode the water-heater DSI fault + add fault binary_sensors
Forced a real lockout (captures/dsi-fault-*.log) and diffed vs the healthy
baseline:
- Water-heater DSI fault = node 95 page-3 b0 bit5 (0x20). Healthy 0x80/0x81,
  fault 0xA0. (Earlier suspects b1=FF and node AE were both wrong.)
- Bus-wide "system fault present" = page-0 b0 bit0 (every node flips 02->03).

Both wired into esphome/onecontrol-canbus.yaml as binary_sensor (device_class:
problem). README + HANDOFF updated; DSI item closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:29:05 -04:00
wesandClaude Opus 4.8 f3073f180f canbus: add Fable session handoff doc (assembly + flash + open items)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:21:35 -04:00
wesandClaude Opus 4.8 742ef49c8a canbus: confirm command path live + frame docs as device integration
Command path proven end to end on the bus (node F8 interior lights, on/off/on),
each answering a distinct fresh challenge; bare opcodes without the exchange are
ignored. ids_can_auth.h verified bit-exact against ids_can_auth.py and the
captured/live pairs.

- idscan_cmd.py: stdlib socketcan tool running the full page-42/43 exchange
- esphome/onecontrol-canbus.yaml: correct IDS-CAN read dispatch (was stale RV-C
  DGN code) + command path wired to the auth header
- README/memory: document the read map + command authentication; rename
  sniff/ -> captures/; neutral device-integration framing throughout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:20:12 -04:00
wesandClaude Fable 5 840cfaf5fc canbus: solve IDS-CAN command-auth cipher; add reference implementations
The CAN write gate (page-42/43 challenge/response) is a 32-round TEA/XTEA-family
Feistel keyed by a per-session 32-bit key; REMOTE_CONTROL = 0xB16B00B5. Verified
51/51 against captured challenge/response pairs across nodes 2A/61/75/F8 (one
global key, not per-node), so the CAN path can now actuate, not just sense.

- ids_can_auth.py         Python reference + self-test (51/51)
- esphome/ids_can_auth.h  C++ port for the ESP32 node (host-tested 8/8)
- sniff/analyze_auth.py   structural analysis (rules out affine; confirms keyed cipher)
- sniff/auth-pairs-multinode-2026-06-11.txt   +9 pairs across 4 nodes
- README                  document the cipher, session keys, unlock sequence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:22:09 -04:00
wesandClaude Fable 5 85455e8631 canbus: add DSI-fault capture plan to open items
Close propane valve, force a water-heater lockout, capture + diff against
healthy baseline. Prime suspects: node 95 b1 (always FF) or node AE page-3
(always 00). Whichever flips becomes the DSI binary_sensor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:26:29 -04:00
wesandClaude Fable 5 b97401fec8 canbus: reverse-engineer OneControl IDS-CAN bus (read fully mapped, write auth-gated)
Tapped the X180T's CAN bus via CANable 2.0 at the monitor panel's terminator
port. The bus is NOT RV-C — it's Lippert's proprietary IDS-CAN (250k, 11-bit
IDs, (page<<8)|node, 1 Hz broadcasts).

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:20:49 -04:00
wesandClaude Opus 4.8 34155fd7f9 Add HA custom integration; fix bleak 3.x write-without-response regression
The OneControl panel's command characteristic is a streaming (Write Without
Response) endpoint. Bleak 3.x changed write_gatt_char to default to
write-with-response when the char advertises the "write" property, so every
command (incl. switch turn_on/off) got rejected by the panel with ATT 0x0E
(Unlikely Error), surfaced as BleakGATTProtocolError. Force response=False on
the command write (matching the auth key write) to restore control.

Also commits the productionized custom_components integration (config flow,
coordinator, switch/sensor/cover entities, key-seed TEA auth, COBS codec) and
the matching src/ RE client/COBS fixes (big-endian framing, table-driven CRC8,
status-event decoding) that were developed but never tracked.

Verified live on the campsite HAOS Pi: switch.exterior_lights / interior_lights
toggle the physical panel with no GATT error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 06:41:14 -04:00
wesandClaude Sonnet 4.6 b6d498c829 Implement key-seed auth handshake (service 0010)
Root cause of immediate disconnect after write: panel requires auth on
service 0010 before accepting commands on 0030. Protocol found by
decompiling Plugin.BLE.dll (BleDeviceUnlockManager.PerformKeySeedExchange).

Auth flow:
1. Read 4-byte seed from char 0012 (00000012-0200-a58e-e411-afe28044e62c)
2. Apply modified TEA with RV-specific cypher (612643285 / 0x248431D5)
3. Write 4-byte little-endian result to char 0013
4. Retries up to 3x if seed < 4 bytes (panel not yet ready)

connect() now calls perform_auth() before enabling 0034 notifications.
Also cleans up experimental auth attempts from campsite debugging session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 19:56:36 -04:00
38 changed files with 56637 additions and 127 deletions
+212
View File
@@ -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 56):
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).
+341
View File
@@ -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** (~0x5000x620 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≈6976 s |
| `F8` | **interior lights** | type 0x1E; toggle test t≈5161 s; **operated live 2026-06-12** ✓ |
| `95` | **water heater** | type 0x1E; toggle test t≈8594 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` ≈ 0x0D4647 → /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 ~350500 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`
+192
View File
@@ -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 (~$1520)
| 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 ≥ 1520 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.
+45
View File
@@ -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
+137
View File
@@ -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
+40
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# ESPHome build cache + real secrets (keep secrets.yaml.example tracked)
/.esphome/
/secrets.yaml
+39
View File
@@ -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
+54
View File
@@ -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
+593
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# Copy to secrets.yaml (gitignored) and fill in. Mirrors the gazebo-fan-proxy
# secrets so the same campsite WiFi + an ESPHome API key are used.
wifi_ssid: "OmnissiahsReach"
wifi_password: "REDACTED"
fallback_ap_password: "REDACTED"
# 32-byte base64 ESPHome API key (generate: `openssl rand -base64 32`)
api_key: "REDACTED"
+201
View File
@@ -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
+126
View File
@@ -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
+425
View File
@@ -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] }}"
+108
View File
@@ -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())
+105
View File
@@ -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)
@@ -0,0 +1,33 @@
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import OneControlCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.COVER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
address = entry.data["address"]
coordinator = OneControlCoordinator(hass, address)
try:
await coordinator.async_connect()
except Exception as e:
_LOGGER.error("Failed to connect to OneControl at %s: %s", address, e)
return False
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
coordinator: OneControlCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.async_disconnect()
return unload_ok
@@ -0,0 +1,149 @@
# cobs_protocol.py
# COBS encoding/decoding and CRC8 implementation for Lippert OneControl
# Based on decompiled source from IDS.Portable.Common.COBS/CobsEncoder.cs and Crc8.cs
class Crc8:
"""CRC8 using the exact lookup table from IDS.Portable.Common/Crc8.cs (init=0x55)."""
_TABLE = [
0, 94, 188, 226, 97, 63, 221, 131, 194, 156,
126, 32, 163, 253, 31, 65, 157, 195, 33, 127,
252, 162, 64, 30, 95, 1, 227, 189, 62, 96,
130, 220, 35, 125, 159, 193, 66, 28, 254, 160,
225, 191, 93, 3, 128, 222, 60, 98, 190, 224,
2, 92, 223, 129, 99, 61, 124, 34, 192, 158,
29, 67, 161, 255, 70, 24, 250, 164, 39, 121,
155, 197, 132, 218, 56, 102, 229, 187, 89, 7,
219, 133, 103, 57, 186, 228, 6, 88, 25, 71,
165, 251, 120, 38, 196, 154, 101, 59, 217, 135,
4, 90, 184, 230, 167, 249, 27, 69, 198, 152,
122, 36, 248, 166, 68, 26, 153, 199, 37, 123,
58, 100, 134, 216, 91, 5, 231, 185, 140, 210,
48, 110, 237, 179, 81, 15, 78, 16, 242, 172,
47, 113, 147, 205, 17, 79, 173, 243, 112, 46,
204, 146, 211, 141, 111, 49, 178, 236, 14, 80,
175, 241, 19, 77, 206, 144, 114, 44, 109, 51,
209, 143, 12, 82, 176, 238, 50, 108, 142, 208,
83, 13, 239, 177, 240, 174, 76, 18, 145, 207,
45, 115, 202, 148, 118, 40, 171, 245, 23, 73,
8, 86, 180, 234, 105, 55, 213, 139, 87, 9,
235, 181, 54, 104, 138, 212, 149, 203, 41, 119,
244, 170, 72, 22, 233, 183, 85, 11, 136, 214,
52, 106, 43, 117, 151, 201, 74, 20, 246, 168,
116, 42, 200, 150, 21, 75, 169, 247, 182, 232,
10, 84, 215, 137, 107, 53,
]
RESET_VALUE = 0x55
@staticmethod
def calculate(data: bytes) -> int:
crc = Crc8.RESET_VALUE
for b in data:
crc = Crc8._TABLE[(crc ^ b) & 0xFF]
return crc
class CobsEncoder:
"""COBS Encoder matching CobsEncoder.cs (numDataBits=6, prependStartFrame=true, useCrc=true).
Code byte format: num_data_bytes + (num_consecutive_zeros * 64)
This differs from standard COBS — zeros are packed into the code byte, not output inline.
"""
LSB = 1 << 6 # 64 (FrameByteCountLsb)
MAX_DATA = LSB - 1 # 63 (MaxDataBytes)
MAX_ZEROS = 255 - MAX_DATA # 192 (MaxCompressedFrameBytes)
def __init__(self):
self.frame_byte = 0x00
def encode(self, source: bytes) -> bytes:
"""Encode source bytes with CRC8 appended, using Lippert COBS variant."""
crc = Crc8.calculate(source)
data = source + bytes([crc])
output = bytearray([self.frame_byte]) # Start frame
i = 0
while i < len(data):
code_index = len(output)
output.append(0) # Placeholder for code byte
num6 = 0
# Collect non-zero data bytes (up to MAX_DATA)
while i < len(data) and num6 < self.MAX_DATA:
b = data[i]
if b == self.frame_byte:
break
output.append(b)
num6 += 1
i += 1
# Collect consecutive zero bytes, packing each as +LSB in the code byte
while i < len(data):
if data[i] != self.frame_byte:
break
num6 += self.LSB
i += 1
if num6 >= self.MAX_ZEROS:
break
output[code_index] = num6
output.append(self.frame_byte) # End frame
return bytes(output)
class CobsDecoder:
"""COBS Decoder — stateful byte-at-a-time algorithm matching CobsDecoder.cs.
Code byte encoding (numDataBits=6):
code_byte = (num_zeros_to_insert << 6) | num_data_bytes_before_trigger
When the lower 6 bits of the running code_byte reach 0, the upper bits
tell how many zero bytes to insert. This differs from standard COBS.
"""
NUM_DATA_BITS = 6
LSB = 1 << NUM_DATA_BITS # 64
MAX_DATA_MASK = LSB - 1 # 63 (0x3F)
def __init__(self):
self.frame_byte = 0x00
def decode(self, encoded: bytes) -> bytes:
"""Decode one complete COBS packet (start frame … end frame)."""
output = bytearray()
code_byte = 0
for b in encoded:
if b == self.frame_byte:
# Frame byte — either start (no data yet) or end (complete packet)
if code_byte != 0 or len(output) == 0:
# Start frame or error: reset state
output.clear()
code_byte = 0
continue
# End frame: strip CRC and return
received_crc = output[-1]
data = output[:-1]
calc_crc = Crc8.calculate(data)
if received_crc != calc_crc:
raise ValueError(f"CRC mismatch: received 0x{received_crc:02x} != calculated 0x{calc_crc:02x}")
return bytes(data)
if code_byte <= 0:
# Code byte: encodes data-count (low 6 bits) + zero-count (high bits)
code_byte = b
else:
# Data byte
code_byte -= 1
output.append(b)
# When lower NUM_DATA_BITS of code_byte hit zero, insert implied zeros
if (code_byte & self.MAX_DATA_MASK) == 0:
while code_byte > 0:
output.append(self.frame_byte)
code_byte -= self.LSB
raise ValueError("Incomplete COBS packet — no end frame received")
@@ -0,0 +1,304 @@
# onecontrol_client.py
# Lippert OneControl BLE Client
# Based on reverse engineered protocol from decompiled Xamarin app
import asyncio
import logging
import struct
from collections.abc import Callable
from bleak import BleakClient
from bleak_retry_connector import establish_connection, BleakClientWithServiceCache
from .cobs_protocol import CobsEncoder, CobsDecoder
from enum import IntEnum, StrEnum
class EventType(StrEnum):
SWITCH = "switch"
TANK = "tank"
BATTERY = "battery"
COVER_RAW = "cover_raw"
_LOGGER = logging.getLogger(__name__)
AUTH_SERVICE = "00000010-0200-a58e-e411-afe28044e62c"
SEED_CHAR = "00000012-0200-a58e-e411-afe28044e62c"
KEY_CHAR = "00000013-0200-a58e-e411-afe28044e62c"
RV_LINK_CYPHER = 612643285
def _tea_encrypt(cypher: int, seed: int) -> int:
M = 0xFFFFFFFF
DELTA = 2654435769
num = DELTA
for _ in range(32):
t = (((cypher << 4) & M) + 1131376761) & M
t ^= (cypher + num) & M
t ^= ((cypher >> 5) + 1919510376) & M
seed = (seed + t) & M
t = (((seed << 4) & M) + 1948272964) & M
t ^= (seed + num) & M
t ^= ((seed >> 5) + 1400073827) & M
cypher = (cypher + t) & M
num = (num + DELTA) & M
return seed
async def _perform_auth(client: BleakClient) -> bool:
for attempt in range(3):
if not client.is_connected:
_LOGGER.warning("Auth: connection lost before attempt %d", attempt + 1)
return False
try:
seed_bytes = await client.read_gatt_char(SEED_CHAR)
except Exception as e:
_LOGGER.warning("Auth: failed to read seed (attempt %d): %s", attempt + 1, e)
if not client.is_connected:
return False
await asyncio.sleep(2)
continue
if seed_bytes.lower() == b"unlocked":
_LOGGER.debug("Auth: already unlocked")
return True
if len(seed_bytes) < 4:
_LOGGER.warning("Auth: seed too short (%d bytes), retrying", len(seed_bytes))
await asyncio.sleep(2)
continue
seed = struct.unpack_from(">I", seed_bytes, 0)[0]
if seed == 0:
_LOGGER.warning("Auth: seed is zero, retrying")
await asyncio.sleep(2)
continue
key = _tea_encrypt(RV_LINK_CYPHER, seed)
_LOGGER.debug("Auth: seed=0x%08x → key=0x%08x", seed, key)
try:
await client.write_gatt_char(KEY_CHAR, struct.pack(">I", key), response=False)
await asyncio.sleep(0.5)
verify = await client.read_gatt_char(SEED_CHAR)
if verify.lower() == b"unlocked":
_LOGGER.debug("Auth: confirmed unlocked")
return True
_LOGGER.warning("Auth: unexpected verify response: %r", verify)
return True # key was accepted even if verify string differs
except Exception as e:
_LOGGER.error("Auth: failed to write key (attempt %d): %s", attempt + 1, e)
return False
_LOGGER.error("Auth: all attempts exhausted")
return False
class CommandType(IntEnum):
GET_DEVICES = 1
ACTION_SWITCH = 64
ACTION_MOVEMENT = 65
ACTION_DIMMABLE = 67
ACTION_RGB = 68
ACTION_HVAC = 69
class SwitchState(IntEnum):
OFF = 0
ON = 1
TOGGLE = 2
class MovementState(IntEnum):
RETRACT = 0
EXTEND = 1
STOP = 2
# State callback type: (event_type: str, data: dict) -> None
# event_type values: "switch", "tank", "battery", "cover"
StateCallback = Callable[[str, dict], None]
class OneControlClient:
SERVICE_UUID = "00000030-0200-A58E-E411-AFE28044E62C"
WRITE_CHAR = "00000033-0200-A58E-E411-AFE28044E62C"
READ_CHAR = "00000034-0200-A58E-E411-AFE28044E62C"
_QUIET_EVENTS = {1, 3, 4, 26, 32}
def __init__(self, address: str, state_callback: StateCallback | None = None,
disconnect_callback: Callable[[], None] | None = None,
ble_device=None):
self.address = address
self._ble_device = ble_device
self._state_cb = state_callback
self._disconnect_cb = disconnect_callback
self.client: BleakClient | None = None
self.encoder = CobsEncoder()
self.decoder = CobsDecoder()
self._seq = 0
self._pending_commands: dict[int, CommandType] = {}
def set_ble_device(self, ble_device) -> None:
"""Update the BLE device reference (from HA scanner)."""
self._ble_device = ble_device
async def connect(self) -> None:
# Panel often drops the first BLE connection immediately.
# Retry the full connect→auth sequence with a fresh client each time.
# Do NOT call pair() — panel uses proprietary TEA auth, not BLE pairing,
# and the pair request may trigger the disconnect.
last_err: Exception | None = None
for attempt in range(5):
try:
if self._ble_device:
self.client = await establish_connection(
BleakClientWithServiceCache,
self._ble_device,
self.address,
disconnected_callback=self._on_disconnect,
max_attempts=2,
)
else:
self.client = BleakClient(self.address, disconnected_callback=self._on_disconnect)
await self.client.connect(timeout=30.0)
_LOGGER.info("BLE connected (attempt %d), starting auth...", attempt + 1)
# Brief settle time — just enough for BlueZ service discovery
await asyncio.sleep(0.5)
if not self.client.is_connected:
_LOGGER.info("Panel dropped connection on attempt %d — retrying", attempt + 1)
await asyncio.sleep(2.0)
continue
if not await _perform_auth(self.client):
if self.client.is_connected:
# Auth truly failed (not just disconnect) — still retry,
# the panel may need a warmup connection first
await self.client.disconnect()
_LOGGER.warning("Auth failed on attempt %d — retrying full sequence", attempt + 1)
await asyncio.sleep(2.0)
continue
await asyncio.sleep(0.5)
await self.client.start_notify(self.READ_CHAR, self._notification_handler)
_LOGGER.info("OneControl connected and authenticated (attempt %d)", attempt + 1)
return
except Exception as e:
last_err = e
_LOGGER.warning("Connect attempt %d failed: %s", attempt + 1, e)
if self.client and self.client.is_connected:
try:
await self.client.disconnect()
except Exception:
pass
await asyncio.sleep(2.0)
raise RuntimeError(f"OneControl: failed to connect after 4 attempts: {last_err}")
def _on_disconnect(self, _client: BleakClient) -> None:
_LOGGER.warning("OneControl: disconnected unexpectedly")
if self._disconnect_cb:
self._disconnect_cb()
async def disconnect(self) -> None:
if self.client and self.client.is_connected:
await self.client.disconnect()
@property
def is_connected(self) -> bool:
return self.client is not None and self.client.is_connected
def _notification_handler(self, sender, data: bytearray) -> None:
try:
decoded = self.decoder.decode(data)
if len(decoded) < 4:
return
event_type = decoded[0]
seq = struct.unpack(">H", decoded[1:3])[0]
resp_type = decoded[3]
payload = decoded[4:]
completed = resp_type >= 128
if event_type == 2:
cmd_type = self._pending_commands.get(seq)
if cmd_type == CommandType.GET_DEVICES and completed:
self._pending_commands.pop(seq, None)
elif cmd_type is not None and completed:
self._pending_commands.pop(seq, None)
else:
self._handle_status_event(event_type, decoded)
except Exception as e:
_LOGGER.debug("OneControl: error decoding notification: %s", e)
def _handle_status_event(self, event_type: int, decoded: bytes) -> None:
if event_type == 7 and len(decoded) >= 6:
v_raw = struct.unpack_from(">H", decoded, 1)[0]
features = decoded[5]
if features & 1 and v_raw != 0xFFFF:
voltage = v_raw / 256.0
self._emit(EventType.BATTERY, {"voltage": voltage})
elif event_type == 12:
i = 2
while i + 1 < len(decoded):
dev_id = decoded[i]
pct = decoded[i + 1]
self._emit(EventType.TANK, {"device_id": dev_id, "pct": pct})
i += 2
elif event_type == 6:
i = 2
while i + 1 < len(decoded):
dev_id = decoded[i]
state_byte = decoded[i + 1]
on = bool(state_byte & 0x01)
self._emit(EventType.SWITCH, {"device_id": dev_id, "on": on})
i += 7
elif event_type == 14:
# RelayHBridgeMomentaryType2 — covers/slides/awnings
# payload structure not fully decoded; pass raw for now
self._emit(EventType.COVER_RAW, {"data": decoded})
elif event_type not in self._QUIET_EVENTS:
_LOGGER.debug("OneControl: unhandled event %d: %s", event_type, decoded.hex())
def _emit(self, event_type: str, data: dict) -> None:
if self._state_cb:
self._state_cb(event_type, data)
def _next_seq(self) -> int:
self._seq = (self._seq + 1) & 0xFFFF
return self._seq
async def send_command(self, cmd_type: CommandType, table_id: int, payload: bytes) -> None:
if not self.is_connected:
raise RuntimeError("Not connected")
seq = self._next_seq()
self._pending_commands[seq] = cmd_type
packet = struct.pack(">HBB", seq, cmd_type, table_id) + payload
encoded = self.encoder.encode(packet)
# Panel's command char is a streaming (Write Without Response) endpoint,
# like the OEM app. Bleak 3.x defaults to write-with-response when the
# char advertises the "write" property, which the panel rejects with
# ATT 0x0E (Unlikely Error). Force write-without-response.
await self.client.write_gatt_char(self.WRITE_CHAR, encoded, response=False)
async def get_devices(self) -> None:
await self.send_command(CommandType.GET_DEVICES, 1, bytes([0, 255]))
async def set_switch(self, device_id: int, on: bool) -> None:
state = SwitchState.ON if on else SwitchState.OFF
await self.send_command(CommandType.ACTION_SWITCH, 1, bytes([state, device_id]))
async def control_movement(self, device_id: int, state: MovementState) -> None:
await self.send_command(CommandType.ACTION_MOVEMENT, 1, bytes([state, device_id]))
async def set_dimmer(self, device_id: int, level: int) -> None:
await self.send_command(CommandType.ACTION_DIMMABLE, 1, bytes([level, device_id]))
@@ -0,0 +1,53 @@
import re
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.data_entry_flow import FlowResult
from .client.onecontrol_client import OneControlClient
from .const import DOMAIN
_MAC_RE = re.compile(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$")
class OneControlConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
address = user_input["address"].strip().upper()
if not _MAC_RE.match(address):
errors["address"] = "invalid_mac"
else:
await self.async_set_unique_id(address)
self._abort_if_unique_id_configured()
ble_device = async_ble_device_from_address(self.hass, address)
connected = False
for _ in range(2):
client = OneControlClient(address, ble_device=ble_device)
try:
await client.connect()
await client.disconnect()
connected = True
break
except Exception:
pass
if not connected:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=f"OneControl ({address})",
data={"address": address},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("address", description={"suggested_value": "30:C6:F7:E0:80:8E"}): str,
}),
errors=errors,
)
@@ -0,0 +1,25 @@
DOMAIN = "lippert_onecontrol"
# DevID → friendly name
SWITCH_DEVICES = {
4: "Water Pump",
5: "Gas Water Heater",
6: "Exterior Lights",
7: "Interior Lights",
}
TANK_DEVICES = {
8: "Grey Tank 2",
9: "Grey Tank 1",
10: "Black Tank",
11: "Fresh Water Tank",
}
COVER_DEVICES = {
2: "Cover 2", # slide or awning — confirm physically before renaming
3: "Cover 3",
}
KEEPALIVE_INTERVAL = 20 # seconds between GetDevices keepalives
MAX_KEEPALIVE_FAILURES = 3 # consecutive failures before forcing reconnect
RECONNECT_DELAYS = [5, 10, 20, 60] # seconds, last value repeats
@@ -0,0 +1,198 @@
import asyncio
import logging
from homeassistant.core import HomeAssistant
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .client.onecontrol_client import EventType, OneControlClient, MovementState
from .const import DOMAIN, RECONNECT_DELAYS, KEEPALIVE_INTERVAL, MAX_KEEPALIVE_FAILURES
_LOGGER = logging.getLogger(__name__)
class OneControlCoordinator(DataUpdateCoordinator):
"""Manages the BLE connection and pushes state to HA entities."""
def __init__(self, hass: HomeAssistant, address: str) -> None:
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=None)
self.address = address
self._switch_states: dict[int, bool] = {}
self._tank_levels: dict[int, int] = {}
self._battery_v: float | None = None
self._client = OneControlClient(
address,
state_callback=self._on_state,
disconnect_callback=self._on_disconnect,
)
self._keepalive_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
async def async_connect(self) -> None:
self._refresh_ble_device()
await self._client.connect()
await self._sync_state()
self._start_keepalive()
async def _sync_state(self) -> None:
"""Force an immediate state poll so HA reflects panel reality."""
try:
await self._client.get_devices()
await asyncio.sleep(1) # give notifications time to arrive
await self._client.get_devices() # second poll for reliability
except Exception as e:
_LOGGER.warning("Initial state sync failed: %s", e)
def _refresh_ble_device(self) -> None:
"""Get the latest BLE device reference from HA's scanner."""
ble_device = async_ble_device_from_address(self.hass, self.address)
if ble_device:
self._client.set_ble_device(ble_device)
else:
_LOGGER.warning("BLE device %s not found by HA scanner — using raw address", self.address)
async def async_disconnect(self) -> None:
self._cancel_tasks()
await self._client.disconnect()
# ------------------------------------------------------------------
# State accessors
# ------------------------------------------------------------------
@property
def is_connected(self) -> bool:
return self._client.is_connected
def get_switch_state(self, device_id: int) -> bool | None:
return self._switch_states.get(device_id)
def get_tank_level(self, device_id: int) -> int | None:
return self._tank_levels.get(device_id)
@property
def battery_voltage(self) -> float | None:
return self._battery_v
# ------------------------------------------------------------------
# Commands
# ------------------------------------------------------------------
async def async_set_switch(self, device_id: int, on: bool) -> None:
await self._client.set_switch(device_id, on)
async def async_control_movement(self, device_id: int, state: MovementState) -> None:
await self._client.control_movement(device_id, state)
# ------------------------------------------------------------------
# State callback (called from BLE notification thread)
# ------------------------------------------------------------------
def _on_state(self, event_type: EventType, data: dict) -> None:
changed = False
if event_type == EventType.SWITCH:
dev_id = data["device_id"]
on = data["on"]
if self._switch_states.get(dev_id) != on:
self._switch_states[dev_id] = on
changed = True
elif event_type == EventType.TANK:
dev_id = data["device_id"]
pct = data["pct"]
if self._tank_levels.get(dev_id) != pct:
self._tank_levels[dev_id] = pct
changed = True
elif event_type == EventType.BATTERY:
v = data["voltage"]
if self._battery_v != v:
self._battery_v = v
changed = True
if changed:
self.hass.loop.call_soon_threadsafe(
lambda: self.hass.async_create_task(self._async_notify())
)
async def _async_notify(self) -> None:
# push-based: we call this manually instead of relying on update_interval
self.async_set_updated_data(None)
# ------------------------------------------------------------------
# Keepalive
# ------------------------------------------------------------------
def _start_keepalive(self) -> None:
self._cancel_tasks()
self._keepalive_task = self.hass.async_create_task(self._keepalive_loop())
async def _keepalive_loop(self) -> None:
consecutive_failures = 0
while True:
await asyncio.sleep(KEEPALIVE_INTERVAL)
if self._client.is_connected:
try:
await self._client.get_devices()
consecutive_failures = 0
except Exception as e:
consecutive_failures += 1
_LOGGER.warning(
"Keepalive failed (%d/%d): %s",
consecutive_failures, MAX_KEEPALIVE_FAILURES, e,
)
if consecutive_failures >= MAX_KEEPALIVE_FAILURES:
_LOGGER.error(
"Keepalive failed %d times — connection is dead, forcing reconnect",
consecutive_failures,
)
try:
await self._client.disconnect()
except Exception:
pass
self._on_disconnect()
return
# ------------------------------------------------------------------
# Reconnect
# ------------------------------------------------------------------
def _on_disconnect(self) -> None:
_LOGGER.warning("OneControl disconnected — scheduling reconnect")
self._cancel_tasks()
self._reconnect_task = self.hass.async_create_task(self._reconnect_loop())
async def _reconnect_loop(self) -> None:
delays = RECONNECT_DELAYS
for i, delay in enumerate(delays):
_LOGGER.info("Reconnecting in %ds (attempt %d)...", delay, i + 1)
await asyncio.sleep(delay)
try:
self._refresh_ble_device()
await self._client.connect()
_LOGGER.info("OneControl reconnected")
self._start_keepalive()
return
except Exception as e:
_LOGGER.warning("Reconnect attempt %d failed: %s", i + 1, e)
# Keep retrying at max interval indefinitely — RV may be off for hours
while True:
await asyncio.sleep(delays[-1])
try:
self._refresh_ble_device()
await self._client.connect()
_LOGGER.info("OneControl reconnected")
self._start_keepalive()
return
except Exception as e:
_LOGGER.warning("Reconnect failed: %s", e)
def _cancel_tasks(self) -> None:
for task in (self._keepalive_task, self._reconnect_task):
if task and not task.done():
task.cancel()
self._keepalive_task = None
self._reconnect_task = None
async def _async_update_data(self):
return None
@@ -0,0 +1,50 @@
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .client.onecontrol_client import MovementState
from .coordinator import OneControlCoordinator
from .const import DOMAIN, COVER_DEVICES
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: OneControlCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
OneControlCover(coordinator, device_id, name)
for device_id, name in COVER_DEVICES.items()
)
class OneControlCover(CoordinatorEntity, CoverEntity):
_attr_device_class = CoverDeviceClass.AWNING
_attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
)
def __init__(self, coordinator: OneControlCoordinator, device_id: int, name: str) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._attr_name = name
self._attr_unique_id = f"{coordinator.address}_cover_{device_id}"
@property
def is_closed(self) -> bool | None:
# State tracking not yet decoded from cover_raw events; return None (unknown)
return None
@property
def available(self) -> bool:
return self.coordinator.is_connected
async def async_open_cover(self, **kwargs) -> None:
await self.coordinator.async_control_movement(self._device_id, MovementState.EXTEND)
async def async_close_cover(self, **kwargs) -> None:
await self.coordinator.async_control_movement(self._device_id, MovementState.RETRACT)
async def async_stop_cover(self, **kwargs) -> None:
await self.coordinator.async_control_movement(self._device_id, MovementState.STOP)
@@ -0,0 +1,9 @@
{
"domain": "lippert_onecontrol",
"name": "Lippert OneControl",
"version": "0.1.0",
"dependencies": ["bluetooth"],
"requirements": [],
"iot_class": "local_push",
"config_flow": true
}
@@ -0,0 +1,60 @@
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfElectricPotential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import OneControlCoordinator
from .const import DOMAIN, TANK_DEVICES
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: OneControlCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[SensorEntity] = [
OneControlTankSensor(coordinator, device_id, name)
for device_id, name in TANK_DEVICES.items()
]
entities.append(OneControlBatterySensor(coordinator))
async_add_entities(entities)
class OneControlTankSensor(CoordinatorEntity, SensorEntity):
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: OneControlCoordinator, device_id: int, name: str) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._attr_name = name
self._attr_unique_id = f"{coordinator.address}_tank_{device_id}"
@property
def native_value(self) -> int | None:
return self.coordinator.get_tank_level(self._device_id)
@property
def available(self) -> bool:
return self.coordinator.is_connected
class OneControlBatterySensor(CoordinatorEntity, SensorEntity):
_attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT
_attr_device_class = SensorDeviceClass.VOLTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_name = "Battery Voltage"
def __init__(self, coordinator: OneControlCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.address}_battery"
@property
def native_value(self) -> float | None:
v = self.coordinator.battery_voltage
return round(v, 2) if v is not None else None
@property
def available(self) -> bool:
return self.coordinator.is_connected
@@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"title": "Lippert OneControl",
"description": "Enter the Bluetooth MAC address of the OneControl panel.",
"data": {
"address": "MAC Address"
}
}
},
"error": {
"cannot_connect": "Cannot connect to device. Verify it is powered on and in range.",
"invalid_mac": "Invalid MAC address format."
},
"abort": {
"already_configured": "This device is already configured."
}
}
}
@@ -0,0 +1,40 @@
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import OneControlCoordinator
from .const import DOMAIN, SWITCH_DEVICES
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
coordinator: OneControlCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
OneControlSwitch(coordinator, device_id, name)
for device_id, name in SWITCH_DEVICES.items()
)
class OneControlSwitch(CoordinatorEntity, SwitchEntity):
def __init__(self, coordinator: OneControlCoordinator, device_id: int, name: str) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._attr_name = name
self._attr_unique_id = f"{coordinator.address}_{device_id}"
@property
def is_on(self) -> bool | None:
return self.coordinator.get_switch_state(self._device_id)
@property
def available(self) -> bool:
return self.coordinator.is_connected
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_set_switch(self._device_id, True)
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_set_switch(self._device_id, False)
+111 -82
View File
@@ -2,121 +2,150 @@
# COBS encoding/decoding and CRC8 implementation for Lippert OneControl
# Based on decompiled source from IDS.Portable.Common.COBS/CobsEncoder.cs and Crc8.cs
from typing import List
class Crc8:
"""CRC8 with init value 0x55 (from Crc8.cs)"""
POLY = 0x07
"""CRC8 using the exact lookup table from IDS.Portable.Common/Crc8.cs (init=0x55)."""
def __init__(self, init: int = 0x55):
self.value = init
_TABLE = [
0, 94, 188, 226, 97, 63, 221, 131, 194, 156,
126, 32, 163, 253, 31, 65, 157, 195, 33, 127,
252, 162, 64, 30, 95, 1, 227, 189, 62, 96,
130, 220, 35, 125, 159, 193, 66, 28, 254, 160,
225, 191, 93, 3, 128, 222, 60, 98, 190, 224,
2, 92, 223, 129, 99, 61, 124, 34, 192, 158,
29, 67, 161, 255, 70, 24, 250, 164, 39, 121,
155, 197, 132, 218, 56, 102, 229, 187, 89, 7,
219, 133, 103, 57, 186, 228, 6, 88, 25, 71,
165, 251, 120, 38, 196, 154, 101, 59, 217, 135,
4, 90, 184, 230, 167, 249, 27, 69, 198, 152,
122, 36, 248, 166, 68, 26, 153, 199, 37, 123,
58, 100, 134, 216, 91, 5, 231, 185, 140, 210,
48, 110, 237, 179, 81, 15, 78, 16, 242, 172,
47, 113, 147, 205, 17, 79, 173, 243, 112, 46,
204, 146, 211, 141, 111, 49, 178, 236, 14, 80,
175, 241, 19, 77, 206, 144, 114, 44, 109, 51,
209, 143, 12, 82, 176, 238, 50, 108, 142, 208,
83, 13, 239, 177, 240, 174, 76, 18, 145, 207,
45, 115, 202, 148, 118, 40, 171, 245, 23, 73,
8, 86, 180, 234, 105, 55, 213, 139, 87, 9,
235, 181, 54, 104, 138, 212, 149, 203, 41, 119,
244, 170, 72, 22, 233, 183, 85, 11, 136, 214,
52, 106, 43, 117, 151, 201, 74, 20, 246, 168,
116, 42, 200, 150, 21, 75, 169, 247, 182, 232,
10, 84, 215, 137, 107, 53,
]
def update(self, byte: int):
self.value ^= byte
for _ in range(8):
if self.value & 0x80:
self.value = ((self.value << 1) ^ self.POLY) & 0xFF
else:
self.value = (self.value << 1) & 0xFF
def update_buffer(self, data: bytes):
for b in data:
self.update(b)
RESET_VALUE = 0x55
@staticmethod
def calculate(data: bytes, init: int = 0x55) -> int:
crc = Crc8(init)
crc.update_buffer(data)
return crc.value
def calculate(data: bytes) -> int:
crc = Crc8.RESET_VALUE
for b in data:
crc = Crc8._TABLE[(crc ^ b) & 0xFF]
return crc
class CobsEncoder:
"""COBS Encoder matching CobsEncoder.cs (numDataBits=6, prependStartFrame=true, useCrc=true).
Code byte format: num_data_bytes + (num_consecutive_zeros * 64)
This differs from standard COBS — zeros are packed into the code byte, not output inline.
"""
COBS Encoder matching DirectConnectionMyRvLinkBle.cs:44
prependStartFrame=true, useCrc=true, frameByte=0, numDataBits=6
"""
LSB = 1 << 6 # 64 (FrameByteCountLsb)
MAX_DATA = LSB - 1 # 63 (MaxDataBytes)
MAX_ZEROS = 255 - MAX_DATA # 192 (MaxCompressedFrameBytes)
def __init__(self):
self.frame_byte = 0x00
self.max_data_bytes = (1 << 6) - 1 # 63 bytes (6-bit)
def encode(self, source: bytes) -> bytes:
"""Encode with CRC8 and COBS"""
if not source:
return bytes([self.frame_byte])
# 1. Calculate CRC8
"""Encode source bytes with CRC8 appended, using Lippert COBS variant."""
crc = Crc8.calculate(source)
data_with_crc = source + bytes([crc])
# 2. COBS encode
output = bytearray([self.frame_byte]) # Prepend start frame
data = source + bytes([crc])
output = bytearray([self.frame_byte]) # Start frame
i = 0
while i < len(data_with_crc):
while i < len(data):
code_index = len(output)
output.append(0xFF) # Placeholder for code byte
output.append(0) # Placeholder for code byte
num6 = 0
count = 0
while i < len(data_with_crc) and count < self.max_data_bytes:
byte = data_with_crc[i]
if byte == self.frame_byte:
# Collect non-zero data bytes (up to MAX_DATA)
while i < len(data) and num6 < self.MAX_DATA:
b = data[i]
if b == self.frame_byte:
break
output.append(byte)
i += 1
count += 1
# Update code byte
output[code_index] = count + 1
# Skip frame bytes
while i < len(data_with_crc) and data_with_crc[i] == self.frame_byte:
output.append(b)
num6 += 1
i += 1
output.append(self.frame_byte) # Append end frame
# Collect consecutive zero bytes, packing each as +LSB in the code byte
while i < len(data):
if data[i] != self.frame_byte:
break
num6 += self.LSB
i += 1
if num6 >= self.MAX_ZEROS:
break
output[code_index] = num6
output.append(self.frame_byte) # End frame
return bytes(output)
class CobsDecoder:
"""COBS Decoder for responses"""
"""COBS Decoder — stateful byte-at-a-time algorithm matching CobsDecoder.cs.
Code byte encoding (numDataBits=6):
code_byte = (num_zeros_to_insert << 6) | num_data_bytes_before_trigger
When the lower 6 bits of the running code_byte reach 0, the upper bits
tell how many zero bytes to insert. This differs from standard COBS.
"""
NUM_DATA_BITS = 6
LSB = 1 << NUM_DATA_BITS # 64
MAX_DATA_MASK = LSB - 1 # 63 (0x3F)
def __init__(self):
self.frame_byte = 0x00
def decode(self, encoded: bytes) -> bytes:
"""Decode COBS packet"""
if not encoded or encoded[0] != self.frame_byte:
raise ValueError("Invalid COBS packet")
"""Decode one complete COBS packet (start frame … end frame)."""
output = bytearray()
i = 1 # Skip start frame
code_byte = 0
while i < len(encoded):
code = encoded[i]
if code == 0:
break
i += 1
count = code - 1
# Copy data bytes
for _ in range(count):
if i >= len(encoded):
break
output.append(encoded[i])
i += 1
# Add frame byte if not at end
if code < 0xFF and i < len(encoded):
output.append(self.frame_byte)
# Verify CRC
if len(output) > 0:
data = output[:-1]
for b in encoded:
if b == self.frame_byte:
# Frame byte — either start (no data yet) or end (complete packet)
if code_byte != 0 or len(output) == 0:
# Start frame or error: reset state
output.clear()
code_byte = 0
continue
# End frame: strip CRC and return
if len(output) < 1:
raise ValueError("Empty COBS packet")
received_crc = output[-1]
calculated_crc = Crc8.calculate(bytes(data))
if received_crc != calculated_crc:
raise ValueError(f"CRC mismatch: {received_crc:02x} != {calculated_crc:02x}")
data = output[:-1]
calc_crc = Crc8.calculate(data)
if received_crc != calc_crc:
raise ValueError(f"CRC mismatch: received 0x{received_crc:02x} != calculated 0x{calc_crc:02x}")
return bytes(data)
return bytes(output[:-1]) # Remove CRC
if code_byte <= 0:
# Code byte: encodes data-count (low 6 bits) + zero-count (high bits)
code_byte = b
else:
# Data byte
code_byte -= 1
output.append(b)
# When lower NUM_DATA_BITS of code_byte hit zero, insert implied zeros
if (code_byte & self.MAX_DATA_MASK) == 0:
while code_byte > 0:
output.append(self.frame_byte)
code_byte -= self.LSB
raise ValueError("Incomplete COBS packet — no end frame received")
+220 -44
View File
@@ -9,6 +9,91 @@ from cobs_protocol import CobsEncoder, CobsDecoder
from enum import IntEnum
# ---------------------------------------------------------------------------
# Auth / Key-Seed Exchange (service 0010)
# Reverse-engineered from Plugin.BLE.dll :: BleDeviceUnlockManager
# ---------------------------------------------------------------------------
AUTH_SERVICE = "00000010-0200-a58e-e411-afe28044e62c"
SEED_CHAR = "00000012-0200-a58e-e411-afe28044e62c" # read seed from here
KEY_CHAR = "00000013-0200-a58e-e411-afe28044e62c" # write response here
RV_LINK_CYPHER = 612643285 # MyRvLinkBleGatewayScanResult.RvLinkKeySeedCypher
def _tea_encrypt(cypher: int, seed: int) -> int:
"""Modified TEA used by Lippert OneControl for BLE auth.
C# uint arithmetic truncates to 32 bits at each step — every intermediate
shift/add must be masked independently, not just the final result."""
M = 0xFFFFFFFF
DELTA = 2654435769 # 0x9E3779B9
num = DELTA
for _ in range(32):
t = (((cypher << 4) & M) + 1131376761) & M
t ^= (cypher + num) & M
t ^= ((cypher >> 5) + 1919510376) & M
seed = (seed + t) & M
t = (((seed << 4) & M) + 1948272964) & M
t ^= (seed + num) & M
t ^= ((seed >> 5) + 1400073827) & M
cypher = (cypher + t) & M
num = (num + DELTA) & M
return seed
async def perform_auth(client: BleakClient) -> bool:
"""
Perform key-seed exchange on service 0010 before any commands on 0030.
Returns True on success, False on failure.
"""
for attempt in range(3):
try:
seed_bytes = await client.read_gatt_char(SEED_CHAR)
except Exception as e:
print(f"Auth: failed to read seed (attempt {attempt+1}): {e}")
await asyncio.sleep(5)
continue
# If device returns "unlocked" it's already authenticated
if len(seed_bytes) == len(b"unlocked") and seed_bytes.lower() == b"unlocked":
print("Auth: already unlocked")
return True
if len(seed_bytes) < 4:
print(f"Auth: seed too short ({len(seed_bytes)} bytes), retrying...")
await asyncio.sleep(5)
continue
seed = struct.unpack_from(">I", seed_bytes, 0)[0] # big-endian: Endian.Big=0
if seed == 0:
print("Auth: seed is zero, retrying...")
await asyncio.sleep(5)
continue
key = _tea_encrypt(RV_LINK_CYPHER, seed)
key_bytes = struct.pack(">I", key) # big-endian: Endian.Big=0
print(f"Auth: seed=0x{seed:08x} → key=0x{key:08x}")
try:
await client.write_gatt_char(KEY_CHAR, key_bytes, response=False)
await asyncio.sleep(0.5)
# Verify: 0012 should now return b"unlocked"
verify = await client.read_gatt_char(SEED_CHAR)
print(f"Auth: verify read = {verify!r} ({verify.hex()})")
if verify.lower() == b"unlocked":
print("Auth: confirmed unlocked!")
else:
print("Auth: key written but 'unlocked' not confirmed — may still work")
return True
except Exception as e:
print(f"Auth: failed to write key (attempt {attempt+1}): {e}")
return False
print("Auth: all attempts exhausted")
return False
class CommandType(IntEnum):
GET_DEVICES = 1
ACTION_SWITCH = 64
@@ -44,11 +129,18 @@ class OneControlClient:
self._pending_commands = {} # seq -> cmd_type
async def connect(self):
"""Connect to OneControl device"""
self.client = BleakClient(self.address)
await self.client.connect()
"""Connect to OneControl device and perform key-seed auth handshake."""
self.client = BleakClient(self.address, disconnected_callback=self._on_disconnect)
await self.client.connect(timeout=20.0)
if not await perform_auth(self.client):
await self.client.disconnect()
raise RuntimeError("Auth handshake failed")
await asyncio.sleep(1.0) # Give panel a moment after auth before commands
await self.client.start_notify(self.READ_CHAR, self._notification_handler)
def _on_disconnect(self, _client: BleakClient):
print("[!] Device disconnected unexpectedly")
async def disconnect(self):
"""Disconnect from device"""
if self.client and self.client.is_connected:
@@ -58,55 +150,151 @@ class OneControlClient:
"""Handle notifications from device"""
try:
decoded = self.decoder.decode(data)
# print(f"Received raw: {decoded.hex()}")
# Response Structure: [Event(1)] [Seq(2)] [RespType(1)] [Payload...]
if len(decoded) < 4:
return
event_type = decoded[0]
seq = struct.unpack("<H", decoded[1:3])[0]
seq = struct.unpack(">H", decoded[1:3])[0]
resp_type = decoded[3]
payload = decoded[4:]
# resp_type: 1=SuccessMultiple, 129=SuccessCompleted, 2=FailureMultiple, 130=FailureCompleted
completed = resp_type >= 128
# 2 = DeviceCommand response
if event_type == 2:
cmd_type = self._pending_commands.get(seq)
if cmd_type:
if cmd_type == CommandType.GET_DEVICES:
if completed and len(payload) == 5:
# Completion packet: [CRC32 (4 bytes BE)][DeviceCount (1)]
crc = struct.unpack_from(">I", payload, 0)[0]
count = payload[4]
print(f"GetDevices complete: {count} devices, table CRC=0x{crc:08x}")
else:
self._parse_get_devices_response(payload)
elif cmd_type == CommandType.ACTION_SWITCH:
print(f"Switch Command Response (Seq {seq}): {payload.hex()}")
print(f"Switch Response (Seq {seq}) resp=0x{resp_type:02x}: {payload.hex()}")
else:
print(f"Response (Seq {seq}, Cmd {cmd_type}): {payload.hex()}")
print(f"Response (Seq {seq}, Cmd {cmd_type}) resp=0x{resp_type:02x}: {payload.hex()}")
# 0x81 (129) = SuccessCompleted, 0x82 (130) = FailureCompleted
if resp_type >= 128:
if completed:
self._pending_commands.pop(seq, None)
else:
print(f"Received Response for unknown Seq {seq}: {payload.hex()}")
print(f"Response for unknown Seq {seq} resp=0x{resp_type:02x}: {payload.hex()}")
else:
print(f"Received Event {event_type}: {decoded.hex()}")
self._handle_status_event(event_type, decoded)
except Exception as e:
print(f"Error decoding: {e}")
DEVICE_NAMES = {
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",
}
_QUIET_EVENTS = {1, 3, 4, 26, 32} # noisy bookkeeping events suppressed from output
_EVENT_NAMES = {
5: "RelayLatchingType1", 8: "DimmableLightStatus",
9: "RgbLightStatus", 11: "HvacStatus",
13: "HBridgeType1", 14: "HBridgeType2",
}
def _handle_status_event(self, event_type: int, decoded: bytes):
"""Decode and print unsolicited status broadcasts."""
if event_type == 7 and len(decoded) >= 6:
# RvStatus: battery voltage + optional external temp
v_raw = struct.unpack_from(">H", decoded, 1)[0]
t_raw = struct.unpack_from(">H", decoded, 3)[0]
features = decoded[5]
if features & 1 and v_raw != 0xFFFF:
voltage = v_raw / 256.0
print(f"[Battery] {voltage:.2f}V", end="")
else:
print(f"[Battery] N/A", end="")
if features & 2 and t_raw != 0x7FFF:
temp_c = struct.unpack_from(">h", decoded, 3)[0] / 256.0
print(f" [Ext Temp] {temp_c:.1f}°C / {temp_c*9/5+32:.1f}°F", end="")
print()
elif event_type == 12:
# TankSensorStatus: [event][table][devId][pct] repeated
i = 2
while i + 1 < len(decoded):
dev_id = decoded[i]
pct = decoded[i + 1]
name = self.DEVICE_NAMES.get(dev_id, f"tank DevID={dev_id}")
print(f"[Tank] {name}: {pct}%")
i += 2
elif event_type == 6:
# RelayBasicLatchingStatusType2: [event][table] then 7 bytes per device
# [devId][state_byte][5 more bytes]
i = 2
while i + 1 < len(decoded):
dev_id = decoded[i]
state_byte = decoded[i + 1]
on = bool(state_byte & 0x01)
name = self.DEVICE_NAMES.get(dev_id, f"DevID={dev_id}")
print(f"[Switch] {name}: {'ON' if on else 'OFF'}")
i += 7
else:
if event_type not in self._QUIET_EVENTS:
name = self._EVENT_NAMES.get(event_type, f"Event{event_type}")
print(f"[{name}] {decoded.hex()}")
def _parse_get_devices_response(self, payload: bytes):
"""Parse GetDevices response payload and print each device entry"""
"""Parse GetDevices intermediate response payload.
ExtendedData layout (payload = bytes 4+ of decoded packet):
[TableId (1)][StartingDeviceId (1)][DeviceCount (1)][device entries...]
Each device entry: [Protocol (1)][PayloadSize (1)][PayloadSize bytes]
Protocol 1 (IdsCan): PayloadSize=10
[DeviceType (1)][DeviceInstance (1)][ProductId (2 BE)][MAC (4)]
Protocol 2 (Host): varies
"""
if not payload:
print("GetDevices: empty payload")
return
# Raw hex first — useful for cross-referencing against PROTOCOL_FINDINGS.md
print(f"GetDevices raw payload: {payload.hex()}")
# Each device entry is 4 bytes: [DeviceID, DeviceType, TableID, Status]
# Structure inferred from protocol docs — verify against real hardware
entry_size = 4
for i in range(0, len(payload) - (entry_size - 1), entry_size):
device_id = payload[i]
device_type = payload[i + 1]
table_id = payload[i + 2]
status = payload[i + 3]
print(f" Device ID={device_id} Type=0x{device_type:02x} Table={table_id} Status=0x{status:02x}")
if len(payload) < 3:
return
table_id = payload[0]
start_id = payload[1]
dev_count = payload[2]
print(f" TableId={table_id} StartId={start_id} DeviceCount={dev_count}")
PROTOCOL_NAMES = {0: "None", 1: "IdsCan", 2: "Host"}
i = 3
entry_num = start_id
while i + 1 < len(payload):
protocol = payload[i]
payload_size = payload[i + 1]
entry_data = payload[i + 2 : i + 2 + payload_size]
proto_name = PROTOCOL_NAMES.get(protocol, f"Proto{protocol}")
if protocol == 1 and payload_size >= 10: # IdsCan
dev_type = entry_data[0]
dev_instance = entry_data[1]
product_id = struct.unpack_from(">H", entry_data, 2)[0]
mac = entry_data[4:8].hex(":")
print(f" [{entry_num}] {proto_name} DevType=0x{dev_type:02x} Instance={dev_instance} ProductId=0x{product_id:04x} MAC={mac}")
else:
print(f" [{entry_num}] {proto_name} data={entry_data.hex()}")
i += 2 + payload_size
entry_num += 1
def _next_seq(self) -> int:
"""Get next sequence number"""
@@ -121,11 +309,13 @@ class OneControlClient:
seq = self._next_seq()
self._pending_commands[seq] = cmd_type
packet = struct.pack("<HBB", seq, cmd_type, table_id) + payload
packet = struct.pack(">HBB", seq, cmd_type, table_id) + payload
encoded = self.encoder.encode(packet)
await self.client.write_gatt_char(self.WRITE_CHAR, encoded)
print(f"Sent (Seq {seq}): {packet.hex()}")
# Write-without-response (Command) — panel rejects write-with-response
# with ATT 0x0E. See client/onecontrol_client.py for details.
await self.client.write_gatt_char(self.WRITE_CHAR, encoded, response=False)
print(f"Sent (Seq {seq}): raw={packet.hex()} encoded={encoded.hex()}")
async def get_devices(self, start_id: int = 0, max_count: int = 255):
"""Get list of devices (Command 1)"""
@@ -168,7 +358,7 @@ async def scan_for_onecontrol():
found = []
for address, (device, adv) in devices.items():
if device.name and "OneControl" in device.name:
if device.name and "LCIRemote" in device.name:
print(f"Found by name: {device.name} ({address})")
found.append(device)
elif any("00000030-0200" in uuid.lower() for uuid in adv.service_uuids):
@@ -181,23 +371,7 @@ async def scan_for_onecontrol():
async def main():
found = await scan_for_onecontrol()
if len(found) == 1:
address = found[0].address
print(f"Auto-connecting to {found[0].name} ({address})")
elif len(found) > 1:
print("Multiple OneControl devices found — set ADDRESS manually:")
for d in found:
print(f" {d.name} ({d.address})")
return
else:
# No device found via scan — set address manually if you already know it
ADDRESS = "XX:XX:XX:XX:XX:XX"
if ADDRESS == "XX:XX:XX:XX:XX:XX":
print("No device found. Set ADDRESS manually if you know the BT address.")
return
address = ADDRESS
address = "30:C6:F7:E0:80:8E"
client = OneControlClient(address)
@@ -206,8 +380,10 @@ async def main():
await client.connect()
print("Connected! Requesting device list...")
# Panel has ~30s idle timeout — send a periodic ping to stay connected
while True:
await asyncio.sleep(20)
await client.get_devices()
await asyncio.sleep(5) # Wait for all response packets
finally:
await client.disconnect()