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 —
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
watching it.
page-42/43 exchange.
**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
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
Bluetooth integration is no longer needed for control. Next step: fold the
challenge-response into the ESPHome node's `switch`/`light`/`cover` actions (the
opcode just needs the page-42/43 exchange in front of it).
Bluetooth integration is no longer needed for control. The challenge-response is
folded into the ESPHome node's `switch`/`light`/`cover` actions (opcode preceded
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
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);
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:
// / C3 retracting (closing); b2-3 (BE) = motor current (raw
// counts, ~<1550 running incl. inrush, ~4200 at the fully-closed
// 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;
else if (x[0] == 0xC3) id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING;
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();
break;
}
// (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
# toolchain. zero-initialized -> every node starts as type 0 (not yet seen).
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:
- id: send_load_command
@@ -316,6 +366,74 @@ script:
id(g_cmd_node));
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)
# ---------------------------------------------------------------------------
@@ -354,6 +472,15 @@ sensor:
id: grey_tank_2
unit_of_measurement: "%"
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)
@@ -443,12 +570,16 @@ switch:
# (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.
# Motion is HOLD-TO-RUN (confirmed 2026-06-11 capture + 2026-07-01 live): one
# opcode runs the motor ~1s. So:
# CLOSE = auto-retract — stream to the fully-closed stall, stop on current
# spike (awning_auto_retract script + interval + stall gate). This is
# the one direction with a real end-stop, so it also marks CLOSED.
# 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:
- platform: template
@@ -456,9 +587,7 @@ cover:
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 }
- script.execute: awning_auto_retract
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
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
alias: "MQTT Bridge: Publish Discovery"
triggers:
@@ -20,7 +24,7 @@ automation:
- trigger: event
event_type: automation_reloaded
- trigger: time_pattern
hours: "/6"
minutes: "/15"
actions:
- delay: "00:00:05"
- action: mqtt.publish
@@ -212,6 +216,8 @@ automation:
"unique_id": "campsite_awning",
"state_topic": "campsite/cover/awning/state",
"command_topic": "campsite/cover/awning/set",
"optimistic": true,
"payload_open": null,
"device_class": "awning",
"availability_topic": "campsite/onecontrol/availability",
"device": {"identifiers": ["campsite_onecontrol"]}}