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 —
|
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 ~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
|
**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
@@ -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
|
||||||
|
|||||||
@@ -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"]}}
|
||||||
|
|||||||
Reference in New Issue
Block a user