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:
+27
-5
@@ -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 ~350–500 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
@@ -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
|
||||
|
||||
@@ -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"]}}
|
||||
|
||||
Reference in New Issue
Block a user