Decode node 89 (furnace) read-only: Furnace Running (page-3 b0 bit0) and Furnace DSI Fault (b0 bit5), same type-0x1E DSI encoding as the water heater. The furnace is thermostat-controlled, not a Lippert load — it sits on the bus only to report ignition state. Captures only ever showed 0x80 (off), so the 0x81 running / 0xA0 fault bytes are inferred by parallel to node 95; confirm on the first real burn. Add Water Heater Heating (node 95 page-3 x[3] bit7 = burner actively firing) — distinct from the on/off switch, which only reflects "enabled". Carry all three through the MQTT bridge to home HA, add the furnace DSI-fault Octavia alert mirroring the water heater, and update the README node map (89 was "unknown switched load"). Campsite + home Camper dashboards updated live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
465 lines
22 KiB
YAML
465 lines
22 KiB
YAML
# OneControl IDS-CAN node
|
|
#
|
|
# ESP32 (native TWAI/CAN) + external SN65HVD230 transceiver, connected to the
|
|
# Lippert UNITY X180T CAN bus at the monitor panel's spare CAN *data* port.
|
|
# Listens to the modules' broadcasts and republishes them as native HA entities,
|
|
# and issues commands using the panel's challenge/response authentication.
|
|
#
|
|
# IDS-CAN = 250 kbit/s. Read broadcasts are 11-bit standard frames,
|
|
# id = (page << 8) | node. Commands and their authentication exchange use 29-bit
|
|
# extended frames. See ../README.md for the connection procedure, the node map,
|
|
# and the message format. Flash over USB first
|
|
# (`esphome run onecontrol-canbus.yaml`), OTA thereafter.
|
|
|
|
substitutions:
|
|
name: onecontrol-canbus
|
|
friendly_name: OneControl CAN
|
|
# ESP32 GPIOs to the transceiver. Any free non-strapping pins work; these match
|
|
# a common SN65HVD230 wiring. tx_pin -> transceiver D/CTX, rx_pin <- R/CRX.
|
|
tx_pin: GPIO5
|
|
rx_pin: GPIO4
|
|
|
|
esphome:
|
|
name: ${name}
|
|
friendly_name: ${friendly_name}
|
|
# Command authentication (REMOTE_CONTROL session, key 0xB16B00B5) provides
|
|
# ids_can_auth::remote_control_response_bytes(); command_guard.h provides
|
|
# onecontrol::command_blocked() — the slides/jacks-are-panel-only safety rule.
|
|
includes:
|
|
- ids_can_auth.h
|
|
- command_guard.h
|
|
|
|
esp32:
|
|
board: esp32dev # classic WROOM-32
|
|
framework:
|
|
type: esp-idf
|
|
|
|
wifi:
|
|
ssid: !secret wifi_ssid
|
|
password: !secret wifi_password
|
|
ap:
|
|
ssid: "OneControl-CAN Fallback"
|
|
password: !secret fallback_ap_password
|
|
|
|
captive_portal:
|
|
|
|
logger:
|
|
level: DEBUG # global DEBUG so entity "Sending state" publishes and
|
|
# 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:
|
|
encryption:
|
|
key: !secret api_key
|
|
|
|
ota:
|
|
- platform: esphome
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CAN bus: ESP32 native TWAI controller + SN65HVD230 transceiver
|
|
#
|
|
# NOTE: read broadcasts are 11-bit *standard* frames; the command authentication
|
|
# challenge is a 29-bit *extended* frame. The node must receive both. Confirm the
|
|
# esp32_can trigger accepts both frame types (a single catch-all with
|
|
# can_id_mask 0); if your ESPHome build filters by frame type, add a second
|
|
# on_frame for standard IDs.
|
|
# ---------------------------------------------------------------------------
|
|
canbus:
|
|
- platform: esp32_can
|
|
id: can_bus
|
|
tx_pin: ${tx_pin}
|
|
rx_pin: ${rx_pin}
|
|
bit_rate: 250kbps # IDS-CAN is 250k
|
|
can_id: 0 # our own TX id (only matters when we send)
|
|
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:
|
|
- can_id: 0
|
|
can_id_mask: 0 # accept every frame, dispatch in the lambda
|
|
use_extended_id: true
|
|
then: &decode_frame
|
|
- lambda: |-
|
|
// `can_id` and `x` (data bytes) are provided by the trigger.
|
|
uint32_t id = can_id;
|
|
|
|
// ---- command authentication: page-42 challenge reply ----
|
|
// After send_load_command sends the page-42 request, the target
|
|
// module returns a fresh 4-byte challenge on the 29-bit ext ID
|
|
// (node<<18)|0x10000|0x0142, payload 00 04 CC CC CC CC. Compute the
|
|
// REMOTE_CONTROL response, send it on page 43, then send the opcode
|
|
// x3. This is the only place we transmit; it acts solely on
|
|
// id(g_cmd_node), so no other node is ever touched.
|
|
if (id(g_cmd_pending) && x.size() >= 6) {
|
|
uint32_t chal_id = ((uint32_t) id(g_cmd_node) << 18) | 0x10000u | 0x0142u;
|
|
if (id == chal_id) {
|
|
// HARD SAFETY GATE (authoritative). This is the only place a
|
|
// command is ever transmitted, so the slides/jacks-are-panel-
|
|
// only rule is enforced here, right before TX, regardless of how
|
|
// the command was queued. If blocked: send nothing — no
|
|
// response, no opcode — and clear the pending command.
|
|
uint8_t n = id(g_cmd_node);
|
|
if (onecontrol::command_blocked(n, id(g_node_type)[n])) {
|
|
ESP_LOGE("idscan", "SAFETY: refusing command to motor node %02X "
|
|
"(slides/jacks are control-panel only)", n);
|
|
id(g_cmd_pending) = false;
|
|
return;
|
|
}
|
|
|
|
uint8_t resp[4];
|
|
ids_can_auth::remote_control_response_bytes(&x[2], resp);
|
|
|
|
// page-43 response: ext ID 0x0004<node>43, payload 00 04 RR RR RR RR
|
|
uint32_t resp_id = 0x00040043u | ((uint32_t) id(g_cmd_node) << 8);
|
|
std::vector<uint8_t> rframe = {0x00, 0x04, resp[0], resp[1], resp[2], resp[3]};
|
|
id(can_bus).send_data(resp_id, true, rframe);
|
|
|
|
// opcode x3: ext ID 0x0006<node><op>, DLC 0 (op 01=on, 00=off)
|
|
uint32_t op_id = 0x00060000u | ((uint32_t) id(g_cmd_node) << 8) | (uint32_t) id(g_cmd_op);
|
|
std::vector<uint8_t> opframe; // empty -> DLC 0
|
|
for (int i = 0; i < 3; i++) id(can_bus).send_data(op_id, true, opframe);
|
|
|
|
id(g_cmd_pending) = false;
|
|
ESP_LOGI("idscan", "node %02X: challenge %02X%02X%02X%02X -> response %02X%02X%02X%02X, opcode %02X x3",
|
|
id(g_cmd_node), x[2], x[3], x[4], x[5],
|
|
resp[0], resp[1], resp[2], resp[3], id(g_cmd_op));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ---- read broadcasts: 11-bit standard frames, id = (page<<8)|node ----
|
|
uint8_t page = (id >> 8) & 0xFF;
|
|
uint8_t node = id & 0xFF;
|
|
|
|
// Track each node's device class from its page-2 identity broadcast
|
|
// (x[3] = type byte). The safety gate uses this to refuse any
|
|
// motor-class (0x21) node that isn't the awning.
|
|
if (page == 2 && x.size() >= 4) {
|
|
id(g_node_type)[node] = x[3];
|
|
}
|
|
|
|
// Frame dump — confirmed both frame types decode (2026-06-12), now
|
|
// silenced: at DEBUG it logs every frame (~50/s across both
|
|
// triggers) and saturates the 115200 serial link. Uncomment to
|
|
// re-map the bus.
|
|
// 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):
|
|
// tanks (type 0x0A): x[0] = level in percent (0x42 = 66%).
|
|
// switched loads (type 0x1E): x[0] bit0 = on/off.
|
|
if (page == 3 && x.size() >= 1) {
|
|
switch (node) {
|
|
// tanks (node addresses from the README node map for this rig)
|
|
case 0xE2: id(fresh_tank).publish_state(x[0]); break;
|
|
case 0xFE: id(black_tank).publish_state(x[0]); break;
|
|
case 0x27: id(grey_tank_1).publish_state(x[0]); break;
|
|
case 0x7D: id(grey_tank_2).publish_state(x[0]); break;
|
|
// switched loads
|
|
case 0xF8: id(interior_lights).publish_state(x[0] & 0x01); break;
|
|
case 0x2A: id(exterior_lights).publish_state(x[0] & 0x01); break;
|
|
// water heater (node 95): b0 bit0 = on, bit5 (0x20) = DSI/gas
|
|
// ignition fault/lockout (healthy: 0x80 off / 0x81 running;
|
|
// fault: 0xA0). Confirmed by a forced lockout 2026-06-12.
|
|
// x[3] bit7 = burner ACTUALLY FIRING (vs merely enabled): idle
|
|
// reads ...00 01, a live burn reads ...00 9X with the low nibble
|
|
// climbing as it heats (captured 2026-06-11). The switch state
|
|
// above is "enabled"; wh_heating is "actually making heat".
|
|
case 0x95: id(water_heater).publish_state(x[0] & 0x01);
|
|
id(dsi_fault).publish_state(x[0] & 0x20);
|
|
if (x.size() >= 4) id(wh_heating).publish_state(x[3] & 0x80);
|
|
break;
|
|
// furnace (node 89): propane forced-air, THERMOSTAT-controlled
|
|
// (not a Lippert load) — on the bus only to report DSI state, in
|
|
// the same type-0x1E encoding as the water heater. b0 bit0 =
|
|
// running, bit5 (0x20) = DSI ignition lockout. A lockout also
|
|
// trips the bus-wide page-0 fault flag (system_fault). Only idle
|
|
// 0x80 was captured (summer bench); 0x81 running / 0xA0 fault are
|
|
// inferred by parallel to node 95 — confirm on the first real
|
|
// burn cycle (or a forced lockout, propane off).
|
|
case 0x89: id(furnace_running).publish_state(x[0] & 0x01);
|
|
id(furnace_dsi_fault).publish_state(x[0] & 0x20); break;
|
|
// awning H-bridge (node 75): b0 C0 idle / C2 extending (opening)
|
|
// / C3 retracting (closing). Reflect motion onto the cover so HA
|
|
// shows open/opening/closing; assumed_state fills the resting pos.
|
|
case 0x75:
|
|
if (x[0] == 0xC2) id(awning).current_operation = esphome::cover::COVER_OPERATION_OPENING;
|
|
else if (x[0] == 0xC3) id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING;
|
|
else id(awning).current_operation = esphome::cover::COVER_OPERATION_IDLE;
|
|
id(awning).publish_state();
|
|
break;
|
|
// (water pump 61 is command-blocked — read-only, not exposed.)
|
|
}
|
|
}
|
|
|
|
// page 0 b0 bit0 = a bus-wide "system fault present" flag (every
|
|
// node mirrors it; healthy 0x02, fault 0x03). Read it from one node
|
|
// (the heater) so we publish a single source, not 14 duplicates.
|
|
if (page == 0 && node == 0x95 && x.size() >= 1) {
|
|
id(system_fault).publish_state(x[0] & 0x01);
|
|
}
|
|
|
|
// Battery voltage rides 29-bit telemetry frames (src 7D/AE, page
|
|
// 0x11), payload 00 2B 0D 4x <rolling>; b2..b3 (BE) / 256 = volts.
|
|
// 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"
|
|
#
|
|
# State shared between send_load_command (sends the request + retries) and the
|
|
# on_frame challenge handler (computes the response + sends the opcode). A
|
|
# command is queued by setting g_cmd_* and g_cmd_pending; the handler clears
|
|
# g_cmd_pending once the exchange completes, which also stops the retry loop.
|
|
# ---------------------------------------------------------------------------
|
|
globals:
|
|
- id: g_cmd_node
|
|
type: uint8_t
|
|
initial_value: '0'
|
|
- id: g_cmd_op
|
|
type: uint8_t
|
|
initial_value: '0'
|
|
- id: g_cmd_pending
|
|
type: bool
|
|
initial_value: 'false'
|
|
# Device class per node, learned from page-2 identity broadcasts (0 = not yet
|
|
# seen). Feeds the motor-output safety gate in command_guard.h.
|
|
- id: g_node_type
|
|
type: 'std::array<uint8_t, 256>'
|
|
# 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:
|
|
- id: send_load_command
|
|
# mode: restart -> a new press supersedes an in-flight exchange.
|
|
mode: restart
|
|
parameters:
|
|
node: int
|
|
op: int
|
|
then:
|
|
- lambda: |-
|
|
// SAFETY, layer 1 — motor-output gate. Slides/jacks are control-panel
|
|
// only; refuse any motor node that isn't the awning. Same rule the
|
|
// transmit point enforces (command_guard.h), checked here too so a
|
|
// blocked command never even starts the exchange.
|
|
if (onecontrol::command_blocked((uint8_t) node, id(g_node_type)[(uint8_t) node])) {
|
|
ESP_LOGE("idscan", "SAFETY: refusing command to motor node %02X "
|
|
"(slides/jacks are control-panel only)", (uint8_t) node);
|
|
id(g_cmd_pending) = false;
|
|
return;
|
|
}
|
|
// SAFETY, layer 2 — exposed-entity allowlist. Nodes wired to entities:
|
|
// 2A ext lights, F8 int lights, 95 water heater, 75 awning (cover). Do
|
|
// NOT add slide/jack/pump nodes — the gate above refuses them regardless.
|
|
if (node != 0x2A && node != 0xF8 && node != 0x95 && node != 0x75) {
|
|
ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X", node);
|
|
id(g_cmd_pending) = false;
|
|
return;
|
|
}
|
|
id(g_cmd_node) = (uint8_t) node;
|
|
id(g_cmd_op) = (uint8_t) op;
|
|
id(g_cmd_pending) = true;
|
|
# 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
|
|
# successful exchange short-circuits the remaining iterations.
|
|
- repeat:
|
|
count: 8
|
|
then:
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(g_cmd_pending);'
|
|
then:
|
|
- lambda: |-
|
|
// page-42 request: ext ID 0x0004<node>42, payload 00 04
|
|
uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8);
|
|
std::vector<uint8_t> req = {0x00, 0x04};
|
|
id(can_bus).send_data(req_id, true, req);
|
|
- delay: 150ms
|
|
- if:
|
|
condition:
|
|
lambda: 'return id(g_cmd_pending);'
|
|
then:
|
|
- lambda: |-
|
|
ESP_LOGW("idscan", "no page-42 challenge from node %02X after 8 tries; command dropped",
|
|
id(g_cmd_node));
|
|
id(g_cmd_pending) = false;
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Read-back sensors (published by the dispatcher above)
|
|
# ---------------------------------------------------------------------------
|
|
sensor:
|
|
- platform: template
|
|
name: "Battery Voltage"
|
|
id: battery_voltage
|
|
unit_of_measurement: "V"
|
|
device_class: voltage
|
|
state_class: measurement
|
|
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
|
|
name: "Fresh Water Tank"
|
|
id: fresh_tank
|
|
unit_of_measurement: "%"
|
|
accuracy_decimals: 0
|
|
- platform: template
|
|
name: "Black Tank"
|
|
id: black_tank
|
|
unit_of_measurement: "%"
|
|
accuracy_decimals: 0
|
|
- platform: template
|
|
name: "Grey Tank 1"
|
|
id: grey_tank_1
|
|
unit_of_measurement: "%"
|
|
accuracy_decimals: 0
|
|
- platform: template
|
|
name: "Grey Tank 2"
|
|
id: grey_tank_2
|
|
unit_of_measurement: "%"
|
|
accuracy_decimals: 0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fault indicators (published by the dispatcher above)
|
|
# ---------------------------------------------------------------------------
|
|
binary_sensor:
|
|
- platform: template
|
|
name: "Water Heater DSI Fault"
|
|
id: dsi_fault
|
|
device_class: problem # node 95 page-3 b0 bit5 — gas ignition lockout
|
|
- platform: template
|
|
name: "Water Heater Heating"
|
|
id: wh_heating
|
|
device_class: running # node 95 page-3 x[3] bit7 — burner actively firing
|
|
- platform: template
|
|
name: "Furnace Running"
|
|
id: furnace_running
|
|
device_class: running # node 89 page-3 b0 bit0 — thermostat-driven burn
|
|
- platform: template
|
|
name: "Furnace DSI Fault"
|
|
id: furnace_dsi_fault
|
|
device_class: problem # node 89 page-3 b0 bit5 — gas ignition lockout
|
|
- platform: template
|
|
name: "OneControl System Fault"
|
|
id: system_fault
|
|
device_class: problem # page-0 b0 bit0 — bus-wide "a fault exists"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Switches — authenticated command path (see send_load_command above).
|
|
# Each turn_on/off queues send_load_command for the load's node; the on_frame
|
|
# handler completes the page-42/43 challenge/response and sends the opcode.
|
|
#
|
|
# Exposed switched loads: exterior lights (2A), interior lights (F8), water
|
|
# heater (95). Each must be in the layer-2 allowlist in send_load_command.
|
|
#
|
|
# SLIDES, JACKS, and the WATER PUMP ARE PANEL/APP ONLY — never over CAN. Hard
|
|
# 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
|
|
# gate but needs a proper cover entity + an attended first test before it's wired.
|
|
# optimistic:false: the page-3 (b0 bit0) read-back above publishes true module
|
|
# 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.
|
|
# ---------------------------------------------------------------------------
|
|
# restore_mode: DISABLED on all three is load-bearing: ESPHome's default
|
|
# restore "applies" the boot state BY EXECUTING THE SWITCH ACTION, which sent
|
|
# a real authenticated OFF to the water heater on the 2026-06-12 OTA reboot
|
|
# (send_load_command is mode:restart, so the last switch in setup order — the
|
|
# heater — won the race). Boot must send nothing: the broadcasts repopulate
|
|
# every state within ~1 s and are the only source of truth.
|
|
switch:
|
|
- platform: template
|
|
name: "Exterior Lights"
|
|
id: exterior_lights
|
|
optimistic: false
|
|
restore_mode: DISABLED
|
|
turn_on_action:
|
|
- script.execute: { id: send_load_command, node: 0x2A, op: 1 }
|
|
turn_off_action:
|
|
- script.execute: { id: send_load_command, node: 0x2A, op: 0 }
|
|
|
|
- platform: template
|
|
name: "Interior Lights"
|
|
id: interior_lights
|
|
optimistic: false
|
|
restore_mode: DISABLED
|
|
turn_on_action:
|
|
- script.execute: { id: send_load_command, node: 0xF8, op: 1 }
|
|
turn_off_action:
|
|
- script.execute: { id: send_load_command, node: 0xF8, op: 0 }
|
|
|
|
- platform: template
|
|
name: "Water Heater"
|
|
id: water_heater
|
|
optimistic: false
|
|
restore_mode: DISABLED
|
|
turn_on_action:
|
|
- script.execute: { id: send_load_command, node: 0x95, op: 1 }
|
|
turn_off_action:
|
|
- script.execute: { id: send_load_command, node: 0x95, op: 0 }
|
|
|
|
# Slides/jacks/pump are command-blocked and must never be wired here.
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Awning (node 75) — open / close / stop cover.
|
|
#
|
|
# H-bridge motor: open = extend (op 01), close = retract (op 02), stop (op 00),
|
|
# per the captures. assumed_state -> HA shows discrete open/close/stop controls
|
|
# (no position slider); current_operation is published from the page-3 motion
|
|
# byte in the dispatcher above.
|
|
#
|
|
# ⚠️ FIRST ACTUATION MUST BE ATTENDED. We haven't confirmed whether one command
|
|
# latches the motor (runs to the travel limit) or is hold-to-run (the OEM app
|
|
# streamed repeats, which hints at hold-to-run). The single-shot command here is
|
|
# the safe default: if hold-to-run, the awning just moves a little and stops on
|
|
# its own — it can't run away. If it under-travels, stream the command (repeat
|
|
# send_load_command until stop) — do that change only after watching it move.
|
|
# ---------------------------------------------------------------------------
|
|
cover:
|
|
- platform: template
|
|
name: "Awning"
|
|
id: awning
|
|
device_class: awning
|
|
assumed_state: true
|
|
open_action:
|
|
- script.execute: { id: send_load_command, node: 0x75, op: 1 }
|
|
close_action:
|
|
- script.execute: { id: send_load_command, node: 0x75, op: 2 }
|
|
stop_action:
|
|
- script.execute: { id: send_load_command, node: 0x75, op: 0 }
|