Closing My Awning, With Style
My travel trailer's awning has exactly two states as far as its factory controls are concerned: a button that extends it a little, and a button that retracts it a little. Hold either one and the awning moves; let go and it stops. There is no "close the awning" command, no position sensor, no limit switch feeding back to anything I can read. The OEM app shows the awning as a cover with an open and a close arrow, but under the hood those arrows are just the same hold-to-run jog, dressed up.
I'd already reverse-engineered enough of the trailer's Lippert CANbus to read tank levels, toggle lights, and sense a water-heater fault the factory app never surfaced. (That's a whole other story that I haven't written yet.) But the awning nagged at me. In Home Assistant it sat there permanently reading "open," with two buttons that each nudged it for about a second. Useless for anything I actually wanted, which was: press one button, walk away, and have the awning pull itself all the way in and know it was closed.
The trailer gives me no position feedback to work with. But it turns out it gives me something better.
CANbus is cool, there are often undocumented nodes with useful information
The awning motor is driven by an H-bridge node on the CANbus — node 0x75 in Lippert's addressing. Like every node on this bus, it broadcasts its state once a second (and immediately on any change) as a six-byte frame. I'd already mapped the first byte: 0xC0 means idle, 0xC2 means extending, 0xC3 means retracting.
Here's the node sitting still:
375 [6] C0 FF 00 00 00 00
375 [6] C0 FF 00 00 00 00
375 [6] C0 FF 00 00 00 00And here it is the instant I hit retract:
375 [6] C3 FF 00 06 00 00
375 [6] C3 FF 00 79 00 00
375 [6] C3 FF 04 48 00 00
375 [6] C3 FF 05 1E 00 00
375 [6] C3 FF 05 93 00 00
375 [6] C3 FF 06 0D 00 00Watch bytes three and four. At idle they're 00 00. The moment the motor spins up they climb — 0x0006, 0x0079, 0x0448, 0x051E, 0x0593, 0x060D — and then hover. That's a big-endian 16-bit value, and it tracks exactly what you'd expect a motor to do: a spike at startup (inrush), then a settled running load. It's the live motor current, streamed to me at roughly 20 Hz for free.
Nobody documents this (not publicly anyway). It's just there in the broadcast, and once you notice that the number moves with the motor, the whole problem changes shape. I can't ask the awning where it is. But a motor pulling against a hard stop draws a lot more current than a motor spooling fabric through open air. If I watch that number, the awning will tell me the moment it's fully closed — because that's the moment it can't move any further and the current spikes.
The only question was whether the spike is big enough, and sharp enough, to catch before the motor sits there straining against the stop.
Capturing a full close
I was at the camper anyway, so I extended the awning all the way out, started logging every 0x375 frame, and pulled it all the way in tight. Here's the current trace from that run, in raw counts, decoded from those two bytes.
The running phase is calm. Spooling the fabric in, the motor draws somewhere under ~1550 counts (I don't have any empirical evidence what units the current is reported in. it's probably safe to assume it's milliamps...) , and as the awning gets close to home the load actually tapers down into the 350–500 range:
st=C3 cur=1516 <- running, mid-travel
st=C3 cur=1550
st=C3 cur=1409
...
st=C3 cur=383 <- nearly closed, coasting in
st=C3 cur=377
st=C3 cur=349And then it hits the stop:
st=C3 cur=411
st=C3 cur=524
st=C3 cur=702
st=C3 cur=1156
st=C3 cur=1516
st=C3 cur=1927
st=C3 cur=2524
st=C3 cur=2827
st=C3 cur=3173
st=C3 cur=3881
st=C3 cur=4106
st=C3 cur=4198
st=C3 cur=4211 <- hard stall plateau
st=C3 cur=4204
st=C3 cur=4193
st=C0 cur=2140 <- motor released, current collapsing
st=C0 cur=929
st=C0 cur=175
st=C0 cur=6That's the whole story in one column of numbers. A running load that stays under ~1550 and tapers toward the end, then a clean ramp from ~400 up to a ~4200 plateau as the awning jams against its closed stop, then — the instant the OEM controller cuts the motor — the current collapses back to zero.
Look at the gap. Running: under 1550, usually much less. Stalled: 4200. There is a huge dead zone between them. I don't need a clever threshold or a derivative or any signal processing. I can draw a line straight through the middle at 2500, and running current never comes near it while stall current blows right past it. And the ramp from 500 to 4200 takes about half a second across maybe eight frames — plenty of resolution at 20 Hz to catch it on the way up, well before the motor is sitting there dead-stalled and hot.
That was the whole feasibility question, answered by one careful pull of the awning.
Becoming the finger
Reading is the easy half. Making the awning move and keep moving took some trial and error.
A single motion command runs the motor for only about a second — it's genuinely a "the user is holding the button" signal, not a latch. To sustain motion you have to keep re-sending it. The factory app does exactly this: after one authentication handshake it streams the movement opcode every ~110 ms and sprinkles in a keepalive frame every half-second, for as long as your finger is on the button. Here's the OEM app holding the retract, captured off the bus:
03F27502 [0] <- movement opcode, repeated...
03F27502 [0]
03F27502 [0]
03F07544 [2] 00 04 <- keepalive every ~500ms
03F27502 [0]
03F27502 [0]
03F27502 [0]
03F07544 [2] 00 04(There's a challenge/response auth exchange in front of all this — the node won't act on a cold opcode — but I'd already cracked that for the lights and it's the same handshake here.)
So the recipe for "close the awning" is: authenticate once, then become the finger — stream the retract opcode and keepalive continuously — and watch the current the entire time. When it spikes, stop streaming. The motor stops on its own within a second because I've stopped holding the button. That last part is a lovely safety property that falls out of hold-to-run for free: if my controller crashes, loses Wi-Fi, or I hit stop, the awning halts on its own. It cannot run away.
The whole thing, on the ESP32
I run all of this on an ESP32 wired to the trailer's CANbus via ESPHome, which is what exposes the trailer to Home Assistant. The close logic lives in three small pieces.



The ominous red glow behind the false wall in the cabinet was a bonus
First, a script that opens the session and flips a flag:
- 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_start_ms) = millis();
id(g_awn_last_c3_ms) = millis();
id(g_awn_active) = true;
id(awning).current_operation = COVER_OPERATION_CLOSING;
id(awning).publish_state();
# authenticate + kick off motion via the proven single-shot path
- script.execute: { id: send_load_command, node: 0x75, op: 2 }Then an interval that is the finger — while the session is active, it re-sends the hold-to-run opcode every 100 ms and the keepalive every fifth tick, exactly mimicking the OEM stream, with a hard 70-second timeout as a backstop:
interval:
- interval: 100ms
then:
- lambda: |-
if (!id(g_awn_active)) return;
if (millis() - id(g_awn_start_ms) > 70000) { // safety timeout
id(g_awn_active) = false;
return;
}
// stream the retract opcode (0006 75 02, DLC 0)
uint32_t op_id = 0x00060000u | (0x75u << 8) | 0x02u;
std::vector<uint8_t> empty;
id(can_bus).send_data(op_id, true, 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);
id(can_bus).send_data(ka_id, true, {0x00, 0x04});
}And finally the part that reads the number and decides. Every 0x375 frame that comes off the bus gets decoded; while a retract is active, it runs the stall gate:
uint16_t cur = ((uint16_t)x[2] << 8) | x[3]; // big-endian motor current
id(awning_current).publish_state(cur);
// stall gate: only while retracting, after a startup blanking window,
// require the spike to persist a few frames so a single blip can't trip it.
if (id(g_awn_active) && x[0] == 0xC3) {
if (millis() - id(g_awn_start_ms) > 1200) { // ignore inrush
if (cur > 2500) {
if (++id(g_awn_stall_count) >= 3) { // ~150ms @ 20Hz
id(g_awn_active) = false; // stop streaming -> motor halts
id(awning).position = COVER_CLOSED; // and it's genuinely closed
ESP_LOGI("awning", "auto-retract: stall %u -> stop (CLOSED)", cur);
}
} else {
id(g_awn_stall_count) = 0;
}
}
}Three guards make it trustworthy. The 1200 ms blanking window ignores the startup inrush so the motor spinning up can't be mistaken for a stall. Requiring the current to stay above 2500 for three consecutive frames means one noisy reading can't trip it. And because "closed" is the one direction with a real, physical end-stop, this is the only time I let the code mark the cover's position as genuinely known.
The moment it worked
I pressed close in Home Assistant, walked out to watch, and the awning pulled itself in — a smooth, continuous six-and-a-half seconds of retract, not a one-second jog — planted itself against the stop, and this appeared in the log:
[16:12:38][I][awning]: auto-retract: stall 3767 -> stop (CLOSED)Caught at 3767 counts, on the way up the ramp, before the motor ever sat down hard against the stop. Home Assistant flipped the tile to "closed", which was accurate for once.
There's a satisfying little irony in this one. The awning has no position sensor, no limit switch, nothing that was ever designed to answer "are you closed?" But the motor controller was quietly broadcasting its own effort the entire time, and effort against an immovable stop is the answer. You just need to know where to look.
I've since just removed the "open" button from ha — extending has no hard stop to sense against, and faking one on a timer felt fragile. But closing is the direction that actually matters when a storm's rolling in and I want the awning in now, from my phone, without babysitting a button.