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>
This commit is contained in:
wes
2026-06-12 16:56:30 -04:00
co-authored by Claude Fable 5
parent 257064289c
commit 2dd884af3c
3 changed files with 254 additions and 0 deletions
+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