Compare commits
11
Commits
9a5ee30db9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c6721953a | ||
|
|
a56f550636 | ||
|
|
6733a79390 | ||
|
|
5b9198878c | ||
|
|
38c4ba485d | ||
|
|
2dd884af3c | ||
|
|
257064289c | ||
|
|
4bf74a6194 | ||
|
|
63034e17ce | ||
|
|
9d6999a5bc | ||
|
|
e179f1f1fd |
@@ -154,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
|
||||||
@@ -161,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
@@ -65,7 +65,7 @@ mapped**, and the **command path is implemented and confirmed by live actuation
|
|||||||
| `F8` | **interior lights** | type 0x1E; toggle test t≈51–61 s; **operated live 2026-06-12** ✓ |
|
| `F8` | **interior lights** | type 0x1E; toggle test t≈51–61 s; **operated live 2026-06-12** ✓ |
|
||||||
| `95` | **water heater** | type 0x1E; toggle test t≈85–94 s |
|
| `95` | **water heater** | type 0x1E; toggle test t≈85–94 s |
|
||||||
| `61` | **water pump** | type 0x1E; toggle test 2026-06-11 (on 13.5s / off 23.8s) ✓ |
|
| `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 ~350–500 near closed,
|
||||||
|
then a sharp ramp to a **~4200 plateau at the fully-closed hard stop** (captures
|
||||||
|
`awning-fullretract-2026-07-01_*.log`). The node's **auto-retract** (cover CLOSE)
|
||||||
|
streams the retract, watches current at 20Hz, and cuts at `cur>2500` for 3 frames
|
||||||
|
(~150ms) — firing ~0.3s into the stall ramp, before the motor sits hard-stalled —
|
||||||
|
then marks the cover CLOSED (the one direction with a real end-stop). Backstops:
|
||||||
|
70s timeout, motion-lost detector, and stop-streaming-stops-the-motor (hold-to-run
|
||||||
|
fail-safe). OPEN stays a single ~1s jog (no safe end-stop for extend). See
|
||||||
|
`esphome/onecontrol-canbus.yaml` (`awning_auto_retract` script + streamer
|
||||||
|
`interval` + the case `0x75` stall gate).
|
||||||
|
|
||||||
**Bottom line: read is fully open** (all sensors + states from broadcasts, no
|
**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
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Awning "Closed" Sensor — Spec (Option 1: standalone WiFi reed node)
|
||||||
|
|
||||||
|
**Goal:** give the campsite awning a *true* stowed/not-stowed state instead of the
|
||||||
|
`assumed_state` the CAN cover currently reports. A magnetic reed switch at the
|
||||||
|
fully-retracted position, read by a small ESP running ESPHome over the existing
|
||||||
|
`OmnissiahsReach` WiFi — **no Zigbee mesh, no coordinator, no new ecosystem.**
|
||||||
|
|
||||||
|
This is "Option 1" (standalone node). Option 2 (wire the reed back to a spare GPIO
|
||||||
|
on the CAN node `192.168.69.18` so the cover entity itself becomes truthful) is the
|
||||||
|
more-integrated alternative but needs a 2-wire run from the awning to the control
|
||||||
|
panel; it's noted at the end. Pairs with the current-sensing auto-retract already
|
||||||
|
in `esphome/onecontrol-canbus.yaml` (README "awning" section): current does the
|
||||||
|
*dynamic* stop during retract, the reed gives *persistent* truth afterward.
|
||||||
|
|
||||||
|
## What it delivers (and what it doesn't)
|
||||||
|
|
||||||
|
- **Reliable CLOSED (stowed) detection.** Awnings extend to an arbitrary spot, so
|
||||||
|
there's no meaningful "fully open" endpoint — but the fully-retracted position is
|
||||||
|
hard and repeatable. That's the state that matters (don't drive off / don't let
|
||||||
|
it flap): a single reed there answers *stowed vs. not*.
|
||||||
|
- It does **not** measure extension %. If you ever want an "opening/closing"
|
||||||
|
animation you already get that from the CAN motion byte; the reed just pins down
|
||||||
|
the resting truth.
|
||||||
|
|
||||||
|
## Bill of materials (~$15–20)
|
||||||
|
|
||||||
|
| Part | Notes |
|
||||||
|
|------|-------|
|
||||||
|
| **ESP32-C3 SuperMini** (or Wemos D1 mini / ESP8266) | Tiny, WiFi, 3.3 V logic. C3 recommended (native ESP-IDF, cheap, plenty of GPIO). |
|
||||||
|
| **Weatherproof reed switch** | RV-compartment / garage-door style, **potted + leaded**, normally-open. Get a wide-gap one (pull-in ≥ 15–20 mm) so alignment is forgiving. |
|
||||||
|
| **Bar/block magnet** | Match/exceed the reed's rated gap. Rare-earth block for margin. |
|
||||||
|
| **12 V → 5 V buck** (MP1584 / mini360 or an automotive USB buck) | Tap the awning motor's 12 V; always-on beats battery/deep-sleep for instant state and no maintenance. |
|
||||||
|
| **Small IP65 enclosure + gland** | Mount the ESP + buck out of the weather. |
|
||||||
|
| Fused 12 V tap (inline 1 A) | Protect the tap off the motor harness. |
|
||||||
|
|
||||||
|
## Wiring
|
||||||
|
|
||||||
|
```
|
||||||
|
awning 12V harness --[inline 1A fuse]--> buck IN+ buck OUT+ (5V) --> ESP 5V
|
||||||
|
chassis GND -----> buck IN- ----buck OUT- ------> ESP GND
|
||||||
|
|
||||||
|
reed leg A --> ESP GPIO4
|
||||||
|
reed leg B --> ESP GND
|
||||||
|
```
|
||||||
|
|
||||||
|
- Reed to GND with the **internal pull-up** enabled: pin idles HIGH (not stowed),
|
||||||
|
goes LOW when the magnet is present (stowed). The ESPHome config below inverts
|
||||||
|
that so the sensor reads ON = stowed.
|
||||||
|
- No external resistor needed. An optional 0.1 µF across the reed helps debounce,
|
||||||
|
but the software `delayed_on/off` below is enough.
|
||||||
|
- Avoid the C3 strapping pins (GPIO2, 8, 9); GPIO4 is safe. GPIO8 has the onboard
|
||||||
|
LED if you want a status blink.
|
||||||
|
|
||||||
|
## Mounting
|
||||||
|
|
||||||
|
- **Magnet on the moving part** (the lead rail / roller endcap that seats when
|
||||||
|
stowed); **reed on the fixed part** (mounting rail or a bracket on the coach).
|
||||||
|
- Aim for the magnet to land within the reed's pull-in gap when the awning is
|
||||||
|
pulled in tight — the same "closed" position the current-stall stop lands on.
|
||||||
|
Expect a test-fit: mark where the rail seats, mount, confirm the sensor flips.
|
||||||
|
- Outdoor: use the potted reed, point the gland down, silicone the entry, and
|
||||||
|
strain-relief the lead so awning motion doesn't fatigue it.
|
||||||
|
|
||||||
|
## ESPHome config
|
||||||
|
|
||||||
|
New device `awning-sensor` (mirror the conventions in `onecontrol-canbus.yaml` /
|
||||||
|
`gazebo-fan-proxy.yaml`: `secrets.yaml` for WiFi + API key, OTA after first USB
|
||||||
|
flash, fallback AP + captive portal). DHCP on `OmnissiahsReach`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
substitutions:
|
||||||
|
name: awning-sensor
|
||||||
|
friendly_name: Awning Sensor
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
friendly_name: ${friendly_name}
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32-c3-devkitm-1
|
||||||
|
framework:
|
||||||
|
type: esp-idf
|
||||||
|
|
||||||
|
logger:
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_key
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid # OmnissiahsReach
|
||||||
|
password: !secret wifi_password
|
||||||
|
ap:
|
||||||
|
ssid: "Awning-Sensor Fallback"
|
||||||
|
password: !secret fallback_ap_password
|
||||||
|
captive_portal:
|
||||||
|
|
||||||
|
binary_sensor:
|
||||||
|
- platform: gpio
|
||||||
|
name: "Awning Stowed"
|
||||||
|
id: awning_stowed
|
||||||
|
pin:
|
||||||
|
number: GPIO4
|
||||||
|
mode:
|
||||||
|
input: true
|
||||||
|
pullup: true
|
||||||
|
inverted: true # reed->GND: magnet present = LOW = ON (stowed)
|
||||||
|
filters:
|
||||||
|
- delay_on: 200ms # debounce the rail seating
|
||||||
|
- delay_off: 200ms
|
||||||
|
# ON = awning pulled in tight (stowed / closed)
|
||||||
|
# OFF = not stowed (extended, or mid-travel)
|
||||||
|
```
|
||||||
|
|
||||||
|
`secrets.yaml` needs `wifi_ssid`, `wifi_password`, `fallback_ap_password`,
|
||||||
|
`api_key` (generate with `esphome`). First flash over USB (`esphome run ... `),
|
||||||
|
thereafter OTA — same as the other campsite nodes.
|
||||||
|
|
||||||
|
## HA integration
|
||||||
|
|
||||||
|
The reed is a separate device from the CAN node, so it lands as its own entity:
|
||||||
|
|
||||||
|
1. **Campsite Pi** auto-discovers it via the ESPHome integration →
|
||||||
|
`binary_sensor.awning_sensor_awning_stowed` (rename to
|
||||||
|
`binary_sensor.awning_stowed`). ON = stowed.
|
||||||
|
2. **Bridge to home HA** — add to `/config/packages/mqtt_bridge.yaml` (repo:
|
||||||
|
`canbus/ha/mqtt_bridge_onecontrol.yaml`) next to the other campsite entities:
|
||||||
|
a discovery block (`homeassistant/binary_sensor/campsite/awning_stowed/config`,
|
||||||
|
device `campsite_onecontrol`, `device_class: "opening"` reads on=open/off=closed,
|
||||||
|
or leave classless for a plain stowed/clear), plus a state-forward trigger on
|
||||||
|
`binary_sensor.awning_stowed` → `campsite/binary_sensor/awning_stowed/state`.
|
||||||
|
Appears on home as `binary_sensor.campsite_onecontrol_awning_stowed`.
|
||||||
|
3. Add the tile to home HA **Overview → Camper → OneControl** section.
|
||||||
|
|
||||||
|
### Optional: make the cover *truthful* (template cover)
|
||||||
|
|
||||||
|
Right now `cover.onecontrol_can_awning` is `assumed_state`. With the reed you can
|
||||||
|
wrap it in a template cover on the Pi whose closed state is the *real* reed, while
|
||||||
|
commands still hit the CAN cover:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cover:
|
||||||
|
- platform: template
|
||||||
|
covers:
|
||||||
|
awning_true:
|
||||||
|
friendly_name: "Awning"
|
||||||
|
device_class: awning
|
||||||
|
value_template: "{{ 'closed' if is_state('binary_sensor.awning_stowed','on') else 'open' }}"
|
||||||
|
close_cover:
|
||||||
|
- action: cover.close_cover # -> CAN cover auto-retract
|
||||||
|
target: { entity_id: cover.onecontrol_can_awning }
|
||||||
|
stop_cover:
|
||||||
|
- action: cover.stop_cover
|
||||||
|
target: { entity_id: cover.onecontrol_can_awning }
|
||||||
|
```
|
||||||
|
|
||||||
|
Bridge `cover.awning_true` to home instead of the raw CAN cover, and you get a
|
||||||
|
cover that shows genuine open/closed. (Keep `payload_open` disabled as today.)
|
||||||
|
|
||||||
|
### Automations this unlocks
|
||||||
|
|
||||||
|
- **Left-it-out alarm:** awning `not stowed` + (leaving geofence / wind gust > X /
|
||||||
|
rain) → Discord ping, or auto-fire `cover.close_cover`.
|
||||||
|
- **Confirm the retract actually seated:** after an auto-retract, if the current
|
||||||
|
stall fired but the reed is still OFF a few seconds later → alert (mis-seat).
|
||||||
|
|
||||||
|
> **Option-1 limitation:** because the reed is on a *separate* device from the CAN
|
||||||
|
> node, the auto-retract's stall gate can't consume it on-device — any
|
||||||
|
> reed↔retract logic is a cross-device HA automation, not firmware. If you want
|
||||||
|
> the ESP to stop the motor *on the reed itself* (belt-and-suspenders with the
|
||||||
|
> current stall), that's Option 2 (wire the reed to a spare GPIO on the CAN node
|
||||||
|
> `192.168.69.18`; the cover entity then reads it directly and becomes truthful
|
||||||
|
> with no template cover).
|
||||||
|
|
||||||
|
## Touchpoints checklist (campsite ESPHome node)
|
||||||
|
|
||||||
|
- [ ] `secrets.yaml` (WiFi = OmnissiahsReach, API key, fallback AP pw)
|
||||||
|
- [ ] USB flash once, confirm on `OmnissiahsReach` (DHCP `192.168.69.x`); OTA after
|
||||||
|
- [ ] Adopt in campsite Pi HA (ESPHome integration), rename entity
|
||||||
|
- [ ] Mount reed + magnet, verify it flips exactly at the stowed position
|
||||||
|
- [ ] `mqtt_bridge.yaml`: discovery + state-forward → home HA; add dashboard tile
|
||||||
|
- [ ] (optional) template cover `awning_true`; bridge it instead of the raw cover
|
||||||
|
- [ ] (optional) automations: left-out alarm, retract-seat confirmation
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
1. USB-flash, join WiFi, adopt in HA. Toggle by hand with a magnet → `awning_stowed`
|
||||||
|
flips ON/OFF with the 200 ms debounce.
|
||||||
|
2. Mount, retract via the dashboard (auto-retract): at full stow the reed should go
|
||||||
|
ON right around when the current-stall stop fires.
|
||||||
|
3. Extend at the OEM wall switch → reed OFF. Confirm home HA mirrors it.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -172,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
|
||||||
|
// 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;
|
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 if (x[0] == 0xC3) id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING;
|
||||||
else id(awning).current_operation = esphome::cover::COVER_OPERATION_IDLE;
|
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.)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,6 +282,29 @@ globals:
|
|||||||
# GlobalsComponent<T>(T) and (std::array<...>) constructors under the current
|
# GlobalsComponent<T>(T) and (std::array<...>) constructors under the current
|
||||||
# toolchain. zero-initialized -> every node starts as type 0 (not yet seen).
|
# toolchain. zero-initialized -> every node starts as type 0 (not yet seen).
|
||||||
initial_value: 'std::array<uint8_t, 256>{}'
|
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
|
||||||
@@ -300,6 +366,74 @@ script:
|
|||||||
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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -338,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)
|
||||||
@@ -347,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
|
||||||
@@ -368,11 +523,18 @@ binary_sensor:
|
|||||||
# state (verified live 2026-06-12), so HA shows ground truth — a dropped command
|
# 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.
|
# 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: false
|
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:
|
||||||
@@ -382,6 +544,7 @@ switch:
|
|||||||
name: "Interior Lights"
|
name: "Interior Lights"
|
||||||
id: interior_lights
|
id: interior_lights
|
||||||
optimistic: false
|
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:
|
||||||
@@ -391,6 +554,7 @@ switch:
|
|||||||
name: "Water Heater"
|
name: "Water Heater"
|
||||||
id: water_heater
|
id: water_heater
|
||||||
optimistic: false
|
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:
|
||||||
@@ -406,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
|
||||||
@@ -419,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
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Campsite presence / occupancy package.
|
||||||
|
#
|
||||||
|
# Occupancy truth: the gas water heater — always on while camping, always
|
||||||
|
# turned off when leaving (propane). WiFi presence (MikroTik Chateau
|
||||||
|
# device_trackers) is the second, internet-independent signal; until that
|
||||||
|
# integration is set up the wifi sensor reads `unavailable` and everything
|
||||||
|
# downstream treats it as "no veto" (failsafes key on GPS, dead-man stays
|
||||||
|
# quiet).
|
||||||
|
#
|
||||||
|
# Deploy to Pi: /config/packages/campsite_presence.yaml
|
||||||
|
# Repo copy: canbus/ha/campsite_presence.yaml
|
||||||
|
#
|
||||||
|
# ⚠️ FIRST DEPLOY: `template:` may be a new domain on this instance — full
|
||||||
|
# core restart, then confirm the sensors exist (see dsi_fault_alert.yaml for
|
||||||
|
# the reload_all trap).
|
||||||
|
|
||||||
|
template:
|
||||||
|
- binary_sensor:
|
||||||
|
- name: "RV Occupied"
|
||||||
|
unique_id: campsite_rv_occupied
|
||||||
|
device_class: occupancy
|
||||||
|
icon: mdi:campfire
|
||||||
|
# unavailable/unknown heater (node down) -> sensor unavailable, not a
|
||||||
|
# false "left": availability gates it.
|
||||||
|
availability: >-
|
||||||
|
{{ states('switch.onecontrol_can_water_heater')
|
||||||
|
not in ['unavailable', 'unknown'] }}
|
||||||
|
state: "{{ is_state('switch.onecontrol_can_water_heater', 'on') }}"
|
||||||
|
|
||||||
|
- name: "Phones On Campsite WiFi"
|
||||||
|
unique_id: campsite_phones_on_wifi
|
||||||
|
device_class: presence
|
||||||
|
icon: mdi:wifi-marker
|
||||||
|
# MikroTik trackers (renamed to these ids after the integration is
|
||||||
|
# added). While they don't exist the sensor is unavailable — that is
|
||||||
|
# load-bearing: the dead-man automation below triggers on a sustained
|
||||||
|
# 'off', which unavailable never satisfies.
|
||||||
|
availability: >-
|
||||||
|
{{ states('device_tracker.wes_phone_wifi')
|
||||||
|
not in ['unavailable', 'unknown']
|
||||||
|
or states('device_tracker.lindsey_iphone_wifi')
|
||||||
|
not in ['unavailable', 'unknown'] }}
|
||||||
|
state: >-
|
||||||
|
{{ is_state('device_tracker.wes_phone_wifi', 'home')
|
||||||
|
or is_state('device_tracker.lindsey_iphone_wifi', 'home') }}
|
||||||
|
|
||||||
|
automation:
|
||||||
|
# --- Bridge both presence sensors home (discovery + state) -------------
|
||||||
|
- id: campsite_presence_mqtt_discovery
|
||||||
|
alias: "Presence: Publish MQTT Discovery"
|
||||||
|
triggers:
|
||||||
|
- trigger: homeassistant
|
||||||
|
event: start
|
||||||
|
- trigger: event
|
||||||
|
event_type: automation_reloaded
|
||||||
|
- trigger: time_pattern
|
||||||
|
hours: "/6"
|
||||||
|
actions:
|
||||||
|
- delay: "00:00:07"
|
||||||
|
- action: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "homeassistant/binary_sensor/campsite/rv_occupied/config"
|
||||||
|
retain: true
|
||||||
|
payload: >-
|
||||||
|
{"name": "RV Occupied",
|
||||||
|
"unique_id": "campsite_rv_occupied",
|
||||||
|
"state_topic": "campsite/binary_sensor/rv_occupied/state",
|
||||||
|
"payload_on": "ON", "payload_off": "OFF",
|
||||||
|
"device_class": "occupancy",
|
||||||
|
"availability_topic": "campsite/onecontrol/availability",
|
||||||
|
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||||
|
- action: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "homeassistant/binary_sensor/campsite/phones_on_campsite_wifi/config"
|
||||||
|
retain: true
|
||||||
|
payload: >-
|
||||||
|
{"name": "Phones On Campsite WiFi",
|
||||||
|
"unique_id": "campsite_phones_on_wifi",
|
||||||
|
"state_topic": "campsite/binary_sensor/phones_on_campsite_wifi/state",
|
||||||
|
"payload_on": "ON", "payload_off": "OFF",
|
||||||
|
"payload_available": "online", "payload_not_available": "offline",
|
||||||
|
"availability_topic": "campsite/presence/wifi_availability",
|
||||||
|
"device_class": "presence",
|
||||||
|
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||||
|
- delay: "00:00:02"
|
||||||
|
- action: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "campsite/binary_sensor/rv_occupied/state"
|
||||||
|
retain: true
|
||||||
|
payload: "{{ 'ON' if is_state('binary_sensor.rv_occupied', 'on') else 'OFF' }}"
|
||||||
|
- action: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "campsite/presence/wifi_availability"
|
||||||
|
retain: true
|
||||||
|
payload: >-
|
||||||
|
{{ 'offline' if states('binary_sensor.phones_on_campsite_wifi')
|
||||||
|
in ['unavailable', 'unknown'] else 'online' }}
|
||||||
|
- action: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "campsite/binary_sensor/phones_on_campsite_wifi/state"
|
||||||
|
retain: true
|
||||||
|
payload: "{{ 'ON' if is_state('binary_sensor.phones_on_campsite_wifi', 'on') else 'OFF' }}"
|
||||||
|
|
||||||
|
- id: campsite_presence_forward_states
|
||||||
|
alias: "Presence: Forward States"
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id:
|
||||||
|
- binary_sensor.rv_occupied
|
||||||
|
- binary_sensor.phones_on_campsite_wifi
|
||||||
|
actions:
|
||||||
|
- action: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "campsite/binary_sensor/{{ trigger.to_state.object_id }}/state"
|
||||||
|
retain: true
|
||||||
|
payload: "{{ 'ON' if trigger.to_state.state == 'on' else 'OFF' }}"
|
||||||
|
- if:
|
||||||
|
- condition: template
|
||||||
|
value_template: "{{ trigger.to_state.object_id == 'phones_on_campsite_wifi' }}"
|
||||||
|
then:
|
||||||
|
- action: mqtt.publish
|
||||||
|
data:
|
||||||
|
topic: "campsite/presence/wifi_availability"
|
||||||
|
retain: true
|
||||||
|
payload: >-
|
||||||
|
{{ 'offline' if trigger.to_state.state
|
||||||
|
in ['unavailable', 'unknown'] else 'online' }}
|
||||||
|
|
||||||
|
# --- Local dead-man notify (works with the WG tunnel down) -------------
|
||||||
|
# Heater on but neither phone seen on campsite WiFi for 3 h. Notify-only:
|
||||||
|
# WiFi absence alone is not proof of departure (pool afternoon, dead
|
||||||
|
# battery), so the auto-off lives home-side where GPS can confirm.
|
||||||
|
- id: campsite_deadman_heater_notify
|
||||||
|
alias: "Presence: Dead-man heater notify (local)"
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id: binary_sensor.phones_on_campsite_wifi
|
||||||
|
to: "off"
|
||||||
|
for: "03:00:00"
|
||||||
|
conditions:
|
||||||
|
- condition: state
|
||||||
|
entity_id: binary_sensor.rv_occupied
|
||||||
|
state: "on"
|
||||||
|
actions:
|
||||||
|
- action: rest_command.discord_server_alerts
|
||||||
|
data:
|
||||||
|
message: >-
|
||||||
|
<@321798967669030912> 🤨 The water heater has been on for 3+ hours
|
||||||
|
with neither of your phones on the campsite WiFi. If you actually
|
||||||
|
left, you forgot the shutdown ritual again — kill the heater from
|
||||||
|
the dashboard. If you're at the pool, carry on.
|
||||||
|
mode: single
|
||||||
|
|
||||||
|
# --- Lot lights (Shelly) — PRE-STAGED, DISABLED until the relay exists --
|
||||||
|
# Enable + fix the entity id once the Shelly is installed in the shed
|
||||||
|
# outlet box. Mirrors the camper exterior-lights sunset/sunrise pattern
|
||||||
|
# (retry because nothing should be trusted fire-and-forget), gated on
|
||||||
|
# occupancy so the lot doesn't light up for an empty site.
|
||||||
|
- id: campsite_lot_lights_sunset
|
||||||
|
alias: "Lot lights on at sunset (occupied only)"
|
||||||
|
initial_state: false
|
||||||
|
triggers:
|
||||||
|
- trigger: sun
|
||||||
|
event: sunset
|
||||||
|
offset: 0
|
||||||
|
conditions:
|
||||||
|
- condition: state
|
||||||
|
entity_id: binary_sensor.rv_occupied
|
||||||
|
state: "on"
|
||||||
|
actions:
|
||||||
|
- repeat:
|
||||||
|
sequence:
|
||||||
|
- action: switch.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: switch.lot_lights
|
||||||
|
- delay: "00:00:05"
|
||||||
|
until:
|
||||||
|
- condition: template
|
||||||
|
value_template: >-
|
||||||
|
{{ is_state('switch.lot_lights', 'on') or repeat.index >= 3 }}
|
||||||
|
mode: single
|
||||||
|
|
||||||
|
- id: campsite_lot_lights_sunrise
|
||||||
|
alias: "Lot lights off at sunrise"
|
||||||
|
initial_state: false
|
||||||
|
triggers:
|
||||||
|
- trigger: sun
|
||||||
|
event: sunrise
|
||||||
|
offset: 0
|
||||||
|
actions:
|
||||||
|
- repeat:
|
||||||
|
sequence:
|
||||||
|
- action: switch.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: switch.lot_lights
|
||||||
|
- delay: "00:00:05"
|
||||||
|
until:
|
||||||
|
- condition: template
|
||||||
|
value_template: >-
|
||||||
|
{{ is_state('switch.lot_lights', 'off') or repeat.index >= 3 }}
|
||||||
|
mode: single
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Water-heater DSI fault → Discord ping in the server-alerts channel.
|
||||||
|
#
|
||||||
|
# The CAN node decodes the heater's DSI lockout (node 95 page-3 b0 bit5 —
|
||||||
|
# see canbus/README.md); when the burner fails to light (usually an empty
|
||||||
|
# propane tank) the module latches the fault and this fires the webhook.
|
||||||
|
#
|
||||||
|
# Deploy to Pi: /config/packages/dsi_fault_alert.yaml
|
||||||
|
# Repo copy: canbus/ha/dsi_fault_alert.yaml
|
||||||
|
#
|
||||||
|
# ⚠️ FIRST DEPLOY NEEDS A FULL CORE RESTART. reload_all only re-reads config
|
||||||
|
# for integrations that are already set up; rest_command was a brand-new domain
|
||||||
|
# on this instance, so until the restart the service didn't exist and every
|
||||||
|
# automation fire died with "Action rest_command.discord_server_alerts not
|
||||||
|
# found" — while check_config said valid. Verify with:
|
||||||
|
# curl .../api/services | jq '[.[].domain] | index("rest_command")'
|
||||||
|
# Secret: discord_server_alerts_webhook in the Pi's /config/secrets.yaml
|
||||||
|
# (NOT the Gatus webhook — that one posts somewhere Wes doesn't
|
||||||
|
# read; this one is confirmed delivering to the alerts channel)
|
||||||
|
|
||||||
|
rest_command:
|
||||||
|
discord_server_alerts:
|
||||||
|
url: !secret discord_server_alerts_webhook
|
||||||
|
method: POST
|
||||||
|
content_type: "application/json; charset=utf-8"
|
||||||
|
payload: >-
|
||||||
|
{"username": "Octavia",
|
||||||
|
"content": {{ message | tojson }},
|
||||||
|
"allowed_mentions": {"users": ["321798967669030912"]}}
|
||||||
|
|
||||||
|
automation:
|
||||||
|
# --- Octavia relay: anything on the broker can speak through the one
|
||||||
|
# verified webhook path. Home HA's failsafe automations publish here
|
||||||
|
# (campsite/octavia/say, payload = the message) since home has no
|
||||||
|
# file/packages access for its own rest_command. ---
|
||||||
|
- id: campsite_octavia_relay
|
||||||
|
alias: "Octavia: MQTT relay"
|
||||||
|
triggers:
|
||||||
|
- trigger: mqtt
|
||||||
|
topic: "campsite/octavia/say"
|
||||||
|
conditions:
|
||||||
|
- condition: template
|
||||||
|
value_template: "{{ trigger.payload | length > 0 and trigger.payload | length < 1900 }}"
|
||||||
|
actions:
|
||||||
|
- action: rest_command.discord_server_alerts
|
||||||
|
data:
|
||||||
|
message: "{{ trigger.payload }}"
|
||||||
|
mode: queued
|
||||||
|
max: 5
|
||||||
|
|
||||||
|
- id: camper_dsi_fault_discord
|
||||||
|
alias: "Camper: DSI fault → Discord"
|
||||||
|
description: >-
|
||||||
|
10s 'for' debounces a single glitched broadcast frame; a real lockout
|
||||||
|
is latched by the module so it easily survives the wait.
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id: binary_sensor.onecontrol_can_water_heater_dsi_fault
|
||||||
|
to: "on"
|
||||||
|
for: "00:00:10"
|
||||||
|
actions:
|
||||||
|
- action: rest_command.discord_server_alerts
|
||||||
|
data:
|
||||||
|
message: >-
|
||||||
|
<@321798967669030912> 🔥 **WATER HEATER DSI FAULT** — hey dumbass,
|
||||||
|
you're out of propane again. (Or the igniter finally died, but let's be honest,
|
||||||
|
it's the propane.) The heater is in lockout — swap the tank, then
|
||||||
|
cycle the heater off/on to relight.
|
||||||
|
mode: single
|
||||||
|
|
||||||
|
- id: camper_dsi_fault_cleared_discord
|
||||||
|
alias: "Camper: DSI fault cleared → Discord"
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id: binary_sensor.onecontrol_can_water_heater_dsi_fault
|
||||||
|
from: "on"
|
||||||
|
to: "off"
|
||||||
|
for: "00:00:10"
|
||||||
|
actions:
|
||||||
|
- action: rest_command.discord_server_alerts
|
||||||
|
data:
|
||||||
|
message: >-
|
||||||
|
✅ DSI fault cleared — the water heater is lighting again. Crisis
|
||||||
|
averted. Try to remember this feeling next time you eyeball the
|
||||||
|
tank gauge and say "eh, it's fine."
|
||||||
|
mode: single
|
||||||
|
|
||||||
|
# --- Furnace DSI fault (node 89). Same lockout scheme as the water heater
|
||||||
|
# (page-3 b0 bit5); see canbus/README.md. A furnace lockout means no forced-air
|
||||||
|
# heat — more urgent than the water heater on a cold night. ---
|
||||||
|
- id: camper_furnace_dsi_fault_discord
|
||||||
|
alias: "Camper: Furnace DSI fault → Discord"
|
||||||
|
description: >-
|
||||||
|
10s 'for' debounces a single glitched broadcast frame; a real lockout
|
||||||
|
is latched by the module so it easily survives the wait.
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id: binary_sensor.onecontrol_can_furnace_dsi_fault
|
||||||
|
to: "on"
|
||||||
|
for: "00:00:10"
|
||||||
|
actions:
|
||||||
|
- action: rest_command.discord_server_alerts
|
||||||
|
data:
|
||||||
|
message: >-
|
||||||
|
<@321798967669030912> 🥶 **FURNACE DSI FAULT** — the furnace tried to
|
||||||
|
light and gave up. That means no heat. Same usual suspect: you're out
|
||||||
|
of propane (or the igniter died, but c'mon). It's in lockout — swap the
|
||||||
|
tank, then cycle the thermostat to relight before everything inside
|
||||||
|
hits ambient.
|
||||||
|
mode: single
|
||||||
|
|
||||||
|
- id: camper_furnace_dsi_fault_cleared_discord
|
||||||
|
alias: "Camper: Furnace DSI fault cleared → Discord"
|
||||||
|
triggers:
|
||||||
|
- trigger: state
|
||||||
|
entity_id: binary_sensor.onecontrol_can_furnace_dsi_fault
|
||||||
|
from: "on"
|
||||||
|
to: "off"
|
||||||
|
for: "00:00:10"
|
||||||
|
actions:
|
||||||
|
- action: rest_command.discord_server_alerts
|
||||||
|
data:
|
||||||
|
message: >-
|
||||||
|
✅ Furnace fault cleared — it's lighting again and making heat. Enjoy
|
||||||
|
the warmth, and maybe glance at the propane gauge once in a while like
|
||||||
|
a functioning adult.
|
||||||
|
mode: single
|
||||||
@@ -11,7 +11,11 @@
|
|||||||
# Repo copy: canbus/ha/mqtt_bridge_onecontrol.yaml
|
# Repo copy: canbus/ha/mqtt_bridge_onecontrol.yaml
|
||||||
|
|
||||||
automation:
|
automation:
|
||||||
# --- MQTT Discovery publish on startup / reload / every 6h ---
|
# --- 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
|
- id: campsite_mqtt_discovery
|
||||||
alias: "MQTT Bridge: Publish Discovery"
|
alias: "MQTT Bridge: Publish Discovery"
|
||||||
triggers:
|
triggers:
|
||||||
@@ -20,7 +24,7 @@ automation:
|
|||||||
- trigger: event
|
- trigger: event
|
||||||
event_type: automation_reloaded
|
event_type: automation_reloaded
|
||||||
- trigger: time_pattern
|
- trigger: time_pattern
|
||||||
hours: "/6"
|
minutes: "/15"
|
||||||
actions:
|
actions:
|
||||||
- delay: "00:00:05"
|
- delay: "00:00:05"
|
||||||
- action: mqtt.publish
|
- action: mqtt.publish
|
||||||
@@ -165,6 +169,43 @@ automation:
|
|||||||
"device_class": "problem",
|
"device_class": "problem",
|
||||||
"availability_topic": "campsite/onecontrol/availability",
|
"availability_topic": "campsite/onecontrol/availability",
|
||||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
"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
|
# Awning
|
||||||
- action: mqtt.publish
|
- action: mqtt.publish
|
||||||
data:
|
data:
|
||||||
@@ -175,6 +216,8 @@ automation:
|
|||||||
"unique_id": "campsite_awning",
|
"unique_id": "campsite_awning",
|
||||||
"state_topic": "campsite/cover/awning/state",
|
"state_topic": "campsite/cover/awning/state",
|
||||||
"command_topic": "campsite/cover/awning/set",
|
"command_topic": "campsite/cover/awning/set",
|
||||||
|
"optimistic": true,
|
||||||
|
"payload_open": null,
|
||||||
"device_class": "awning",
|
"device_class": "awning",
|
||||||
"availability_topic": "campsite/onecontrol/availability",
|
"availability_topic": "campsite/onecontrol/availability",
|
||||||
"device": {"identifiers": ["campsite_onecontrol"]}}
|
"device": {"identifiers": ["campsite_onecontrol"]}}
|
||||||
@@ -230,6 +273,21 @@ automation:
|
|||||||
topic: "campsite/binary_sensor/onecontrol_system_fault/state"
|
topic: "campsite/binary_sensor/onecontrol_system_fault/state"
|
||||||
retain: true
|
retain: true
|
||||||
payload: "{{ 'ON' if is_state('binary_sensor.onecontrol_can_onecontrol_system_fault', 'on') else 'OFF' }}"
|
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
|
- action: mqtt.publish
|
||||||
data:
|
data:
|
||||||
topic: "campsite/cover/awning/state"
|
topic: "campsite/cover/awning/state"
|
||||||
@@ -300,6 +358,9 @@ automation:
|
|||||||
entity_id:
|
entity_id:
|
||||||
- binary_sensor.onecontrol_can_water_heater_dsi_fault
|
- binary_sensor.onecontrol_can_water_heater_dsi_fault
|
||||||
- binary_sensor.onecontrol_can_onecontrol_system_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:
|
conditions:
|
||||||
- condition: template
|
- condition: template
|
||||||
value_template: "{{ trigger.to_state.state in ['on', 'off'] }}"
|
value_template: "{{ trigger.to_state.state in ['on', 'off'] }}"
|
||||||
|
|||||||
Reference in New Issue
Block a user