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
state + what's left.
## FLASHED & READ-VERIFIED (2026-06-12, afternoon)
The node is built, flashed over USB, on the bus, and on WiFi
(`192.168.69.18`, hostname `onecontrol-canbus`, adoptable via the API key in
`secrets.yaml`). **All 11 read entities verified live over the native API**:
battery 13.27 V, tanks fresh 0 / black 33 / grey1 33 / grey2 100 %, interior
lights on, exterior/heater off, both fault flags clear, awning idle.
What changed this session:
- `secrets.yaml` filled (WiFi + fallback AP from the shared gazebo proxy creds).
- **Battery voltage decode finished** (remaining-work item 4): matches the
page-`0x11` `00 2B …` telemetry frame, `b2..b3` BE / 256 — reads 13.27 V.
- **Toolchain fixes** (ESPHome moved since the last build; it no longer compiled):
the `g_node_type` array global's bare `{}` initializer was ambiguous (named the
type), and the awning cover enum is `COVER_OPERATION_OPENING/CLOSING` (no `IS_`).
- **Dual `on_frame` trigger** — this build filters `on_frame` by frame type, so a
single `use_extended_id: true` trigger only saw the 29-bit frames; the 11-bit
read broadcasts (tanks/lights/heater/awning) were silently dropped. Added a
second `use_extended_id: false` trigger sharing the same lambda via a YAML
anchor. (This was the exact risk the bus-config note called out.)
- **Logger** — raised the `canbus` component to INFO and silenced the per-frame
dump; at DEBUG it logged ~50 frames/s and saturated the 115200 serial link.
Still TODO (needs the operator in the loop — see "Remaining work" items 56):
command-path verification (toggle a light) and the attended awning test.
## Current status (2026-06-12)
- **Message format documented and confirmed against live captures.** The bus is
@@ -42,31 +68,54 @@ state + what's left.
## Remaining work (in order)
1. **`secrets.yaml`** — copy `esphome/secrets.yaml.example`, fill in WiFi, the
ESPHome `api_key`, and the fallback-AP password. It's git-ignored.
2. **First flash over USB:** `esphome run esphome/onecontrol-canbus.yaml` (pick
the serial port). OTA after that. Mirrors the gazebo-fan-proxy workflow.
3. **Confirm the read entities populate** in HA once it's on the bus: the four
tanks and the two light switches publish from the page-3 broadcasts. Watch the
DEBUG frame dump (`logger: level: DEBUG`) to confirm frames are decoded; drop
to INFO when happy.
4. **Finish the two open read items in the YAML lambda:**
- **Battery voltage** — rides a 29-bit telemetry frame (src `7D`/`AE`,
page `0x11`), bytes 23 big-endian / 256 = volts. Match that frame and
publish `battery_voltage`. (See README "29-bit extended frames".)
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.
5. **Verify the command path** from HA: toggle Interior/Exterior Lights and the
Water Heater. The `send_load_command` script + `on_frame` handler do the
exchange and send the opcode.
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. **(Optional) Surface at the campsite HA + bridge home** like the gazebo fans /
OneControl BLE devices, if you want these in the home dashboard too.
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.
@@ -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
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
@@ -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
= `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).
+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** ✓ |
| `95` | **water heater** | type 0x1E; toggle test t≈8594 s |
| `61` | **water pump** | type 0x1E; toggle test 2026-06-11 (on 13.5s / off 23.8s) ✓ |
| `89` | 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 |
| `6A`, `7F`, `9C` | slide / jacks / movement class | type 0x21, untested |
| `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 —
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
watching it.
page-42/43 exchange.
**Awning motion is HOLD-TO-RUN, and auto-retract is live (2026-07-01).** A single
authenticated opcode runs the motor only ~1s (it's a "keep the button held"
signal, not a latch). The OEM app sustains motion by **streaming after one auth**:
opcode `03F2<node>02` every ~110ms **plus** a page-44 keepalive `03F0<node>44`
payload `00 04` every ~510ms — **no per-command re-auth during the run** (a cold
opcode with no session is still ignored; the auth just opens the motion session).
Our ESPHome node reproduces this with its own controller identity (opcode
`0006 75 02`, keepalive `0004 75 44`) — proven to sustain ~6.5s of continuous
retract on 2026-07-01.
The awning has no position feedback, but the **motor current** rides page-3 b2-3
(BE, raw counts): ~<1550 running incl. inrush, tapering to ~350500 near closed,
then a sharp ramp to a **~4200 plateau at the fully-closed hard stop** (captures
`awning-fullretract-2026-07-01_*.log`). The node's **auto-retract** (cover CLOSE)
streams the retract, watches current at 20Hz, and cuts at `cur>2500` for 3 frames
(~150ms) — firing ~0.3s into the stall ramp, before the motor sits hard-stalled —
then marks the cover CLOSED (the one direction with a real end-stop). Backstops:
70s timeout, motion-lost detector, and stop-streaming-stops-the-motor (hold-to-run
fail-safe). OPEN stays a single ~1s jog (no safe end-stop for extend). See
`esphome/onecontrol-canbus.yaml` (`awning_auto_retract` script + streamer
`interval` + the case `0x75` stall gate).
**Bottom line: read is fully open** (all sensors + states from broadcasts, no
authentication) **and command is implemented and proven** (`ids_can_auth.py` +
`idscan_cmd.py`). The CAN path can both sense and operate the system, so the
Bluetooth integration is no longer needed for control. Next step: fold the
challenge-response into the ESPHome node's `switch`/`light`/`cover` actions (the
opcode just needs the page-42/43 exchange in front of it).
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
+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:
logger:
level: DEBUG # DEBUG so the on_frame ESP_LOGD frame dump is visible
# during bring-up. Drop to INFO once the map is solid.
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:
@@ -71,11 +77,17 @@ canbus:
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:
then: &decode_frame
- lambda: |-
// `can_id` and `x` (data bytes) are provided by the trigger.
uint32_t id = can_id;
@@ -135,11 +147,14 @@ canbus:
id(g_node_type)[node] = x[3];
}
// Frame dump — comment out once the map is trustworthy.
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);
// 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%).
@@ -157,17 +172,60 @@ canbus:
// 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); 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)
// / C3 retracting (closing). Reflect motion onto the cover so HA
// shows open/opening/closing; assumed_state fills the resting pos.
case 0x75:
if (x[0] == 0xC2) id(awning).current_operation = COVER_OPERATION_IS_OPENING;
else if (x[0] == 0xC3) id(awning).current_operation = COVER_OPERATION_IS_CLOSING;
else id(awning).current_operation = COVER_OPERATION_IDLE;
// / 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.)
}
}
@@ -180,8 +238,23 @@ canbus:
}
// Battery voltage rides 29-bit telemetry frames (src 7D/AE, page
// 0x11), payload 00 2B 0D 4x ..; b2..b3 (BE) / 256 = volts.
// TODO: match the exact source frame and publish battery_voltage.
// 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"
@@ -205,7 +278,33 @@ globals:
# seen). Feeds the motor-output safety gate in command_guard.h.
- id: g_node_type
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:
- id: send_load_command
@@ -237,11 +336,16 @@ script:
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 ~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
# successful exchange short-circuits the remaining iterations.
- repeat:
count: 3
count: 8
then:
- if:
condition:
@@ -252,16 +356,84 @@ script:
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: 100ms
- delay: 150ms
- if:
condition:
lambda: 'return id(g_cmd_pending);'
then:
- 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_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)
# ---------------------------------------------------------------------------
@@ -273,6 +445,13 @@ sensor:
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
@@ -293,6 +472,15 @@ sensor:
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)
@@ -302,6 +490,18 @@ binary_sensor:
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
@@ -319,14 +519,22 @@ binary_sensor:
# 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:true for now — the page-3 (b0 bit0) read-back above already publishes
# true module state, so these can switch to optimistic:false once verified.
# 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: true
optimistic: false
restore_mode: DISABLED
turn_on_action:
- script.execute: { id: send_load_command, node: 0x2A, op: 1 }
turn_off_action:
@@ -335,7 +543,8 @@ switch:
- platform: template
name: "Interior Lights"
id: interior_lights
optimistic: true
optimistic: false
restore_mode: DISABLED
turn_on_action:
- script.execute: { id: send_load_command, node: 0xF8, op: 1 }
turn_off_action:
@@ -344,7 +553,8 @@ switch:
- platform: template
name: "Water Heater"
id: water_heater
optimistic: true
optimistic: false
restore_mode: DISABLED
turn_on_action:
- script.execute: { id: send_load_command, node: 0x95, op: 1 }
turn_off_action:
@@ -360,12 +570,16 @@ switch:
# (no position slider); current_operation is published from the page-3 motion
# byte in the dispatcher above.
#
# ⚠️ FIRST ACTUATION MUST BE ATTENDED. We haven't confirmed whether one command
# latches the motor (runs to the travel limit) or is hold-to-run (the OEM app
# streamed repeats, which hints at hold-to-run). The single-shot command here is
# the safe default: if hold-to-run, the awning just moves a little and stops on
# its own — it can't run away. If it under-travels, stream the command (repeat
# send_load_command until stop) — do that change only after watching it move.
# 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
@@ -373,9 +587,7 @@ cover:
id: awning
device_class: awning
assumed_state: true
open_action:
- script.execute: { id: send_load_command, node: 0x75, op: 1 }
close_action:
- script.execute: { id: send_load_command, node: 0x75, op: 2 }
- script.execute: awning_auto_retract
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] }}"