awning: current-sensing auto-retract + true CLOSED state; drop useless OPEN jog

Motion is hold-to-run (one authed opcode runs the motor ~1s). Reproduce the OEM
movement session -- auth once, then stream the opcode @110ms + a page-44
keepalive @510ms -- to sustain continuous retract, and watch node-75 page-3
motor current at 20Hz to stop at the fully-closed stall (~4200 vs ~<1550
running), then mark the cover CLOSED. Backstops: 70s timeout, motion-lost
detector, hold-to-run stop-on-silence. Proven live at the camper 2026-07-01.

- esphome: awning_auto_retract script + 100ms streamer interval + case-0x75
  stall gate + "Awning Motor Current" sensor. Cover CLOSE=auto-retract,
  STOP=abort. Removed open_action (1s jog is useless, no safe timed auto-open).
- bridge: optimistic:true keeps the home HA cover assumed-state so retract is
  always pressable (was greyed when closed); payload_open:null drops OPEN on the
  home (primary) dashboard. Also synced the 6h->15min discovery-cadence drift.
- captures + README: full-retract stall profile and the live auto-retract test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wes
2026-07-01 22:18:15 -04:00
co-authored by Claude Opus 4.8
parent 6733a79390
commit a56f550636
5 changed files with 10171 additions and 20 deletions
+27 -5
View File
@@ -169,15 +169,37 @@ python3 idscan_cmd.py F8 on # node_hex on|off ; needs can0 up
Movement nodes (awning `75`, slides, jacks) use the **same** authentication — Movement nodes (awning `75`, slides, jacks) use the **same** authentication —
the app-driven awning commands in `captures/app-commands-*.log` show the identical the app-driven awning commands in `captures/app-commands-*.log` show the identical
page-42/43 exchange. Not yet operated this way; exercise a motor only while page-42/43 exchange.
watching it.
**Awning motion is HOLD-TO-RUN, and auto-retract is live (2026-07-01).** A single
authenticated opcode runs the motor only ~1s (it's a "keep the button held"
signal, not a latch). The OEM app sustains motion by **streaming after one auth**:
opcode `03F2<node>02` every ~110ms **plus** a page-44 keepalive `03F0<node>44`
payload `00 04` every ~510ms — **no per-command re-auth during the run** (a cold
opcode with no session is still ignored; the auth just opens the motion session).
Our ESPHome node reproduces this with its own controller identity (opcode
`0006 75 02`, keepalive `0004 75 44`) — proven to sustain ~6.5s of continuous
retract on 2026-07-01.
The awning has no position feedback, but the **motor current** rides page-3 b2-3
(BE, raw counts): ~<1550 running incl. inrush, tapering to ~350500 near closed,
then a sharp ramp to a **~4200 plateau at the fully-closed hard stop** (captures
`awning-fullretract-2026-07-01_*.log`). The node's **auto-retract** (cover CLOSE)
streams the retract, watches current at 20Hz, and cuts at `cur>2500` for 3 frames
(~150ms) — firing ~0.3s into the stall ramp, before the motor sits hard-stalled —
then marks the cover CLOSED (the one direction with a real end-stop). Backstops:
70s timeout, motion-lost detector, and stop-streaming-stops-the-motor (hold-to-run
fail-safe). OPEN stays a single ~1s jog (no safe end-stop for extend). See
`esphome/onecontrol-canbus.yaml` (`awning_auto_retract` script + streamer
`interval` + the case `0x75` stall gate).
**Bottom line: read is fully open** (all sensors + states from broadcasts, no **Bottom line: read is fully open** (all sensors + states from broadcasts, no
authentication) **and command is implemented and proven** (`ids_can_auth.py` + authentication) **and command is implemented and proven** (`ids_can_auth.py` +
`idscan_cmd.py`). The CAN path can both sense and operate the system, so the `idscan_cmd.py`). The CAN path can both sense and operate the system, so the
Bluetooth integration is no longer needed for control. Next step: fold the Bluetooth integration is no longer needed for control. The challenge-response is
challenge-response into the ESPHome node's `switch`/`light`/`cover` actions (the folded into the ESPHome node's `switch`/`light`/`cover` actions (opcode preceded
opcode just needs the page-42/43 exchange in front of it). by the page-42/43 exchange), and the awning cover does authenticated streaming
auto-retract — see the awning section above.
Other app-session traffic (not control): `701` = controller heartbeat during a Other app-session traffic (not control): `701` = controller heartbeat during a
Bluetooth session; src `01` → node pages `30/31` = paged descriptor/table reads Bluetooth session; src `01` → node pages `30/31` = paged descriptor/table reads
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+142 -13
View File
@@ -191,14 +191,41 @@ canbus:
case 0x89: id(furnace_running).publish_state(x[0] & 0x01); case 0x89: id(furnace_running).publish_state(x[0] & 0x01);
id(furnace_dsi_fault).publish_state(x[0] & 0x20); break; id(furnace_dsi_fault).publish_state(x[0] & 0x20); break;
// awning H-bridge (node 75): b0 C0 idle / C2 extending (opening) // awning H-bridge (node 75): b0 C0 idle / C2 extending (opening)
// / C3 retracting (closing). Reflect motion onto the cover so HA // / C3 retracting (closing); b2-3 (BE) = motor current (raw
// shows open/opening/closing; assumed_state fills the resting pos. // counts, ~<1550 running incl. inrush, ~4200 at the fully-closed
case 0x75: // stall — captured 2026-07-01). Reflect motion onto the cover and
// publish current; while an auto-retract session is active, watch
// for the stall spike and cut the stream (see interval + script).
case 0x75: {
uint16_t cur = (x.size() >= 4) ? (uint16_t)(((uint16_t)x[2] << 8) | x[3]) : 0;
if (x.size() >= 4) id(awning_current).publish_state(cur);
if (x[0] == 0xC2) id(awning).current_operation = esphome::cover::COVER_OPERATION_OPENING; 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 if (x[0] == 0xC3) id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING;
else id(awning).current_operation = esphome::cover::COVER_OPERATION_IDLE; else id(awning).current_operation = esphome::cover::COVER_OPERATION_IDLE;
// Stall gate: only while auto-retracting (C3), after the inrush
// blanking window. cur>2500 for 3 consecutive frames (~150ms @
// 20Hz) = fully closed → clear the session flag (interval stops
// streaming → motor halts) and mark the cover CLOSED (the one
// direction we get a real end-stop, so it's no longer assumed).
if (x[0] == 0xC3) id(g_awn_last_c3_ms) = millis();
if (id(g_awn_active) && x[0] == 0xC3 && x.size() >= 4) {
if (millis() - id(g_awn_start_ms) > 1200) {
if (cur > 2500) {
if (++id(g_awn_stall_count) >= 3) {
id(g_awn_active) = false;
id(awning).position = esphome::cover::COVER_CLOSED;
ESP_LOGI("awning", "auto-retract: stall %u -> stop (CLOSED)", cur);
}
} else {
id(g_awn_stall_count) = 0;
}
}
}
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.)
} }
} }
@@ -255,6 +282,29 @@ globals:
# GlobalsComponent<T>(T) and (std::array<...>) constructors under the current # GlobalsComponent<T>(T) and (std::array<...>) constructors under the current
# toolchain. zero-initialized -> every node starts as type 0 (not yet seen). # toolchain. zero-initialized -> every node starts as type 0 (not yet seen).
initial_value: 'std::array<uint8_t, 256>{}' initial_value: 'std::array<uint8_t, 256>{}'
# --- Awning auto-retract session state -----------------------------------
# Set true by awning_auto_retract; the 100ms interval streams the retract
# opcode + page-44 keepalive while true, and the page-3 stall gate (or the
# timeout) clears it. Cleared → streaming stops → motor halts within ~1s
# (hold-to-run), so this bool is the master kill-switch for awning motion.
- id: g_awn_active
type: bool
initial_value: 'false'
- id: g_awn_start_ms
type: uint32_t
initial_value: '0'
- id: g_awn_stall_count
type: uint8_t
initial_value: '0'
- id: g_awn_ka_tick
type: uint8_t
initial_value: '0'
# millis() of the last observed retracting (C3) frame — lets the interval end
# the session ~1s after motion actually stops (whether the stall gate cut it,
# the OEM controller cut at its own limit, or streaming failed to sustain).
- id: g_awn_last_c3_ms
type: uint32_t
initial_value: '0'
script: script:
- id: send_load_command - id: send_load_command
@@ -316,6 +366,74 @@ script:
id(g_cmd_node)); id(g_cmd_node));
id(g_cmd_pending) = false; id(g_cmd_pending) = false;
# -------------------------------------------------------------------------
# Awning auto-retract — retract to the fully-closed hard stop, then stop by
# sensing the motor-current stall spike (no position feedback on the bus).
#
# Movement is HOLD-TO-RUN: a single authenticated opcode runs the motor only
# ~1s. The OEM app sustains motion by streaming the opcode (~110ms) plus a
# page-44 keepalive (~510ms) after ONE auth (captured 2026-06-11). We do the
# same: authenticate once via send_load_command, then the 100ms `interval`
# below streams opcode+keepalive while g_awn_active. The page-3 stall gate
# (case 0x75) clears g_awn_active at the closed stop; the interval's 70s
# timeout is the backstop. Stop streaming = motor stops within ~1s, so this
# fails safe on crash/Wi-Fi loss/stop-press.
- id: awning_auto_retract
mode: single # ignore re-press while a retract is already running
then:
- lambda: |-
id(g_awn_stall_count) = 0;
id(g_awn_ka_tick) = 0;
id(g_awn_start_ms) = millis();
id(g_awn_last_c3_ms) = millis();
id(g_awn_active) = true;
id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING;
id(awning).publish_state();
# Authenticate + start motion via the proven single-shot path; the
# interval then keeps it moving until the stall gate or timeout fires.
- script.execute: { id: send_load_command, node: 0x75, op: 2 }
- id: awning_stop
then:
- lambda: 'id(g_awn_active) = false;' # halt the stream (motor stops ~1s)
- script.execute: { id: send_load_command, node: 0x75, op: 0 }
# ---------------------------------------------------------------------------
# Awning motion streamer — while g_awn_active, refresh the hold-to-run opcode
# every 100ms and the page-44 keepalive every ~500ms; enforce the safety
# timeout. Idle (g_awn_active false) → returns immediately, transmits nothing.
# ---------------------------------------------------------------------------
interval:
- interval: 100ms
then:
- lambda: |-
if (!id(g_awn_active)) return;
if (millis() - id(g_awn_start_ms) > 70000) {
id(g_awn_active) = false;
ESP_LOGW("awning", "auto-retract: 70s timeout -> stop");
return;
}
// motion-lost: once past the auth/startup window, if no retracting (C3)
// frame has arrived for >1s the motor isn't moving (stall gate/OEM
// cutoff/failed-to-sustain) — end the session instead of streaming on.
if (millis() - id(g_awn_start_ms) > 2500 &&
millis() - id(g_awn_last_c3_ms) > 1000) {
id(g_awn_active) = false;
ESP_LOGI("awning", "auto-retract: motion ended -> stop");
return;
}
// stream the retract opcode (0006 75 02, DLC 0) — refreshes hold-to-run
uint32_t op_id = 0x00060000u | (0x75u << 8) | 0x02u;
std::vector<uint8_t> op_empty; // DLC 0
id(can_bus).send_data(op_id, true, op_empty);
// page-44 keepalive (0004 75 44, payload 00 04) every ~500ms
if (++id(g_awn_ka_tick) >= 5) {
id(g_awn_ka_tick) = 0;
uint32_t ka_id = 0x00040044u | (0x75u << 8);
std::vector<uint8_t> ka = {0x00, 0x04};
id(can_bus).send_data(ka_id, true, ka);
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Read-back sensors (published by the dispatcher above) # Read-back sensors (published by the dispatcher above)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -354,6 +472,15 @@ sensor:
id: grey_tank_2 id: grey_tank_2
unit_of_measurement: "%" unit_of_measurement: "%"
accuracy_decimals: 0 accuracy_decimals: 0
- platform: template
name: "Awning Motor Current"
id: awning_current
# Raw node-75 page-3 b2-3 counts (not amps): ~<1550 running incl. inrush,
# ramps to a ~4200 plateau at the fully-closed hard stop. Only updates while
# the motor moves (20Hz); holds last value at rest. Feeds the stall gate.
unit_of_measurement: "raw"
accuracy_decimals: 0
state_class: measurement
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fault indicators (published by the dispatcher above) # Fault indicators (published by the dispatcher above)
@@ -443,12 +570,16 @@ switch:
# (no position slider); current_operation is published from the page-3 motion # (no position slider); current_operation is published from the page-3 motion
# byte in the dispatcher above. # byte in the dispatcher above.
# #
# ⚠️ FIRST ACTUATION MUST BE ATTENDED. We haven't confirmed whether one command # Motion is HOLD-TO-RUN (confirmed 2026-06-11 capture + 2026-07-01 live): one
# latches the motor (runs to the travel limit) or is hold-to-run (the OEM app # opcode runs the motor ~1s. So:
# streamed repeats, which hints at hold-to-run). The single-shot command here is # CLOSE = auto-retract — stream to the fully-closed stall, stop on current
# the safe default: if hold-to-run, the awning just moves a little and stops on # spike (awning_auto_retract script + interval + stall gate). This is
# its own — it can't run away. If it under-travels, stream the command (repeat # the one direction with a real end-stop, so it also marks CLOSED.
# send_load_command until stop) — do that change only after watching it move. # STOP = abort any active retract (clears g_awn_active) + send stop opcode.
# NO open_action ON PURPOSE: a single opcode only jogs the motor ~1s (useless),
# and there's no position/end-stop feedback to safely auto-extend on a timer.
# Extend the awning at the OEM wall switch. Omitting open_action drops SUPPORT_OPEN
# so HA shows only close/stop. assumed_state -> both stay always-pressable (no slider).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
cover: cover:
- platform: template - platform: template
@@ -456,9 +587,7 @@ cover:
id: awning id: awning
device_class: awning device_class: awning
assumed_state: true assumed_state: true
open_action:
- script.execute: { id: send_load_command, node: 0x75, op: 1 }
close_action: close_action:
- script.execute: { id: send_load_command, node: 0x75, op: 2 } - script.execute: awning_auto_retract
stop_action: stop_action:
- script.execute: { id: send_load_command, node: 0x75, op: 0 } - script.execute: awning_stop
+8 -2
View File
@@ -11,7 +11,11 @@
# Repo copy: canbus/ha/mqtt_bridge_onecontrol.yaml # Repo copy: canbus/ha/mqtt_bridge_onecontrol.yaml
automation: automation:
# --- MQTT Discovery publish on startup / reload / every 6h --- # --- MQTT Discovery + state republish on startup / reload / every 15 min ---
# The 15-min cadence (was 6h) heals stale retained switch states when an
# on-change forward publish is lost during a roof-AP/MQTT link flap — the
# weak-link failure that left home showing Exterior off while the camper
# was on (2026-06-14). Config republishes are idempotent (HA dedupes).
- id: campsite_mqtt_discovery - id: campsite_mqtt_discovery
alias: "MQTT Bridge: Publish Discovery" alias: "MQTT Bridge: Publish Discovery"
triggers: triggers:
@@ -20,7 +24,7 @@ automation:
- trigger: event - trigger: event
event_type: automation_reloaded event_type: automation_reloaded
- trigger: time_pattern - trigger: time_pattern
hours: "/6" minutes: "/15"
actions: actions:
- delay: "00:00:05" - delay: "00:00:05"
- action: mqtt.publish - action: mqtt.publish
@@ -212,6 +216,8 @@ automation:
"unique_id": "campsite_awning", "unique_id": "campsite_awning",
"state_topic": "campsite/cover/awning/state", "state_topic": "campsite/cover/awning/state",
"command_topic": "campsite/cover/awning/set", "command_topic": "campsite/cover/awning/set",
"optimistic": true,
"payload_open": null,
"device_class": "awning", "device_class": "awning",
"availability_topic": "campsite/onecontrol/availability", "availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}} "device": {"identifiers": ["campsite_onecontrol"]}}