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