Compare commits

..
2 Commits
Author SHA1 Message Date
wesandClaude Fable 5 9a5ee30db9 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>
2026-06-12 15:32:12 -04:00
wesandClaude Fable 5 e8b2447d62 canbus: battery decode, dual on_frame triggers, ground-truth switches
Flash-session firmware work, live on the node since 2026-06-12:
- battery voltage decode (29-bit page-0x11 telemetry, b2..b3 BE / 256),
  gated to extended frames so an 11-bit node 0x11 can never spoof it,
  with a delta/throttle filter to stop 1/256-V jitter churning both
  HA recorders and the MQTT bridge
- second on_frame trigger (use_extended_id: false) sharing the decode
  lambda via YAML anchor — this esp32_can build filters triggers by
  frame type, so a single trigger silently dropped the 11-bit reads
- switches optimistic:false now the page-3 read-back is verified live
- arm retry widened to 8x150ms; module-side ~2s post-success cooldown
  documented
- canbus component logs to INFO (per-frame DEBUG dump saturated serial)
- toolchain fixes: named std::array initializer, namespaced cover enums

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:32:12 -04:00
3 changed files with 500 additions and 41 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.
+69 -23
View File
@@ -44,8 +44,14 @@ wifi:
captive_portal: captive_portal:
logger: logger:
level: DEBUG # DEBUG so the on_frame ESP_LOGD frame dump is visible level: DEBUG # global DEBUG so entity "Sending state" publishes and
# during bring-up. Drop to INFO once the map is solid. # the command exchange (idscan) are visible during
# bring-up. Drop the whole thing to INFO once happy.
logs:
canbus: INFO # the esp32_can component logs EVERY received frame at
# DEBUG (~50/s) — that floods the 115200 serial link and
# starves wifi/api/sensor logs. Silence it; our decode
# is what we care about, not the raw component dump.
api: api:
encryption: encryption:
@@ -71,11 +77,17 @@ canbus:
bit_rate: 250kbps # IDS-CAN is 250k bit_rate: 250kbps # IDS-CAN is 250k
can_id: 0 # our own TX id (only matters when we send) can_id: 0 # our own TX id (only matters when we send)
use_extended_id: true # commands use 29-bit IDs use_extended_id: true # commands use 29-bit IDs
# This ESPHome build filters on_frame by frame type, so a single trigger only
# ever fires for one kind. We need BOTH: read broadcasts (tanks/lights/heater/
# awning) are 11-bit *standard* frames; the command challenge + battery
# telemetry are 29-bit *extended* frames. Two triggers, one shared lambda
# (YAML anchor) — the lambda branches on the id itself, so it's correct for
# either frame type.
on_frame: on_frame:
- can_id: 0 - can_id: 0
can_id_mask: 0 # accept every frame, dispatch in the lambda can_id_mask: 0 # accept every frame, dispatch in the lambda
use_extended_id: true use_extended_id: true
then: then: &decode_frame
- lambda: |- - lambda: |-
// `can_id` and `x` (data bytes) are provided by the trigger. // `can_id` and `x` (data bytes) are provided by the trigger.
uint32_t id = can_id; uint32_t id = can_id;
@@ -135,11 +147,14 @@ canbus:
id(g_node_type)[node] = x[3]; id(g_node_type)[node] = x[3];
} }
// Frame dump — comment out once the map is trustworthy. // Frame dump — confirmed both frame types decode (2026-06-12), now
ESP_LOGD("idscan", "page=%u node=%02X len=%u %02X %02X %02X %02X %02X %02X", // silenced: at DEBUG it logs every frame (~50/s across both
page, node, x.size(), // triggers) and saturates the 115200 serial link. Uncomment to
x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0, // re-map the bus.
x.size()>3?x[3]:0, x.size()>4?x[4]:0, x.size()>5?x[5]:0); // ESP_LOGD("idscan", "page=%u node=%02X len=%u %02X %02X %02X %02X %02X %02X",
// page, node, x.size(),
// x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0,
// x.size()>3?x[3]:0, x.size()>4?x[4]:0, x.size()>5?x[5]:0);
// page 3 = live value. Layout depends on device class (README): // page 3 = live value. Layout depends on device class (README):
// tanks (type 0x0A): x[0] = level in percent (0x42 = 66%). // tanks (type 0x0A): x[0] = level in percent (0x42 = 66%).
@@ -163,9 +178,9 @@ canbus:
// / C3 retracting (closing). Reflect motion onto the cover so HA // / C3 retracting (closing). Reflect motion onto the cover so HA
// shows open/opening/closing; assumed_state fills the resting pos. // shows open/opening/closing; assumed_state fills the resting pos.
case 0x75: case 0x75:
if (x[0] == 0xC2) id(awning).current_operation = COVER_OPERATION_IS_OPENING; if (x[0] == 0xC2) id(awning).current_operation = esphome::cover::COVER_OPERATION_OPENING;
else if (x[0] == 0xC3) id(awning).current_operation = COVER_OPERATION_IS_CLOSING; else if (x[0] == 0xC3) id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING;
else id(awning).current_operation = COVER_OPERATION_IDLE; else id(awning).current_operation = esphome::cover::COVER_OPERATION_IDLE;
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.)
@@ -180,8 +195,23 @@ canbus:
} }
// Battery voltage rides 29-bit telemetry frames (src 7D/AE, page // Battery voltage rides 29-bit telemetry frames (src 7D/AE, page
// 0x11), payload 00 2B 0D 4x ..; b2..b3 (BE) / 256 = volts. // 0x11), payload 00 2B 0D 4x <rolling>; b2..b3 (BE) / 256 = volts.
// TODO: match the exact source frame and publish battery_voltage. // The low byte of the 29-bit id is the page (0x11); the "00 2B"
// prefix gates out any other page-0x11 traffic. Match on signature
// rather than the exact source id so either telemetry module
// (7D->FC or AE->01) feeds the same sensor. id > 0x7FF restricts the
// match to 29-bit frames: this lambda also runs for 11-bit broadcasts
// (second trigger), where the low byte is a NODE address — a node
// 0x11 would otherwise spoof the battery reading.
if (id > 0x7FFu && (id & 0xFFu) == 0x11u && x.size() >= 4 && x[0] == 0x00 && x[1] == 0x2B) {
float volts = ((uint16_t) x[2] << 8 | x[3]) / 256.0f;
id(battery_voltage).publish_state(volts);
}
# Second trigger: 11-bit standard frames (the read broadcasts). Same lambda.
- can_id: 0
can_id_mask: 0
use_extended_id: false
then: *decode_frame
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Command path: authenticated "set switched load" # Command path: authenticated "set switched load"
@@ -205,7 +235,10 @@ globals:
# seen). Feeds the motor-output safety gate in command_guard.h. # seen). Feeds the motor-output safety gate in command_guard.h.
- id: g_node_type - id: g_node_type
type: 'std::array<uint8_t, 256>' type: 'std::array<uint8_t, 256>'
initial_value: '{}' # Name the type in the initializer: a bare '{}' is ambiguous between the
# GlobalsComponent<T>(T) and (std::array<...>) constructors under the current
# toolchain. zero-initialized -> every node starts as type 0 (not yet seen).
initial_value: 'std::array<uint8_t, 256>{}'
script: script:
- id: send_load_command - id: send_load_command
@@ -237,11 +270,16 @@ script:
id(g_cmd_node) = (uint8_t) node; id(g_cmd_node) = (uint8_t) node;
id(g_cmd_op) = (uint8_t) op; id(g_cmd_op) = (uint8_t) op;
id(g_cmd_pending) = true; id(g_cmd_pending) = true;
# Send the page-42 request, wait ~100 ms for the challenge; retry up to 3x. # Send the page-42 request, wait ~150 ms for the challenge; retry up to 8x
# (~1.2 s window) to ride out an occasional dropped arm on the busy bus.
# NOTE (confirmed live 2026-06-12): the module imposes a ~2 s cooldown after
# a SUCCESSFUL session — it won't issue a new challenge during it, so a
# second command to the same load <~2 s later is dropped regardless of opcode
# (this is module-side, not a bug; normal HA toggles are spaced far enough).
# The on_frame handler clears g_cmd_pending the moment it answers, so a # The on_frame handler clears g_cmd_pending the moment it answers, so a
# successful exchange short-circuits the remaining iterations. # successful exchange short-circuits the remaining iterations.
- repeat: - repeat:
count: 3 count: 8
then: then:
- if: - if:
condition: condition:
@@ -252,13 +290,13 @@ script:
uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8); uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8);
std::vector<uint8_t> req = {0x00, 0x04}; std::vector<uint8_t> req = {0x00, 0x04};
id(can_bus).send_data(req_id, true, req); id(can_bus).send_data(req_id, true, req);
- delay: 100ms - delay: 150ms
- if: - if:
condition: condition:
lambda: 'return id(g_cmd_pending);' lambda: 'return id(g_cmd_pending);'
then: then:
- lambda: |- - lambda: |-
ESP_LOGW("idscan", "no page-42 challenge from node %02X after 3 tries; command dropped", ESP_LOGW("idscan", "no page-42 challenge from node %02X after 8 tries; command dropped",
id(g_cmd_node)); id(g_cmd_node));
id(g_cmd_pending) = false; id(g_cmd_pending) = false;
@@ -273,6 +311,13 @@ sensor:
device_class: voltage device_class: voltage
state_class: measurement state_class: measurement
accuracy_decimals: 2 accuracy_decimals: 2
# The raw value jitters at 1/256 V every telemetry frame; unfiltered, that's
# constant state churn into both HA recorders + retained MQTT over the WG
# tunnel. Pass real moves (>0.05 V) immediately, else at most one per minute.
filters:
- or:
- delta: 0.05
- throttle: 60s
- platform: template - platform: template
name: "Fresh Water Tank" name: "Fresh Water Tank"
id: fresh_tank id: fresh_tank
@@ -319,14 +364,15 @@ binary_sensor:
# policy in command_guard.h, enforced both here and at the transmit point, so even # policy in command_guard.h, enforced both here and at the transmit point, so even
# adding a switch for one below cannot actuate it. The awning is permitted by the # adding a switch for one below cannot actuate it. The awning is permitted by the
# gate but needs a proper cover entity + an attended first test before it's wired. # gate but needs a proper cover entity + an attended first test before it's wired.
# optimistic:true for now — the page-3 (b0 bit0) read-back above already publishes # optimistic:false: the page-3 (b0 bit0) read-back above publishes true module
# true module state, so these can switch to optimistic:false once verified. # 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.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
switch: switch:
- platform: template - platform: template
name: "Exterior Lights" name: "Exterior Lights"
id: exterior_lights id: exterior_lights
optimistic: true optimistic: false
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:
@@ -335,7 +381,7 @@ switch:
- platform: template - platform: template
name: "Interior Lights" name: "Interior Lights"
id: interior_lights id: interior_lights
optimistic: true optimistic: false
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:
@@ -344,7 +390,7 @@ switch:
- platform: template - platform: template
name: "Water Heater" name: "Water Heater"
id: water_heater id: water_heater
optimistic: true optimistic: false
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:
+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] }}"