canbus: replace BLE integration with CAN node in HA + MQTT bridge home

The campsite HAOS Pi now runs the ESPHome CAN node natively ("OneControl
CAN", *.onecontrol_can_*); the BLE integration is fully torn out (config
entry, custom_components on the Pi, stale MQTT registry orphans) and the
camper dashboard rewritten — water-pump tile dropped on purpose (pump is
panel-only), awning + fault sensors added.

New canbus/ha/mqtt_bridge_onecontrol.yaml (deployed to the Pi as
packages/mqtt_bridge.yaml) bridges the CAN entities to the home broker
via MQTT Discovery, same pattern as the gazebo bridge. Kept the old
unique_ids so home entity ids and recorder history carried over; added
an availability topic and explicit command-topic allowlists (3 switches
+ awning — a future debug entity must not become remotely controllable
for free). Round-trip verified from home HA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-06-12 15:32:12 -04:00
co-authored by Claude Fable 5
parent e8b2447d62
commit 9a5ee30db9
2 changed files with 431 additions and 18 deletions
+67 -18
View File
@@ -9,6 +9,32 @@ Read `README.md` first — it has the full message-format documentation, the nod
map for this coach, and the wiring procedure. This file is just the current map for this coach, and the wiring procedure. This file is just the current
state + what's left. state + what's left.
## FLASHED & READ-VERIFIED (2026-06-12, afternoon)
The node is built, flashed over USB, on the bus, and on WiFi
(`192.168.69.18`, hostname `onecontrol-canbus`, adoptable via the API key in
`secrets.yaml`). **All 11 read entities verified live over the native API**:
battery 13.27 V, tanks fresh 0 / black 33 / grey1 33 / grey2 100 %, interior
lights on, exterior/heater off, both fault flags clear, awning idle.
What changed this session:
- `secrets.yaml` filled (WiFi + fallback AP from the shared gazebo proxy creds).
- **Battery voltage decode finished** (remaining-work item 4): matches the
page-`0x11` `00 2B …` telemetry frame, `b2..b3` BE / 256 — reads 13.27 V.
- **Toolchain fixes** (ESPHome moved since the last build; it no longer compiled):
the `g_node_type` array global's bare `{}` initializer was ambiguous (named the
type), and the awning cover enum is `COVER_OPERATION_OPENING/CLOSING` (no `IS_`).
- **Dual `on_frame` trigger** — this build filters `on_frame` by frame type, so a
single `use_extended_id: true` trigger only saw the 29-bit frames; the 11-bit
read broadcasts (tanks/lights/heater/awning) were silently dropped. Added a
second `use_extended_id: false` trigger sharing the same lambda via a YAML
anchor. (This was the exact risk the bus-config note called out.)
- **Logger** — raised the `canbus` component to INFO and silenced the per-frame
dump; at DEBUG it logged ~50 frames/s and saturated the 115200 serial link.
Still TODO (needs the operator in the loop — see "Remaining work" items 56):
command-path verification (toggle a light) and the attended awning test.
## Current status (2026-06-12) ## Current status (2026-06-12)
- **Message format documented and confirmed against live captures.** The bus is - **Message format documented and confirmed against live captures.** The bus is
@@ -42,31 +68,54 @@ state + what's left.
## Remaining work (in order) ## Remaining work (in order)
1. **`secrets.yaml`** — copy `esphome/secrets.yaml.example`, fill in WiFi, the 1. ~~**`secrets.yaml`**~~ — DONE. WiFi + fallback-AP creds filled from the shared
ESPHome `api_key`, and the fallback-AP password. It's git-ignored. gazebo proxy; the `api_key` was already set.
2. **First flash over USB:** `esphome run esphome/onecontrol-canbus.yaml` (pick 2. ~~**First flash over USB**~~ — DONE (`/dev/ttyUSB0`). OTA from here on.
the serial port). OTA after that. Mirrors the gazebo-fan-proxy workflow. Note: this laptop's user isn't in the `uucp` group, so the serial node needed
3. **Confirm the read entities populate** in HA once it's on the bus: the four `chmod 666 /dev/ttyUSB0` (resets on replug) — irrelevant for OTA.
tanks and the two light switches publish from the page-3 broadcasts. Watch the 3. ~~**Confirm the read entities populate**~~ — DONE. All 11 verified live over the
DEBUG frame dump (`logger: level: DEBUG`) to confirm frames are decoded; drop native API (see status block above). NB: ESPHome 2026.5 doesn't echo
to INFO when happy. `publish_state` at DEBUG over serial, so a quiet serial log is normal — read the
4. **Finish the two open read items in the YAML lambda:** states over the API, not the console.
- **Battery voltage** — rides a 29-bit telemetry frame (src `7D`/`AE`, 4. **Open read items in the YAML lambda:**
page `0x11`), bytes 23 big-endian / 256 = volts. Match that frame and - ~~**Battery voltage**~~ — DONE; reads 13.27 V.
publish `battery_voltage`. (See README "29-bit extended frames".)
- Optionally add **water pump (`61`)** and **water heater (`95`)** — both are - Optionally add **water pump (`61`)** and **water heater (`95`)** — both are
ordinary switched loads, same decode + command path as the lights. ordinary switched loads, same decode + command path as the lights. (Heater is
5. **Verify the command path** from HA: toggle Interior/Exterior Lights and the already exposed; pump stays command-blocked.)
Water Heater. The `send_load_command` script + `on_frame` handler do the 5. ~~**Verify the command path**~~ — DONE (Interior + Exterior lights, on & off,
exchange and send the opcode. 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 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 (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 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 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 stream the opcode (repeat until Stop) — only after watching it. The single-shot
default can't run the motor away. default can't run the motor away.
7. **(Optional) Surface at the campsite HA + bridge home** like the gazebo fans / 7. ~~**Surface at the campsite HA**~~ — DONE (2026-06-12). Added to the campsite
OneControl BLE devices, if you want these in the home dashboard too. 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. The DSI fault is already decoded and wired in (see below) — no capture needed.
+364
View File
@@ -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] }}"