Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:32:12 -04:00
9 changed files with 11329 additions and 61 deletions
+114 -18
View File
@@ -9,6 +9,32 @@ Read `README.md` first — it has the full message-format documentation, the nod
map for this coach, and the wiring procedure. This file is just the current map for this coach, and the wiring procedure. This file is just the current
state + what's left. state + what's left.
## FLASHED & READ-VERIFIED (2026-06-12, afternoon)
The node is built, flashed over USB, on the bus, and on WiFi
(`192.168.69.18`, hostname `onecontrol-canbus`, adoptable via the API key in
`secrets.yaml`). **All 11 read entities verified live over the native API**:
battery 13.27 V, tanks fresh 0 / black 33 / grey1 33 / grey2 100 %, interior
lights on, exterior/heater off, both fault flags clear, awning idle.
What changed this session:
- `secrets.yaml` filled (WiFi + fallback AP from the shared gazebo proxy creds).
- **Battery voltage decode finished** (remaining-work item 4): matches the
page-`0x11` `00 2B …` telemetry frame, `b2..b3` BE / 256 — reads 13.27 V.
- **Toolchain fixes** (ESPHome moved since the last build; it no longer compiled):
the `g_node_type` array global's bare `{}` initializer was ambiguous (named the
type), and the awning cover enum is `COVER_OPERATION_OPENING/CLOSING` (no `IS_`).
- **Dual `on_frame` trigger** — this build filters `on_frame` by frame type, so a
single `use_extended_id: true` trigger only saw the 29-bit frames; the 11-bit
read broadcasts (tanks/lights/heater/awning) were silently dropped. Added a
second `use_extended_id: false` trigger sharing the same lambda via a YAML
anchor. (This was the exact risk the bus-config note called out.)
- **Logger** — raised the `canbus` component to INFO and silenced the per-frame
dump; at DEBUG it logged ~50 frames/s and saturated the 115200 serial link.
Still TODO (needs the operator in the loop — see "Remaining work" items 56):
command-path verification (toggle a light) and the attended awning test.
## Current status (2026-06-12) ## Current status (2026-06-12)
- **Message format documented and confirmed against live captures.** The bus is - **Message format documented and confirmed against live captures.** The bus is
@@ -42,31 +68,54 @@ state + what's left.
## Remaining work (in order) ## Remaining work (in order)
1. **`secrets.yaml`** — copy `esphome/secrets.yaml.example`, fill in WiFi, the 1. ~~**`secrets.yaml`**~~ — DONE. WiFi + fallback-AP creds filled from the shared
ESPHome `api_key`, and the fallback-AP password. It's git-ignored. gazebo proxy; the `api_key` was already set.
2. **First flash over USB:** `esphome run esphome/onecontrol-canbus.yaml` (pick 2. ~~**First flash over USB**~~ — DONE (`/dev/ttyUSB0`). OTA from here on.
the serial port). OTA after that. Mirrors the gazebo-fan-proxy workflow. Note: this laptop's user isn't in the `uucp` group, so the serial node needed
3. **Confirm the read entities populate** in HA once it's on the bus: the four `chmod 666 /dev/ttyUSB0` (resets on replug) — irrelevant for OTA.
tanks and the two light switches publish from the page-3 broadcasts. Watch the 3. ~~**Confirm the read entities populate**~~ — DONE. All 11 verified live over the
DEBUG frame dump (`logger: level: DEBUG`) to confirm frames are decoded; drop native API (see status block above). NB: ESPHome 2026.5 doesn't echo
to INFO when happy. `publish_state` at DEBUG over serial, so a quiet serial log is normal — read the
4. **Finish the two open read items in the YAML lambda:** states over the API, not the console.
- **Battery voltage** — rides a 29-bit telemetry frame (src `7D`/`AE`, 4. **Open read items in the YAML lambda:**
page `0x11`), bytes 23 big-endian / 256 = volts. Match that frame and - ~~**Battery voltage**~~ — DONE; reads 13.27 V.
publish `battery_voltage`. (See README "29-bit extended frames".)
- Optionally add **water pump (`61`)** and **water heater (`95`)** — both are - Optionally add **water pump (`61`)** and **water heater (`95`)** — both are
ordinary switched loads, same decode + command path as the lights. ordinary switched loads, same decode + command path as the lights. (Heater is
5. **Verify the command path** from HA: toggle Interior/Exterior Lights and the already exposed; pump stays command-blocked.)
Water Heater. The `send_load_command` script + `on_frame` handler do the 5. ~~**Verify the command path**~~ — DONE (Interior + Exterior lights, on & off,
exchange and send the opcode. 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 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 (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 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 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 stream the opcode (repeat until Stop) — only after watching it. The single-shot
default can't run the motor away. default can't run the motor away.
7. **(Optional) Surface at the campsite HA + bridge home** like the gazebo fans / 7. ~~**Surface at the campsite HA**~~ — DONE (2026-06-12). Added to the campsite
OneControl BLE devices, if you want these in the home dashboard too. 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. The DSI fault is already decoded and wired in (see below) — no capture needed.
@@ -105,6 +154,49 @@ The DSI fault is already decoded and wired in (see below) — no capture needed.
- One transceiver = one bus-end terminator. Never add a terminated node in the - One transceiver = one bus-end terminator. Never add a terminated node in the
middle of the bus (would make three terminators). 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) ## DSI fault — DECODED (2026-06-12)
Forced a real lockout and captured it (`captures/dsi-fault-*.log`). Already wired Forced a real lockout and captured it (`captures/dsi-fault-*.log`). Already wired
@@ -112,5 +204,9 @@ 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 - **Water Heater DSI Fault** = node `95` page-3 `b0` bit5 (`0x20`). Healthy heater
= `0x80` off / `0x81` running; lockout = `0xA0`. = `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 - **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). "fault exists somewhere" flag (read from node `95`'s page 0 in the lambda).
+28 -6
View File
@@ -65,7 +65,7 @@ mapped**, and the **command path is implemented and confirmed by live actuation
| `F8` | **interior lights** | type 0x1E; toggle test t≈5161 s; **operated live 2026-06-12** ✓ | | `F8` | **interior lights** | type 0x1E; toggle test t≈5161 s; **operated live 2026-06-12** ✓ |
| `95` | **water heater** | type 0x1E; toggle test t≈8594 s | | `95` | **water heater** | type 0x1E; toggle test t≈8594 s |
| `61` | **water pump** | type 0x1E; toggle test 2026-06-11 (on 13.5s / off 23.8s) ✓ | | `61` | **water pump** | type 0x1E; toggle test 2026-06-11 (on 13.5s / off 23.8s) ✓ |
| `89` | unknown switched load | type 0x1E, never toggled (furnace? DSI?) | | `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 | | `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 | | `6A`, `7F`, `9C` | slide / jacks / movement class | type 0x21, untested |
| `AE` | unknown (type 0x27, page3=0x00) | LP gas sensor? | | `AE` | unknown (type 0x27, page3=0x00) | LP gas sensor? |
@@ -169,15 +169,37 @@ python3 idscan_cmd.py F8 on # node_hex on|off ; needs can0 up
Movement nodes (awning `75`, slides, jacks) use the **same** authentication — Movement nodes (awning `75`, slides, jacks) use the **same** authentication —
the app-driven awning commands in `captures/app-commands-*.log` show the identical the app-driven awning commands in `captures/app-commands-*.log` show the identical
page-42/43 exchange. Not yet operated this way; exercise a motor only while page-42/43 exchange.
watching it.
**Awning motion is HOLD-TO-RUN, and auto-retract is live (2026-07-01).** A single
authenticated opcode runs the motor only ~1s (it's a "keep the button held"
signal, not a latch). The OEM app sustains motion by **streaming after one auth**:
opcode `03F2<node>02` every ~110ms **plus** a page-44 keepalive `03F0<node>44`
payload `00 04` every ~510ms — **no per-command re-auth during the run** (a cold
opcode with no session is still ignored; the auth just opens the motion session).
Our ESPHome node reproduces this with its own controller identity (opcode
`0006 75 02`, keepalive `0004 75 44`) — proven to sustain ~6.5s of continuous
retract on 2026-07-01.
The awning has no position feedback, but the **motor current** rides page-3 b2-3
(BE, raw counts): ~<1550 running incl. inrush, tapering to ~350500 near closed,
then a sharp ramp to a **~4200 plateau at the fully-closed hard stop** (captures
`awning-fullretract-2026-07-01_*.log`). The node's **auto-retract** (cover CLOSE)
streams the retract, watches current at 20Hz, and cuts at `cur>2500` for 3 frames
(~150ms) — firing ~0.3s into the stall ramp, before the motor sits hard-stalled —
then marks the cover CLOSED (the one direction with a real end-stop). Backstops:
70s timeout, motion-lost detector, and stop-streaming-stops-the-motor (hold-to-run
fail-safe). OPEN stays a single ~1s jog (no safe end-stop for extend). See
`esphome/onecontrol-canbus.yaml` (`awning_auto_retract` script + streamer
`interval` + the case `0x75` stall gate).
**Bottom line: read is fully open** (all sensors + states from broadcasts, no **Bottom line: read is fully open** (all sensors + states from broadcasts, no
authentication) **and command is implemented and proven** (`ids_can_auth.py` + 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 `idscan_cmd.py`). The CAN path can both sense and operate the system, so the
Bluetooth integration is no longer needed for control. Next step: fold the Bluetooth integration is no longer needed for control. The challenge-response is
challenge-response into the ESPHome node's `switch`/`light`/`cover` actions (the folded into the ESPHome node's `switch`/`light`/`cover` actions (opcode preceded
opcode just needs the page-42/43 exchange in front of it). 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 Other app-session traffic (not control): `701` = controller heartbeat during a
Bluetooth session; src `01` → node pages `30/31` = paged descriptor/table reads Bluetooth session; src `01` → node pages `30/31` = paged descriptor/table reads
+192
View File
@@ -0,0 +1,192 @@
# Awning "Closed" Sensor — Spec (Option 1: standalone WiFi reed node)
**Goal:** give the campsite awning a *true* stowed/not-stowed state instead of the
`assumed_state` the CAN cover currently reports. A magnetic reed switch at the
fully-retracted position, read by a small ESP running ESPHome over the existing
`OmnissiahsReach` WiFi — **no Zigbee mesh, no coordinator, no new ecosystem.**
This is "Option 1" (standalone node). Option 2 (wire the reed back to a spare GPIO
on the CAN node `192.168.69.18` so the cover entity itself becomes truthful) is the
more-integrated alternative but needs a 2-wire run from the awning to the control
panel; it's noted at the end. Pairs with the current-sensing auto-retract already
in `esphome/onecontrol-canbus.yaml` (README "awning" section): current does the
*dynamic* stop during retract, the reed gives *persistent* truth afterward.
## What it delivers (and what it doesn't)
- **Reliable CLOSED (stowed) detection.** Awnings extend to an arbitrary spot, so
there's no meaningful "fully open" endpoint — but the fully-retracted position is
hard and repeatable. That's the state that matters (don't drive off / don't let
it flap): a single reed there answers *stowed vs. not*.
- It does **not** measure extension %. If you ever want an "opening/closing"
animation you already get that from the CAN motion byte; the reed just pins down
the resting truth.
## Bill of materials (~$1520)
| Part | Notes |
|------|-------|
| **ESP32-C3 SuperMini** (or Wemos D1 mini / ESP8266) | Tiny, WiFi, 3.3 V logic. C3 recommended (native ESP-IDF, cheap, plenty of GPIO). |
| **Weatherproof reed switch** | RV-compartment / garage-door style, **potted + leaded**, normally-open. Get a wide-gap one (pull-in ≥ 1520 mm) so alignment is forgiving. |
| **Bar/block magnet** | Match/exceed the reed's rated gap. Rare-earth block for margin. |
| **12 V → 5 V buck** (MP1584 / mini360 or an automotive USB buck) | Tap the awning motor's 12 V; always-on beats battery/deep-sleep for instant state and no maintenance. |
| **Small IP65 enclosure + gland** | Mount the ESP + buck out of the weather. |
| Fused 12 V tap (inline 1 A) | Protect the tap off the motor harness. |
## Wiring
```
awning 12V harness --[inline 1A fuse]--> buck IN+ buck OUT+ (5V) --> ESP 5V
chassis GND -----> buck IN- ----buck OUT- ------> ESP GND
reed leg A --> ESP GPIO4
reed leg B --> ESP GND
```
- Reed to GND with the **internal pull-up** enabled: pin idles HIGH (not stowed),
goes LOW when the magnet is present (stowed). The ESPHome config below inverts
that so the sensor reads ON = stowed.
- No external resistor needed. An optional 0.1 µF across the reed helps debounce,
but the software `delayed_on/off` below is enough.
- Avoid the C3 strapping pins (GPIO2, 8, 9); GPIO4 is safe. GPIO8 has the onboard
LED if you want a status blink.
## Mounting
- **Magnet on the moving part** (the lead rail / roller endcap that seats when
stowed); **reed on the fixed part** (mounting rail or a bracket on the coach).
- Aim for the magnet to land within the reed's pull-in gap when the awning is
pulled in tight — the same "closed" position the current-stall stop lands on.
Expect a test-fit: mark where the rail seats, mount, confirm the sensor flips.
- Outdoor: use the potted reed, point the gland down, silicone the entry, and
strain-relief the lead so awning motion doesn't fatigue it.
## ESPHome config
New device `awning-sensor` (mirror the conventions in `onecontrol-canbus.yaml` /
`gazebo-fan-proxy.yaml`: `secrets.yaml` for WiFi + API key, OTA after first USB
flash, fallback AP + captive portal). DHCP on `OmnissiahsReach`.
```yaml
substitutions:
name: awning-sensor
friendly_name: Awning Sensor
esphome:
name: ${name}
friendly_name: ${friendly_name}
esp32:
board: esp32-c3-devkitm-1
framework:
type: esp-idf
logger:
api:
encryption:
key: !secret api_key
ota:
- platform: esphome
wifi:
ssid: !secret wifi_ssid # OmnissiahsReach
password: !secret wifi_password
ap:
ssid: "Awning-Sensor Fallback"
password: !secret fallback_ap_password
captive_portal:
binary_sensor:
- platform: gpio
name: "Awning Stowed"
id: awning_stowed
pin:
number: GPIO4
mode:
input: true
pullup: true
inverted: true # reed->GND: magnet present = LOW = ON (stowed)
filters:
- delay_on: 200ms # debounce the rail seating
- delay_off: 200ms
# ON = awning pulled in tight (stowed / closed)
# OFF = not stowed (extended, or mid-travel)
```
`secrets.yaml` needs `wifi_ssid`, `wifi_password`, `fallback_ap_password`,
`api_key` (generate with `esphome`). First flash over USB (`esphome run ... `),
thereafter OTA — same as the other campsite nodes.
## HA integration
The reed is a separate device from the CAN node, so it lands as its own entity:
1. **Campsite Pi** auto-discovers it via the ESPHome integration →
`binary_sensor.awning_sensor_awning_stowed` (rename to
`binary_sensor.awning_stowed`). ON = stowed.
2. **Bridge to home HA** — add to `/config/packages/mqtt_bridge.yaml` (repo:
`canbus/ha/mqtt_bridge_onecontrol.yaml`) next to the other campsite entities:
a discovery block (`homeassistant/binary_sensor/campsite/awning_stowed/config`,
device `campsite_onecontrol`, `device_class: "opening"` reads on=open/off=closed,
or leave classless for a plain stowed/clear), plus a state-forward trigger on
`binary_sensor.awning_stowed``campsite/binary_sensor/awning_stowed/state`.
Appears on home as `binary_sensor.campsite_onecontrol_awning_stowed`.
3. Add the tile to home HA **Overview → Camper → OneControl** section.
### Optional: make the cover *truthful* (template cover)
Right now `cover.onecontrol_can_awning` is `assumed_state`. With the reed you can
wrap it in a template cover on the Pi whose closed state is the *real* reed, while
commands still hit the CAN cover:
```yaml
cover:
- platform: template
covers:
awning_true:
friendly_name: "Awning"
device_class: awning
value_template: "{{ 'closed' if is_state('binary_sensor.awning_stowed','on') else 'open' }}"
close_cover:
- action: cover.close_cover # -> CAN cover auto-retract
target: { entity_id: cover.onecontrol_can_awning }
stop_cover:
- action: cover.stop_cover
target: { entity_id: cover.onecontrol_can_awning }
```
Bridge `cover.awning_true` to home instead of the raw CAN cover, and you get a
cover that shows genuine open/closed. (Keep `payload_open` disabled as today.)
### Automations this unlocks
- **Left-it-out alarm:** awning `not stowed` + (leaving geofence / wind gust > X /
rain) → Discord ping, or auto-fire `cover.close_cover`.
- **Confirm the retract actually seated:** after an auto-retract, if the current
stall fired but the reed is still OFF a few seconds later → alert (mis-seat).
> **Option-1 limitation:** because the reed is on a *separate* device from the CAN
> node, the auto-retract's stall gate can't consume it on-device — any
> reed↔retract logic is a cross-device HA automation, not firmware. If you want
> the ESP to stop the motor *on the reed itself* (belt-and-suspenders with the
> current stall), that's Option 2 (wire the reed to a spare GPIO on the CAN node
> `192.168.69.18`; the cover entity then reads it directly and becomes truthful
> with no template cover).
## Touchpoints checklist (campsite ESPHome node)
- [ ] `secrets.yaml` (WiFi = OmnissiahsReach, API key, fallback AP pw)
- [ ] USB flash once, confirm on `OmnissiahsReach` (DHCP `192.168.69.x`); OTA after
- [ ] Adopt in campsite Pi HA (ESPHome integration), rename entity
- [ ] Mount reed + magnet, verify it flips exactly at the stowed position
- [ ] `mqtt_bridge.yaml`: discovery + state-forward → home HA; add dashboard tile
- [ ] (optional) template cover `awning_true`; bridge it instead of the raw cover
- [ ] (optional) automations: left-out alarm, retract-seat confirmation
## Validation
1. USB-flash, join WiFi, adopt in HA. Toggle by hand with a magnet → `awning_stowed`
flips ON/OFF with the 200 ms debounce.
2. Mount, retract via the dashboard (auto-retract): at full stow the reed should go
ON right around when the current-stall stop fires.
3. Extend at the OEM wall switch → reed OFF. Confirm home HA mirrors it.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+249 -37
View File
@@ -44,8 +44,14 @@ wifi:
captive_portal: captive_portal:
logger: logger:
level: DEBUG # DEBUG so the on_frame ESP_LOGD frame dump is visible level: DEBUG # global DEBUG so entity "Sending state" publishes and
# during bring-up. Drop to INFO once the map is solid. # 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: api:
encryption: encryption:
@@ -71,11 +77,17 @@ canbus:
bit_rate: 250kbps # IDS-CAN is 250k bit_rate: 250kbps # IDS-CAN is 250k
can_id: 0 # our own TX id (only matters when we send) can_id: 0 # our own TX id (only matters when we send)
use_extended_id: true # commands use 29-bit IDs 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: on_frame:
- can_id: 0 - can_id: 0
can_id_mask: 0 # accept every frame, dispatch in the lambda can_id_mask: 0 # accept every frame, dispatch in the lambda
use_extended_id: true use_extended_id: true
then: then: &decode_frame
- lambda: |- - lambda: |-
// `can_id` and `x` (data bytes) are provided by the trigger. // `can_id` and `x` (data bytes) are provided by the trigger.
uint32_t id = can_id; uint32_t id = can_id;
@@ -135,11 +147,14 @@ canbus:
id(g_node_type)[node] = x[3]; id(g_node_type)[node] = x[3];
} }
// Frame dump — comment out once the map is trustworthy. // Frame dump — confirmed both frame types decode (2026-06-12), now
ESP_LOGD("idscan", "page=%u node=%02X len=%u %02X %02X %02X %02X %02X %02X", // silenced: at DEBUG it logs every frame (~50/s across both
page, node, x.size(), // triggers) and saturates the 115200 serial link. Uncomment to
x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0, // re-map the bus.
x.size()>3?x[3]:0, x.size()>4?x[4]:0, x.size()>5?x[5]:0); // 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): // page 3 = live value. Layout depends on device class (README):
// tanks (type 0x0A): x[0] = level in percent (0x42 = 66%). // tanks (type 0x0A): x[0] = level in percent (0x42 = 66%).
@@ -157,17 +172,60 @@ canbus:
// water heater (node 95): b0 bit0 = on, bit5 (0x20) = DSI/gas // water heater (node 95): b0 bit0 = on, bit5 (0x20) = DSI/gas
// ignition fault/lockout (healthy: 0x80 off / 0x81 running; // ignition fault/lockout (healthy: 0x80 off / 0x81 running;
// fault: 0xA0). Confirmed by a forced lockout 2026-06-12. // 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); case 0x95: id(water_heater).publish_state(x[0] & 0x01);
id(dsi_fault).publish_state(x[0] & 0x20); break; 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) // awning H-bridge (node 75): b0 C0 idle / C2 extending (opening)
// / C3 retracting (closing). Reflect motion onto the cover so HA // / C3 retracting (closing); b2-3 (BE) = motor current (raw
// shows open/opening/closing; assumed_state fills the resting pos. // counts, ~<1550 running incl. inrush, ~4200 at the fully-closed
case 0x75: // stall — captured 2026-07-01). Reflect motion onto the cover and
if (x[0] == 0xC2) id(awning).current_operation = COVER_OPERATION_IS_OPENING; // publish current; while an auto-retract session is active, watch
else if (x[0] == 0xC3) id(awning).current_operation = COVER_OPERATION_IS_CLOSING; // for the stall spike and cut the stream (see interval + script).
else id(awning).current_operation = COVER_OPERATION_IDLE; 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(); id(awning).publish_state();
break; break;
}
// (water pump 61 is command-blocked — read-only, not exposed.) // (water pump 61 is command-blocked — read-only, not exposed.)
} }
} }
@@ -180,8 +238,23 @@ canbus:
} }
// Battery voltage rides 29-bit telemetry frames (src 7D/AE, page // Battery voltage rides 29-bit telemetry frames (src 7D/AE, page
// 0x11), payload 00 2B 0D 4x ..; b2..b3 (BE) / 256 = volts. // 0x11), payload 00 2B 0D 4x <rolling>; b2..b3 (BE) / 256 = volts.
// TODO: match the exact source frame and publish battery_voltage. // 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" # Command path: authenticated "set switched load"
@@ -205,7 +278,33 @@ globals:
# seen). Feeds the motor-output safety gate in command_guard.h. # seen). Feeds the motor-output safety gate in command_guard.h.
- id: g_node_type - id: g_node_type
type: 'std::array<uint8_t, 256>' type: 'std::array<uint8_t, 256>'
initial_value: '{}' # 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: script:
- id: send_load_command - id: send_load_command
@@ -237,11 +336,16 @@ script:
id(g_cmd_node) = (uint8_t) node; id(g_cmd_node) = (uint8_t) node;
id(g_cmd_op) = (uint8_t) op; id(g_cmd_op) = (uint8_t) op;
id(g_cmd_pending) = true; id(g_cmd_pending) = true;
# Send the page-42 request, wait ~100 ms for the challenge; retry up to 3x. # 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 # The on_frame handler clears g_cmd_pending the moment it answers, so a
# successful exchange short-circuits the remaining iterations. # successful exchange short-circuits the remaining iterations.
- repeat: - repeat:
count: 3 count: 8
then: then:
- if: - if:
condition: condition:
@@ -252,16 +356,84 @@ script:
uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8); uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8);
std::vector<uint8_t> req = {0x00, 0x04}; std::vector<uint8_t> req = {0x00, 0x04};
id(can_bus).send_data(req_id, true, req); id(can_bus).send_data(req_id, true, req);
- delay: 100ms - delay: 150ms
- if: - if:
condition: condition:
lambda: 'return id(g_cmd_pending);' lambda: 'return id(g_cmd_pending);'
then: then:
- lambda: |- - lambda: |-
ESP_LOGW("idscan", "no page-42 challenge from node %02X after 3 tries; command dropped", ESP_LOGW("idscan", "no page-42 challenge from node %02X after 8 tries; command dropped",
id(g_cmd_node)); id(g_cmd_node));
id(g_cmd_pending) = false; 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) # Read-back sensors (published by the dispatcher above)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -273,6 +445,13 @@ sensor:
device_class: voltage device_class: voltage
state_class: measurement state_class: measurement
accuracy_decimals: 2 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 - platform: template
name: "Fresh Water Tank" name: "Fresh Water Tank"
id: fresh_tank id: fresh_tank
@@ -293,6 +472,15 @@ sensor:
id: grey_tank_2 id: grey_tank_2
unit_of_measurement: "%" unit_of_measurement: "%"
accuracy_decimals: 0 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) # Fault indicators (published by the dispatcher above)
@@ -302,6 +490,18 @@ binary_sensor:
name: "Water Heater DSI Fault" name: "Water Heater DSI Fault"
id: dsi_fault id: dsi_fault
device_class: problem # node 95 page-3 b0 bit5 — gas ignition lockout 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 - platform: template
name: "OneControl System Fault" name: "OneControl System Fault"
id: system_fault id: system_fault
@@ -319,14 +519,22 @@ binary_sensor:
# policy in command_guard.h, enforced both here and at the transmit point, so even # 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 # 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. # gate but needs a proper cover entity + an attended first test before it's wired.
# optimistic:true for now — the page-3 (b0 bit0) read-back above already publishes # optimistic:false: the page-3 (b0 bit0) read-back above publishes true module
# true module state, so these can switch to optimistic:false once verified. # 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: switch:
- platform: template - platform: template
name: "Exterior Lights" name: "Exterior Lights"
id: exterior_lights id: exterior_lights
optimistic: true optimistic: false
restore_mode: DISABLED
turn_on_action: turn_on_action:
- script.execute: { id: send_load_command, node: 0x2A, op: 1 } - script.execute: { id: send_load_command, node: 0x2A, op: 1 }
turn_off_action: turn_off_action:
@@ -335,7 +543,8 @@ switch:
- platform: template - platform: template
name: "Interior Lights" name: "Interior Lights"
id: interior_lights id: interior_lights
optimistic: true optimistic: false
restore_mode: DISABLED
turn_on_action: turn_on_action:
- script.execute: { id: send_load_command, node: 0xF8, op: 1 } - script.execute: { id: send_load_command, node: 0xF8, op: 1 }
turn_off_action: turn_off_action:
@@ -344,7 +553,8 @@ switch:
- platform: template - platform: template
name: "Water Heater" name: "Water Heater"
id: water_heater id: water_heater
optimistic: true optimistic: false
restore_mode: DISABLED
turn_on_action: turn_on_action:
- script.execute: { id: send_load_command, node: 0x95, op: 1 } - script.execute: { id: send_load_command, node: 0x95, op: 1 }
turn_off_action: turn_off_action:
@@ -360,12 +570,16 @@ switch:
# (no position slider); current_operation is published from the page-3 motion # (no position slider); current_operation is published from the page-3 motion
# byte in the dispatcher above. # byte in the dispatcher above.
# #
# ⚠️ FIRST ACTUATION MUST BE ATTENDED. We haven't confirmed whether one command # Motion is HOLD-TO-RUN (confirmed 2026-06-11 capture + 2026-07-01 live): one
# latches the motor (runs to the travel limit) or is hold-to-run (the OEM app # opcode runs the motor ~1s. So:
# streamed repeats, which hints at hold-to-run). The single-shot command here is # CLOSE = auto-retract — stream to the fully-closed stall, stop on current
# the safe default: if hold-to-run, the awning just moves a little and stops on # spike (awning_auto_retract script + interval + stall gate). This is
# its own — it can't run away. If it under-travels, stream the command (repeat # the one direction with a real end-stop, so it also marks CLOSED.
# send_load_command until stop) — do that change only after watching it move. # 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: cover:
- platform: template - platform: template
@@ -373,9 +587,7 @@ cover:
id: awning id: awning
device_class: awning device_class: awning
assumed_state: true assumed_state: true
open_action:
- script.execute: { id: send_load_command, node: 0x75, op: 1 }
close_action: close_action:
- script.execute: { id: send_load_command, node: 0x75, op: 2 } - script.execute: awning_auto_retract
stop_action: stop_action:
- script.execute: { id: send_load_command, node: 0x75, op: 0 } - script.execute: awning_stop
+201
View File
@@ -0,0 +1,201 @@
# Campsite presence / occupancy package.
#
# Occupancy truth: the gas water heater — always on while camping, always
# turned off when leaving (propane). WiFi presence (MikroTik Chateau
# device_trackers) is the second, internet-independent signal; until that
# integration is set up the wifi sensor reads `unavailable` and everything
# downstream treats it as "no veto" (failsafes key on GPS, dead-man stays
# quiet).
#
# Deploy to Pi: /config/packages/campsite_presence.yaml
# Repo copy: canbus/ha/campsite_presence.yaml
#
# ⚠️ FIRST DEPLOY: `template:` may be a new domain on this instance — full
# core restart, then confirm the sensors exist (see dsi_fault_alert.yaml for
# the reload_all trap).
template:
- binary_sensor:
- name: "RV Occupied"
unique_id: campsite_rv_occupied
device_class: occupancy
icon: mdi:campfire
# unavailable/unknown heater (node down) -> sensor unavailable, not a
# false "left": availability gates it.
availability: >-
{{ states('switch.onecontrol_can_water_heater')
not in ['unavailable', 'unknown'] }}
state: "{{ is_state('switch.onecontrol_can_water_heater', 'on') }}"
- name: "Phones On Campsite WiFi"
unique_id: campsite_phones_on_wifi
device_class: presence
icon: mdi:wifi-marker
# MikroTik trackers (renamed to these ids after the integration is
# added). While they don't exist the sensor is unavailable — that is
# load-bearing: the dead-man automation below triggers on a sustained
# 'off', which unavailable never satisfies.
availability: >-
{{ states('device_tracker.wes_phone_wifi')
not in ['unavailable', 'unknown']
or states('device_tracker.lindsey_iphone_wifi')
not in ['unavailable', 'unknown'] }}
state: >-
{{ is_state('device_tracker.wes_phone_wifi', 'home')
or is_state('device_tracker.lindsey_iphone_wifi', 'home') }}
automation:
# --- Bridge both presence sensors home (discovery + state) -------------
- id: campsite_presence_mqtt_discovery
alias: "Presence: Publish MQTT Discovery"
triggers:
- trigger: homeassistant
event: start
- trigger: event
event_type: automation_reloaded
- trigger: time_pattern
hours: "/6"
actions:
- delay: "00:00:07"
- action: mqtt.publish
data:
topic: "homeassistant/binary_sensor/campsite/rv_occupied/config"
retain: true
payload: >-
{"name": "RV Occupied",
"unique_id": "campsite_rv_occupied",
"state_topic": "campsite/binary_sensor/rv_occupied/state",
"payload_on": "ON", "payload_off": "OFF",
"device_class": "occupancy",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
- action: mqtt.publish
data:
topic: "homeassistant/binary_sensor/campsite/phones_on_campsite_wifi/config"
retain: true
payload: >-
{"name": "Phones On Campsite WiFi",
"unique_id": "campsite_phones_on_wifi",
"state_topic": "campsite/binary_sensor/phones_on_campsite_wifi/state",
"payload_on": "ON", "payload_off": "OFF",
"payload_available": "online", "payload_not_available": "offline",
"availability_topic": "campsite/presence/wifi_availability",
"device_class": "presence",
"device": {"identifiers": ["campsite_onecontrol"]}}
- delay: "00:00:02"
- action: mqtt.publish
data:
topic: "campsite/binary_sensor/rv_occupied/state"
retain: true
payload: "{{ 'ON' if is_state('binary_sensor.rv_occupied', 'on') else 'OFF' }}"
- action: mqtt.publish
data:
topic: "campsite/presence/wifi_availability"
retain: true
payload: >-
{{ 'offline' if states('binary_sensor.phones_on_campsite_wifi')
in ['unavailable', 'unknown'] else 'online' }}
- action: mqtt.publish
data:
topic: "campsite/binary_sensor/phones_on_campsite_wifi/state"
retain: true
payload: "{{ 'ON' if is_state('binary_sensor.phones_on_campsite_wifi', 'on') else 'OFF' }}"
- id: campsite_presence_forward_states
alias: "Presence: Forward States"
triggers:
- trigger: state
entity_id:
- binary_sensor.rv_occupied
- binary_sensor.phones_on_campsite_wifi
actions:
- action: mqtt.publish
data:
topic: "campsite/binary_sensor/{{ trigger.to_state.object_id }}/state"
retain: true
payload: "{{ 'ON' if trigger.to_state.state == 'on' else 'OFF' }}"
- if:
- condition: template
value_template: "{{ trigger.to_state.object_id == 'phones_on_campsite_wifi' }}"
then:
- action: mqtt.publish
data:
topic: "campsite/presence/wifi_availability"
retain: true
payload: >-
{{ 'offline' if trigger.to_state.state
in ['unavailable', 'unknown'] else 'online' }}
# --- Local dead-man notify (works with the WG tunnel down) -------------
# Heater on but neither phone seen on campsite WiFi for 3 h. Notify-only:
# WiFi absence alone is not proof of departure (pool afternoon, dead
# battery), so the auto-off lives home-side where GPS can confirm.
- id: campsite_deadman_heater_notify
alias: "Presence: Dead-man heater notify (local)"
triggers:
- trigger: state
entity_id: binary_sensor.phones_on_campsite_wifi
to: "off"
for: "03:00:00"
conditions:
- condition: state
entity_id: binary_sensor.rv_occupied
state: "on"
actions:
- action: rest_command.discord_server_alerts
data:
message: >-
<@321798967669030912> 🤨 The water heater has been on for 3+ hours
with neither of your phones on the campsite WiFi. If you actually
left, you forgot the shutdown ritual again — kill the heater from
the dashboard. If you're at the pool, carry on.
mode: single
# --- Lot lights (Shelly) — PRE-STAGED, DISABLED until the relay exists --
# Enable + fix the entity id once the Shelly is installed in the shed
# outlet box. Mirrors the camper exterior-lights sunset/sunrise pattern
# (retry because nothing should be trusted fire-and-forget), gated on
# occupancy so the lot doesn't light up for an empty site.
- id: campsite_lot_lights_sunset
alias: "Lot lights on at sunset (occupied only)"
initial_state: false
triggers:
- trigger: sun
event: sunset
offset: 0
conditions:
- condition: state
entity_id: binary_sensor.rv_occupied
state: "on"
actions:
- repeat:
sequence:
- action: switch.turn_on
target:
entity_id: switch.lot_lights
- delay: "00:00:05"
until:
- condition: template
value_template: >-
{{ is_state('switch.lot_lights', 'on') or repeat.index >= 3 }}
mode: single
- id: campsite_lot_lights_sunrise
alias: "Lot lights off at sunrise"
initial_state: false
triggers:
- trigger: sun
event: sunrise
offset: 0
actions:
- repeat:
sequence:
- action: switch.turn_off
target:
entity_id: switch.lot_lights
- delay: "00:00:05"
until:
- condition: template
value_template: >-
{{ is_state('switch.lot_lights', 'off') or repeat.index >= 3 }}
mode: single
+126
View File
@@ -0,0 +1,126 @@
# Water-heater DSI fault → Discord ping in the server-alerts channel.
#
# The CAN node decodes the heater's DSI lockout (node 95 page-3 b0 bit5 —
# see canbus/README.md); when the burner fails to light (usually an empty
# propane tank) the module latches the fault and this fires the webhook.
#
# Deploy to Pi: /config/packages/dsi_fault_alert.yaml
# Repo copy: canbus/ha/dsi_fault_alert.yaml
#
# ⚠️ FIRST DEPLOY NEEDS A FULL CORE RESTART. reload_all only re-reads config
# for integrations that are already set up; rest_command was a brand-new domain
# on this instance, so until the restart the service didn't exist and every
# automation fire died with "Action rest_command.discord_server_alerts not
# found" — while check_config said valid. Verify with:
# curl .../api/services | jq '[.[].domain] | index("rest_command")'
# Secret: discord_server_alerts_webhook in the Pi's /config/secrets.yaml
# (NOT the Gatus webhook — that one posts somewhere Wes doesn't
# read; this one is confirmed delivering to the alerts channel)
rest_command:
discord_server_alerts:
url: !secret discord_server_alerts_webhook
method: POST
content_type: "application/json; charset=utf-8"
payload: >-
{"username": "Octavia",
"content": {{ message | tojson }},
"allowed_mentions": {"users": ["321798967669030912"]}}
automation:
# --- Octavia relay: anything on the broker can speak through the one
# verified webhook path. Home HA's failsafe automations publish here
# (campsite/octavia/say, payload = the message) since home has no
# file/packages access for its own rest_command. ---
- id: campsite_octavia_relay
alias: "Octavia: MQTT relay"
triggers:
- trigger: mqtt
topic: "campsite/octavia/say"
conditions:
- condition: template
value_template: "{{ trigger.payload | length > 0 and trigger.payload | length < 1900 }}"
actions:
- action: rest_command.discord_server_alerts
data:
message: "{{ trigger.payload }}"
mode: queued
max: 5
- id: camper_dsi_fault_discord
alias: "Camper: DSI fault → Discord"
description: >-
10s 'for' debounces a single glitched broadcast frame; a real lockout
is latched by the module so it easily survives the wait.
triggers:
- trigger: state
entity_id: binary_sensor.onecontrol_can_water_heater_dsi_fault
to: "on"
for: "00:00:10"
actions:
- action: rest_command.discord_server_alerts
data:
message: >-
<@321798967669030912> 🔥 **WATER HEATER DSI FAULT** — hey dumbass,
you're out of propane again. (Or the igniter finally died, but let's be honest,
it's the propane.) The heater is in lockout — swap the tank, then
cycle the heater off/on to relight.
mode: single
- id: camper_dsi_fault_cleared_discord
alias: "Camper: DSI fault cleared → Discord"
triggers:
- trigger: state
entity_id: binary_sensor.onecontrol_can_water_heater_dsi_fault
from: "on"
to: "off"
for: "00:00:10"
actions:
- action: rest_command.discord_server_alerts
data:
message: >-
✅ DSI fault cleared — the water heater is lighting again. Crisis
averted. Try to remember this feeling next time you eyeball the
tank gauge and say "eh, it's fine."
mode: single
# --- Furnace DSI fault (node 89). Same lockout scheme as the water heater
# (page-3 b0 bit5); see canbus/README.md. A furnace lockout means no forced-air
# heat — more urgent than the water heater on a cold night. ---
- id: camper_furnace_dsi_fault_discord
alias: "Camper: Furnace DSI fault → Discord"
description: >-
10s 'for' debounces a single glitched broadcast frame; a real lockout
is latched by the module so it easily survives the wait.
triggers:
- trigger: state
entity_id: binary_sensor.onecontrol_can_furnace_dsi_fault
to: "on"
for: "00:00:10"
actions:
- action: rest_command.discord_server_alerts
data:
message: >-
<@321798967669030912> 🥶 **FURNACE DSI FAULT** — the furnace tried to
light and gave up. That means no heat. Same usual suspect: you're out
of propane (or the igniter died, but c'mon). It's in lockout — swap the
tank, then cycle the thermostat to relight before everything inside
hits ambient.
mode: single
- id: camper_furnace_dsi_fault_cleared_discord
alias: "Camper: Furnace DSI fault cleared → Discord"
triggers:
- trigger: state
entity_id: binary_sensor.onecontrol_can_furnace_dsi_fault
from: "on"
to: "off"
for: "00:00:10"
actions:
- action: rest_command.discord_server_alerts
data:
message: >-
✅ Furnace fault cleared — it's lighting again and making heat. Enjoy
the warmth, and maybe glance at the propane gauge once in a while like
a functioning adult.
mode: single
+425
View File
@@ -0,0 +1,425 @@
# Campsite MQTT Bridge — publishes the OneControl CAN entities to home
# Mosquitto (cyrion, 192.168.88.69) via MQTT Discovery so they auto-appear
# on the home HA (192.168.88.40) as device "Campsite OneControl".
#
# Source side is the ESPHome IDS-CAN node ("OneControl CAN", entities
# *.onecontrol_can_*) — NOT the retired BLE integration.
# No water pump and no slide covers on purpose: those are panel-only
# (see canbus/HANDOFF.md safety notes).
#
# Deploy to Pi: /config/packages/mqtt_bridge.yaml
# Repo copy: canbus/ha/mqtt_bridge_onecontrol.yaml
automation:
# --- MQTT Discovery + state republish on startup / reload / every 15 min ---
# The 15-min cadence (was 6h) heals stale retained switch states when an
# on-change forward publish is lost during a roof-AP/MQTT link flap — the
# weak-link failure that left home showing Exterior off while the camper
# was on (2026-06-14). Config republishes are idempotent (HA dedupes).
- id: campsite_mqtt_discovery
alias: "MQTT Bridge: Publish Discovery"
triggers:
- trigger: homeassistant
event: start
- trigger: event
event_type: automation_reloaded
- trigger: time_pattern
minutes: "/15"
actions:
- delay: "00:00:05"
- action: mqtt.publish
data:
topic: "campsite/onecontrol/availability"
retain: true
payload: >-
{{ 'offline' if states('sensor.onecontrol_can_battery_voltage')
in ['unavailable', 'unknown'] else 'online' }}
# Switches
- action: mqtt.publish
data:
topic: "homeassistant/switch/campsite/gas_water_heater/config"
retain: true
payload: >-
{"name": "Water Heater",
"unique_id": "campsite_gas_water_heater",
"state_topic": "campsite/switch/water_heater/state",
"command_topic": "campsite/switch/water_heater/set",
"payload_on": "ON", "payload_off": "OFF",
"state_on": "ON", "state_off": "OFF",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"],
"name": "Campsite OneControl",
"manufacturer": "Lippert",
"model": "Chateau 22QB (IDS-CAN)"}}
- action: mqtt.publish
data:
topic: "homeassistant/switch/campsite/exterior_lights/config"
retain: true
payload: >-
{"name": "Exterior Lights",
"unique_id": "campsite_exterior_lights",
"state_topic": "campsite/switch/exterior_lights/state",
"command_topic": "campsite/switch/exterior_lights/set",
"payload_on": "ON", "payload_off": "OFF",
"state_on": "ON", "state_off": "OFF",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
- action: mqtt.publish
data:
topic: "homeassistant/switch/campsite/interior_lights/config"
retain: true
payload: >-
{"name": "Interior Lights",
"unique_id": "campsite_interior_lights",
"state_topic": "campsite/switch/interior_lights/state",
"command_topic": "campsite/switch/interior_lights/set",
"payload_on": "ON", "payload_off": "OFF",
"state_on": "ON", "state_off": "OFF",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
# Sensors
- action: mqtt.publish
data:
topic: "homeassistant/sensor/campsite/battery_voltage/config"
retain: true
payload: >-
{"name": "Battery Voltage",
"unique_id": "campsite_battery_voltage",
"state_topic": "campsite/sensor/battery_voltage/state",
"unit_of_measurement": "V",
"device_class": "voltage",
"state_class": "measurement",
"suggested_display_precision": 2,
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
- action: mqtt.publish
data:
topic: "homeassistant/sensor/campsite/fresh_water_tank/config"
retain: true
payload: >-
{"name": "Fresh Water Tank",
"unique_id": "campsite_fresh_water_tank",
"state_topic": "campsite/sensor/fresh_water_tank/state",
"unit_of_measurement": "%",
"state_class": "measurement",
"icon": "mdi:water",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
- action: mqtt.publish
data:
topic: "homeassistant/sensor/campsite/grey_tank_1/config"
retain: true
payload: >-
{"name": "Grey Tank 1",
"unique_id": "campsite_grey_tank_1",
"state_topic": "campsite/sensor/grey_tank_1/state",
"unit_of_measurement": "%",
"state_class": "measurement",
"icon": "mdi:water-opacity",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
- action: mqtt.publish
data:
topic: "homeassistant/sensor/campsite/grey_tank_2/config"
retain: true
payload: >-
{"name": "Grey Tank 2",
"unique_id": "campsite_grey_tank_2",
"state_topic": "campsite/sensor/grey_tank_2/state",
"unit_of_measurement": "%",
"state_class": "measurement",
"icon": "mdi:water-opacity",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
- action: mqtt.publish
data:
topic: "homeassistant/sensor/campsite/black_tank/config"
retain: true
payload: >-
{"name": "Black Tank",
"unique_id": "campsite_black_tank",
"state_topic": "campsite/sensor/black_tank/state",
"unit_of_measurement": "%",
"state_class": "measurement",
"icon": "mdi:water-alert",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
# Fault binary sensors
- action: mqtt.publish
data:
topic: "homeassistant/binary_sensor/campsite/water_heater_dsi_fault/config"
retain: true
payload: >-
{"name": "Water Heater DSI Fault",
"unique_id": "campsite_water_heater_dsi_fault",
"state_topic": "campsite/binary_sensor/water_heater_dsi_fault/state",
"payload_on": "ON", "payload_off": "OFF",
"device_class": "problem",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
- action: mqtt.publish
data:
topic: "homeassistant/binary_sensor/campsite/onecontrol_system_fault/config"
retain: true
payload: >-
{"name": "System Fault",
"unique_id": "campsite_onecontrol_system_fault",
"state_topic": "campsite/binary_sensor/onecontrol_system_fault/state",
"payload_on": "ON", "payload_off": "OFF",
"device_class": "problem",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
- action: mqtt.publish
data:
topic: "homeassistant/binary_sensor/campsite/furnace_dsi_fault/config"
retain: true
payload: >-
{"name": "Furnace DSI Fault",
"unique_id": "campsite_furnace_dsi_fault",
"state_topic": "campsite/binary_sensor/furnace_dsi_fault/state",
"payload_on": "ON", "payload_off": "OFF",
"device_class": "problem",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
# Running/heating status binary sensors
- action: mqtt.publish
data:
topic: "homeassistant/binary_sensor/campsite/water_heater_heating/config"
retain: true
payload: >-
{"name": "Water Heater Heating",
"unique_id": "campsite_water_heater_heating",
"state_topic": "campsite/binary_sensor/water_heater_heating/state",
"payload_on": "ON", "payload_off": "OFF",
"device_class": "running",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
- action: mqtt.publish
data:
topic: "homeassistant/binary_sensor/campsite/furnace_running/config"
retain: true
payload: >-
{"name": "Furnace Running",
"unique_id": "campsite_furnace_running",
"state_topic": "campsite/binary_sensor/furnace_running/state",
"payload_on": "ON", "payload_off": "OFF",
"device_class": "running",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
# Awning
- action: mqtt.publish
data:
topic: "homeassistant/cover/campsite/awning/config"
retain: true
payload: >-
{"name": "Awning",
"unique_id": "campsite_awning",
"state_topic": "campsite/cover/awning/state",
"command_topic": "campsite/cover/awning/set",
"optimistic": true,
"payload_open": null,
"device_class": "awning",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}
# Publish current states immediately after discovery
- delay: "00:00:02"
- action: mqtt.publish
data:
topic: "campsite/switch/water_heater/state"
retain: true
payload: "{{ 'ON' if is_state('switch.onecontrol_can_water_heater', 'on') else 'OFF' }}"
- action: mqtt.publish
data:
topic: "campsite/switch/exterior_lights/state"
retain: true
payload: "{{ 'ON' if is_state('switch.onecontrol_can_exterior_lights', 'on') else 'OFF' }}"
- action: mqtt.publish
data:
topic: "campsite/switch/interior_lights/state"
retain: true
payload: "{{ 'ON' if is_state('switch.onecontrol_can_interior_lights', 'on') else 'OFF' }}"
- action: mqtt.publish
data:
topic: "campsite/sensor/battery_voltage/state"
retain: true
payload: "{{ states('sensor.onecontrol_can_battery_voltage') }}"
- action: mqtt.publish
data:
topic: "campsite/sensor/fresh_water_tank/state"
retain: true
payload: "{{ states('sensor.onecontrol_can_fresh_water_tank') }}"
- action: mqtt.publish
data:
topic: "campsite/sensor/grey_tank_1/state"
retain: true
payload: "{{ states('sensor.onecontrol_can_grey_tank_1') }}"
- action: mqtt.publish
data:
topic: "campsite/sensor/grey_tank_2/state"
retain: true
payload: "{{ states('sensor.onecontrol_can_grey_tank_2') }}"
- action: mqtt.publish
data:
topic: "campsite/sensor/black_tank/state"
retain: true
payload: "{{ states('sensor.onecontrol_can_black_tank') }}"
- action: mqtt.publish
data:
topic: "campsite/binary_sensor/water_heater_dsi_fault/state"
retain: true
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_water_heater_dsi_fault', 'on') else 'OFF' }}"
- action: mqtt.publish
data:
topic: "campsite/binary_sensor/onecontrol_system_fault/state"
retain: true
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_onecontrol_system_fault', 'on') else 'OFF' }}"
- action: mqtt.publish
data:
topic: "campsite/binary_sensor/furnace_dsi_fault/state"
retain: true
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_furnace_dsi_fault', 'on') else 'OFF' }}"
- action: mqtt.publish
data:
topic: "campsite/binary_sensor/water_heater_heating/state"
retain: true
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_water_heater_heating', 'on') else 'OFF' }}"
- action: mqtt.publish
data:
topic: "campsite/binary_sensor/furnace_running/state"
retain: true
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_furnace_running', 'on') else 'OFF' }}"
- action: mqtt.publish
data:
topic: "campsite/cover/awning/state"
retain: true
payload: "{{ states('cover.onecontrol_can_awning') }}"
# --- Availability tracking (CAN node up/down) ---
- id: campsite_forward_availability
alias: "MQTT Bridge: Forward Availability"
triggers:
- trigger: state
entity_id: sensor.onecontrol_can_battery_voltage
to: "unavailable"
- trigger: state
entity_id: sensor.onecontrol_can_battery_voltage
from: "unavailable"
actions:
- action: mqtt.publish
data:
topic: "campsite/onecontrol/availability"
retain: true
payload: >-
{{ 'offline' if trigger.to_state.state == 'unavailable' else 'online' }}
# --- State change forwarding ---
- id: campsite_forward_switch_states
alias: "MQTT Bridge: Forward Switch States"
triggers:
- trigger: state
entity_id:
- switch.onecontrol_can_water_heater
- switch.onecontrol_can_exterior_lights
- switch.onecontrol_can_interior_lights
conditions:
- condition: template
value_template: "{{ trigger.to_state.state in ['on', 'off'] }}"
actions:
- action: mqtt.publish
data:
topic: "campsite/switch/{{ trigger.to_state.object_id | replace('onecontrol_can_', '') }}/state"
retain: true
payload: "{{ trigger.to_state.state | upper }}"
- id: campsite_forward_sensor_states
alias: "MQTT Bridge: Forward Sensor States"
triggers:
- trigger: state
entity_id:
- sensor.onecontrol_can_battery_voltage
- sensor.onecontrol_can_fresh_water_tank
- sensor.onecontrol_can_grey_tank_1
- sensor.onecontrol_can_grey_tank_2
- sensor.onecontrol_can_black_tank
conditions:
- condition: template
value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}"
actions:
- action: mqtt.publish
data:
topic: "campsite/sensor/{{ trigger.to_state.object_id | replace('onecontrol_can_', '') }}/state"
retain: true
payload: "{{ trigger.to_state.state }}"
- id: campsite_forward_binary_sensor_states
alias: "MQTT Bridge: Forward Fault States"
triggers:
- trigger: state
entity_id:
- binary_sensor.onecontrol_can_water_heater_dsi_fault
- binary_sensor.onecontrol_can_onecontrol_system_fault
- binary_sensor.onecontrol_can_furnace_dsi_fault
- binary_sensor.onecontrol_can_water_heater_heating
- binary_sensor.onecontrol_can_furnace_running
conditions:
- condition: template
value_template: "{{ trigger.to_state.state in ['on', 'off'] }}"
actions:
- action: mqtt.publish
data:
topic: "campsite/binary_sensor/{{ trigger.to_state.object_id | replace('onecontrol_can_', '') }}/state"
retain: true
payload: "{{ trigger.to_state.state | upper }}"
- id: campsite_forward_cover_states
alias: "MQTT Bridge: Forward Cover States"
triggers:
- trigger: state
entity_id:
- cover.onecontrol_can_awning
conditions:
- condition: template
value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}"
actions:
- action: mqtt.publish
data:
topic: "campsite/cover/{{ trigger.to_state.object_id | replace('onecontrol_can_', '') }}/state"
retain: true
payload: "{{ trigger.to_state.state }}"
# --- Incoming command handling ---
# Explicit allowlists — the wildcard subscription must never widen the bridge
# beyond the 3 switches + awning it intends to expose. A future debug/test
# entity on the ESPHome node must NOT become remotely controllable for free
# (the firmware command_guard is the backstop, not the only gate).
- id: campsite_handle_switch_commands
alias: "MQTT Bridge: Handle Switch Commands"
triggers:
- trigger: mqtt
topic: "campsite/switch/+/set"
conditions:
- condition: template
value_template: >-
{{ trigger.topic.split('/')[2] in
['water_heater', 'exterior_lights', 'interior_lights'] }}
- condition: template
value_template: "{{ trigger.payload | lower in ['on', 'off'] }}"
actions:
- action: "switch.turn_{{ trigger.payload | lower }}"
target:
entity_id: "switch.onecontrol_can_{{ trigger.topic.split('/')[2] }}"
- id: campsite_handle_cover_commands
alias: "MQTT Bridge: Handle Cover Commands"
triggers:
- trigger: mqtt
topic: "campsite/cover/+/set"
conditions:
- condition: template
value_template: "{{ trigger.topic.split('/')[2] == 'awning' }}"
- condition: template
value_template: "{{ trigger.payload | lower in ['open', 'close', 'stop'] }}"
actions:
- action: "cover.{{ trigger.payload | lower }}_cover"
target:
entity_id: "cover.onecontrol_can_{{ trigger.topic.split('/')[2] }}"