diff --git a/canbus/HANDOFF.md b/canbus/HANDOFF.md index eb692ff..c65a83a 100644 --- a/canbus/HANDOFF.md +++ b/canbus/HANDOFF.md @@ -154,6 +154,40 @@ 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). +- **PENDING — MikroTik WiFi presence:** needs Chateau admin once: enable + `/ip service api address=192.168.69.10`, add read-only `homeassistant` user, + then add the `mikrotik` integration on the Pi (hub 192.168.69.1) and rename + the phone trackers to `device_tracker.wes_phone_wifi` / + `device_tracker.lindsey_iphone_wifi`. Everything downstream lights up by + itself (sensor availability flips on). +- **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 diff --git a/canbus/ha/campsite_presence.yaml b/canbus/ha/campsite_presence.yaml new file mode 100644 index 0000000..a30050d --- /dev/null +++ b/canbus/ha/campsite_presence.yaml @@ -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 diff --git a/canbus/ha/dsi_fault_alert.yaml b/canbus/ha/dsi_fault_alert.yaml index ab574bd..a568181 100644 --- a/canbus/ha/dsi_fault_alert.yaml +++ b/canbus/ha/dsi_fault_alert.yaml @@ -28,6 +28,25 @@ rest_command: "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: >-