From 9a5ee30db972c6422fa9884dd15e86d0e59ba662 Mon Sep 17 00:00:00 2001 From: Wesley Ray Date: Fri, 12 Jun 2026 15:32:12 -0400 Subject: [PATCH] canbus: replace BLE integration with CAN node in HA + MQTT bridge home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- canbus/HANDOFF.md | 85 ++++-- canbus/ha/mqtt_bridge_onecontrol.yaml | 364 ++++++++++++++++++++++++++ 2 files changed, 431 insertions(+), 18 deletions(-) create mode 100644 canbus/ha/mqtt_bridge_onecontrol.yaml diff --git a/canbus/HANDOFF.md b/canbus/HANDOFF.md index 2de992f..a4253c3 100644 --- a/canbus/HANDOFF.md +++ b/canbus/HANDOFF.md @@ -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 5–6): +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 2–3 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. diff --git a/canbus/ha/mqtt_bridge_onecontrol.yaml b/canbus/ha/mqtt_bridge_onecontrol.yaml new file mode 100644 index 0000000..ab5de29 --- /dev/null +++ b/canbus/ha/mqtt_bridge_onecontrol.yaml @@ -0,0 +1,364 @@ +# 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 publish on startup / reload / every 6h --- + - id: campsite_mqtt_discovery + alias: "MQTT Bridge: Publish Discovery" + triggers: + - trigger: homeassistant + event: start + - trigger: event + event_type: automation_reloaded + - trigger: time_pattern + hours: "/6" + 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"]}} + # 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", + "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/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 + 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] }}"