Repository navigation
fix(crow_alarm_panel): pace arm/disarm retries, match ARMED_STATE intent - #12
Conversation
HA log 2026-08-19 17:09 shows two consecutive disarm calls exhausting all 5 back-to-back retries (12 failed attempts in ~21 s) during a controller degradation episode that outlasted the ~7 s retry envelope — pings stopped and both physical keypads re-registered ~90 s later. A manual call ~8 s after the second abort succeeded first try. - Pace retries with a growing backoff (1/2/3/5/8/13 s) and raise ARM_DISARM_MAX_RETRIES to 6, stretching one call's envelope to ~40 s; ignore KEYPAD_COMMANDs while a backoff wait is pending so a stray periodic command can't advance the dead sequence. - Resolve on ARMED_STATE broadcasts from any non-IDLE state, but only when they match the request's intent (Disarmed for disarm, Arming or Armed Away for arm): a late Disarmed mid-retry could otherwise let the retry re-arm the panel via code+ENTER-while-disarmed, and a registration re-announce of Armed Away could falsely complete a disarm. Compiles against crow_alarm_panel_test.yaml; not yet hardware-validated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cast arm_disarm_retry_backoff_ms_ (uint32_t, long unsigned int on this target) to unsigned for the %u format specifier, matching the existing (unsigned) casts used for the other uint32 durations logged here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Terminal-key handling has a re-entrancy race that can resurrect a completed sequence and access an empty code vector.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds paced arm/disarm retries and intent-matched controller confirmations.
Changes:
- Adds growing retry backoff with six retries.
- Resolves requests from matching
ARMED_STATEbroadcasts. - Documents trace evidence and behavior.
File summaries
| File | Description |
|---|---|
components/crow_alarm_panel/crow_alarm_panel.cpp |
Implements retry pacing and intent matching. |
components/crow_alarm_panel/crow_alarm_panel.h |
Defines retry state and backoff schedule. |
components/crow_alarm_panel/docs/arm_disarm_state_machine.md |
Documents rationale and state-machine changes. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| this->keypress(this->arm_disarm_terminal_key_); | ||
| this->arm_disarm_state_ = ArmDisarmState::CODE_ENTER_PENDING; | ||
| this->arm_disarm_state_enter_ms_ = millis(); |
There was a problem hiding this comment.
Fixed in 00a7d1f — CODE_ENTER_PENDING and its timestamp are now set before the terminal keypress(), matching the ordering the other send paths use, so a matching ARMED_STATE broadcast arriving during the send's yield can no longer be overwritten and the watchdog retry can't index the cleared code vector.
keypress()->send_packet() can delay()/yield() and re-enter loop(). If the matching ARMED_STATE broadcast resolved the sequence during that yield, assigning CODE_ENTER_PENDING after the call resurrected the completed sequence with an emptied code vector, and the watchdog retry would then index arm_disarm_code_digits_[0] out of bounds. Set state and timestamp before the keypress, as every other send path already does. Flagged by Copilot review on PR #12. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
An in-flight keypress can still transmit after a matching broadcast resolves the operation, preserving the re-arming hazard.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
components/crow_alarm_panel/docs/arm_disarm_state_machine.md:110
- This updates only the
CODE_ENTER_PENDINGtimeout transition, but the watchdog retries every non-IDLE arm/disarm state. The diagram still saysARM_AWAY_PENDING/ARM_STAY_PENDINGandCODE_DIGIT_PENDINGtimeouts abort directly to IDLE at lines 83–84 and 97–98; update those transitions to show the same backoff/retry behavior.
├─ on timeout (>1s)
│ └─> retry with growing backoff, up to ARM_DISARM_MAX_RETRIES; then IDLE (abort; failure)
components/crow_alarm_panel/crow_alarm_panel.h:335
ARM_DISARM_MAX_RETRIES = 6produces seven total attempts: the initial attempt plus six retries, as the six backoff slots also imply. The PR Summary says “6 attempts”; update it to “6 retries / 7 attempts” so the advertised behavior matches the implementation and the documented ~40-second envelope.
static const uint8_t ARM_DISARM_MAX_RETRIES = 6;
components/crow_alarm_panel/docs/arm_disarm_state_machine.md:465
- Remove the duplicated singular/plural wording in “failure mode (modes documented above).”
multi-second gaps. Hammering also adds bus contention, the prime suspect for the collision
failure mode (modes documented above).
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
| this->arm_disarm_state_ = ArmDisarmState::IDLE; | ||
| this->arm_disarm_code_digits_.clear(); | ||
| this->arm_disarm_code_idx_ = 0; | ||
| this->arm_disarm_retry_count_ = 0; | ||
| this->arm_disarm_retry_pending_ = false; |
There was a problem hiding this comment.
Fixed in ad453ec. Arm/disarm keys now go through a cancellable send path: arm_disarm_keypress_() captures a generation token (arm_disarm_generation_) before the bus-idle wait and re-checks it immediately before the blocking transmit. Every resolution to IDLE (matching ARMED_STATE broadcast, ARM/STAY ack, watchdog abort) bumps the token, so a key committed by a sequence that resolved during the wait is suppressed instead of transmitted. send_packet() was split into wait_for_bus_idle_() + transmit_packet_() so both paths share the same wait/transmit code; other send paths are unchanged.
Resolving the arm/disarm state machine to IDLE could not recall a keypress already waiting inside send_packet()'s bus-idle loop — that wait delays/yields (re-entering loop()) and then transmits unconditionally. If a late matching ARMED_STATE broadcast resolved the sequence during a retry's terminal-key wait, ENTER still went out; on a just-disarmed panel the retry's buffered digits + ENTER is exactly the 'arm with code' gesture and could re-arm it. Arm/disarm keys now go through arm_disarm_keypress_(), which captures a generation token before the bus-idle wait and re-checks it immediately before the blocking transmit; every resolution to IDLE (broadcast confirmation, ARM/STAY ack, watchdog abort) bumps the token, so a key whose sequence has since resolved is dropped instead of typed into the panel. send_packet() is split into wait_for_bus_idle_() + transmit_packet_() to share those pieces; behavior of other send paths is unchanged. Flagged by Copilot review on PR #12. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Re-entrant bus waits can currently drop or duplicate arm/disarm keypresses.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
components/crow_alarm_panel/docs/arm_disarm_state_machine.md:110
- The diagram now documents retries only for
CODE_ENTER_PENDING, but the watchdog retries every non-IDLE state. The timeout branches forARM_AWAY_PENDING/ARM_STAY_PENDING(lines 83–84) andCODE_DIGIT_PENDING(lines 97–98) still say they abort directly, so the state-machine reference remains contradictory. Update those branches to show the same backoff/retry transition.
├─ on timeout (>1s)
│ └─> retry with growing backoff, up to ARM_DISARM_MAX_RETRIES; then IDLE (abort; failure)
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
| if (gen != this->arm_disarm_generation_) { | ||
| ESP_LOGD(TAG, "Arm/disarm: sequence resolved during bus-idle wait, suppressing key 0x%02X", key); | ||
| return; |
There was a problem hiding this comment.
Fixed in ff3e998 via the gating option: arm_disarm_key_in_flight_ is true from commit to transmit, and the KEYPAD_COMMAND-driven state machine transitions are skipped while it's set — a command arriving during the bus-idle wait can no longer falsely complete ARM_*_PENDING (or advance CODE_DIGIT_PENDING out of order), so the pending key is no longer suppressed by its own pre-transmit window. ARMED_STATE resolution stays ungated deliberately: it's the controller's own state, independent of our TX, and cancelling the in-flight key is exactly what the generation token is for.
| // key must no longer go out: setting arm_disarm_state_ back to IDLE can't recall a keypress | ||
| // already committed to send_packet(), and after a disarm resolution a retry's already-typed | ||
| // digits + ENTER is exactly the "arm with code" gesture (could re-arm the panel). | ||
| const uint32_t gen = this->arm_disarm_generation_; |
There was a problem hiding this comment.
Fixed in ff3e998. Rather than a per-send generation, the watchdog is now gated while arm_disarm_key_in_flight_ is set — a retry can no longer start from inside an earlier keypress's bus-idle wait, so two concurrent waiters can't exist (the CMD-driven transitions are gated too, and new user requests are already blocked by the non-IDLE state guard). Timing out an attempt whose key hasn't transmitted yet was premature anyway; the no-progress window now restarts from the actual transmit.
The generation-token cancellation (ad453ec) exposed two windows where the state machine acts on events preceding the committed key's actual transmission (Copilot review on PR #12): - A periodic KEYPAD_COMMAND arriving during KEY_ARM/KEY_STAY's bus-idle wait falsely completed ARM_*_PENDING (it cannot ack a key that has not transmitted) and bumped the generation, so the arm key was then suppressed entirely — sequence reported done, panel never armed. The same pre-transmit CMD could also advance CODE_DIGIT_PENDING and emit digits out of order. - The watchdog could start a retry from inside an earlier keypress's bus-idle wait; both waiters then passed the generation check and transmitted duplicate keys. arm_disarm_key_in_flight_ is now true from commit to transmit; the KEYPAD_COMMAND-driven transitions and the watchdog are skipped while it is set. ARMED_STATE resolution stays ungated — it is the controller's own state and cancelling the in-flight key is the token's purpose. On transmit the no-progress window restarts from the actual send, since the wait may have consumed the 1 s budget while the watchdog was gated. Also updates the state diagram's stale timeout branches (retries apply to every non-IDLE state, not just CODE_ENTER_PENDING). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Output-select can start during the extended backoff and interleave keys with the arm/disarm retry.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
components/crow_alarm_panel/docs/arm_disarm_state_machine.md:465
- The sentence contains a duplicated construction: “failure mode (modes documented above).”
failure mode (modes documented above).
components/crow_alarm_panel/docs/arm_disarm_state_machine.md:540
- This section is above the Notes section, so “below” points readers in the wrong direction.
- ARMED_STATE messages (0x11) are published independently regardless of `arm_disarm_state_` (entities always reflect them). As of 2026-08-22, an ARMED_STATE broadcast matching the request's intent resolves the sequence from **any** non-IDLE state (including a retry backoff wait) — see "Retry backoff + intent-matched ARMED_STATE resolution" below. `CODE_ENTER_PENDING` remains the only state that *requires* one to succeed (`ARM_AWAY_PENDING`/`ARM_STAY_PENDING` still resolve on their KEYPAD_COMMAND ack).
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Balanced
| if (this->arm_disarm_retry_pending_) { | ||
| if (now_ms - this->arm_disarm_state_enter_ms_ >= this->arm_disarm_retry_backoff_ms_) { |
There was a problem hiding this comment.
Fixed in b9e78ff. Every keypress-sequence entry point now enforces the mutual exclusion: set_output(), arm_away(), arm_stay() and disarm() reject starting while any other machine is non-IDLE, matching the check set_zone_bypass() already had. Since arm_disarm_state_ stays non-IDLE for the whole retry-backoff envelope, that covers the multi-second window this comment describes.
| // zone-bypass a blind retry can't undo a change that already landed. 6 with the growing | ||
| // backoff below spans the worst episode observed so far (HA log 2026-08-19 17:09: two | ||
| // consecutive calls exhausted 5 back-to-back retries each, and a manual call ~29 s after the | ||
| // first attempt succeeded — back-to-back retries burned the whole budget in ~7 s, well inside | ||
| // that episode). |
There was a problem hiding this comment.
Fixed in b9e78ff — the comment now states the observed facts (timeouts have almost always meant no state change, but an 825 ms boundary ack shows a confirmation can land after the watchdog fires) and attributes retry safety to intent-matched ARMED_STATE resolution plus the generation-token cancellation instead of the disproven timeout assumption.
| retry envelope from ~7 s to ~40 s — spanning the ~29 s recovery observed on 2026-08-19. While | ||
| the backoff is pending, `KEYPAD_COMMAND`s no longer advance the (dead) sequence — without that | ||
| gate, a stray periodic command arriving mid-wait would type leftover digits into the panel | ||
| outside any attempt. `send_packet()`'s existing bus-idle wait covers TX timing when the retry |
There was a problem hiding this comment.
Fixed in b9e78ff — the retry description now says the keys go through arm_disarm_keypress_() (same bus-idle wait, plus the generation check and in-flight gating described in the follow-up section). Also fixed the two suppressed doc nits from this review (duplicated phrase, below-vs-above pointer).
All three keypress state machines (output-select, arm/disarm, zone bypass) advance on the same addressed KEYPAD_COMMAND frames, so only one may run at a time — but only set_zone_bypass() enforced that. set_output() could start during an arm/disarm sequence (and vice versa), and the new multi-second retry backoff keeps arm_disarm_state_ non-IDLE long enough to make that overlap realistic; both machines would then emit keys and double-consume confirmations (Copilot review on PR #12). set_output(), arm_away(), arm_stay() and disarm() now reject starting while another machine is non-IDLE, matching set_zone_bypass(). Also rewrites the ARM_DISARM_MAX_RETRIES rationale: 'a timeout reliably means no state change' is disproven by the observed 825 ms boundary ack — retry safety actually comes from intent-matched ARMED_STATE resolution plus generation-token cancellation. Fixes two stale doc references (send_packet() vs arm_disarm_keypress_() in the retry description, and a below-vs-above section pointer) and a duplicated phrase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Newly rejected overlapping operations can leave ESPHome entities reporting optimistic states for actions that never started.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
components/crow_alarm_panel/crow_alarm_panel.cpp:948
- The new backoff explanation still follows the assertion that a timeout “reliably means the panel's state did not change,” which this PR's late-confirmation analysis explicitly disproves. Retry safety now comes from intent matching and cancellation, so retaining the old rationale can lead future watchdog changes to remove those safeguards.
// Retries are paced by a growing backoff (ARM_DISARM_RETRY_BACKOFF_MS) instead of restarting
components/crow_alarm_panel/crow_alarm_panel.cpp:1109
- This rejection is invisible to
CrowAlarmControlPanel::control(), which unconditionally publishesACP_STATE_ARMINGafterarm_stay()(crow_alarm_control_panel.cpp:40-41). When another keypress sequence is active, the arm-stay operation never starts and there is no watchdog to restore the optimistic state. Return an acceptance result and publishARMINGonly when the parent accepted the request.
if (this->output_select_state_ != OutputSelectState::IDLE ||
this->zone_bypass_state_ != ZoneBypassState::IDLE) {
ESP_LOGW(TAG, "arm_stay: another keypress sequence in progress, ignoring");
return;
components/crow_alarm_panel/crow_alarm_panel.cpp:1142
CrowAlarmControlPanel::control()publishesACP_STATE_DISARMINGafter this void call regardless of this new rejection (crow_alarm_control_panel.cpp:50-51). A disarm request overlapping an output/bypass sequence therefore leaves the entity inDISARMINGeven though no disarm state machine or abort recovery is running. Return whether the request was accepted and condition the optimistic publish on that result.
if (this->output_select_state_ != OutputSelectState::IDLE ||
this->zone_bypass_state_ != ZoneBypassState::IDLE) {
ESP_LOGW(TAG, "disarm: another keypress sequence in progress, ignoring");
return;
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
| if (this->output_select_state_ != OutputSelectState::IDLE || | ||
| this->zone_bypass_state_ != ZoneBypassState::IDLE) { | ||
| ESP_LOGW(TAG, "arm_away: another keypress sequence in progress, ignoring"); | ||
| return; |
There was a problem hiding this comment.
Fixed in 2f8d089. arm_away(), arm_stay() and disarm() (plus the shared start_code_sequence_()) now return whether the request was accepted, and control() publishes ACP_STATE_ARMING/DISARMING only on acceptance — a rejected request leaves the entity in its current state instead of stranding it in a transitional one. The buttons ignore the result deliberately: they're stateless and the rejection is already logged.
| if (this->arm_disarm_state_ != ArmDisarmState::IDLE || | ||
| this->zone_bypass_state_ != ZoneBypassState::IDLE) { | ||
| ESP_LOGW(TAG, "set_output(%u, %s): another keypress sequence in progress, ignoring", output, | ||
| state ? "on" : "off"); | ||
| return; |
There was a problem hiding this comment.
Fixed in 2f8d089 — set_output() now returns whether the sequence started, and the output switch publishes the requested state only on acceptance; on rejection the entity keeps its last state (no restore needed since nothing was published).
The mutual-exclusion guards (b9e78ff) made rejected requests silent to their callers, which publish optimistic entity states unconditionally (Copilot review on PR #12): - CrowAlarmControlPanel::control() published ARMING/DISARMING after arm_away()/arm_stay()/disarm() even when the request was rejected — no sequence and no watchdog started, so nothing would ever move the entity out of the transitional state again (ESPHome's validate_() then rejects both future arm and disarm calls). - The output switch published the requested state after set_output() even when no output keys were sent, misreporting the output until the next controller broadcast. arm_away(), arm_stay(), disarm(), set_output() (and the shared start_code_sequence_()) now return whether the request was accepted, and both callers publish the optimistic state only on acceptance. Buttons ignore the result — they are stateless and the rejection is already logged. Also updates the loop() watchdog comment still asserting 'a timeout reliably means the panel's state did not change' (disproven by the 825 ms boundary ack; the header rationale was fixed in b9e78ff). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Synchronous confirmations can be overwritten by optimistic transitional states after arm/disarm calls return.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
components/crow_alarm_panel/crow_alarm_control_panel.cpp:35
arm_away()may yield while waiting for an idle bus. A matchingARMED_STATEcan therefore publishARMED_AWAYand resolve the sequence before this call returns, after which this unconditional optimistic publish overwrites the confirmed state withARMINGand no watchdog remains to restore it. Preserve a terminal state that arrived synchronously during the call.
This issue also appears in the following locations of the same file:
- line 44
- line 55
if (this->parent_->arm_away(code)) {
this->publish_state(alarm_control_panel::ACP_STATE_ARMING);
components/crow_alarm_panel/crow_alarm_control_panel.cpp:45
arm_stay()can process a matchingARMED_STATEduring its bus-idle yield and return after the handler has already publishedARMED_AWAYand resolved the sequence. PublishingARMINGafterward regresses the confirmed state permanently because the operation is already IDLE. Skip the optimistic publish when a terminal armed state arrived during the call.
if (this->parent_->arm_stay(code)) {
this->publish_state(alarm_control_panel::ACP_STATE_ARMING);
components/crow_alarm_panel/crow_alarm_control_panel.cpp:56
disarm()may yield until a matchingDisarmedbroadcast publishes the confirmed state and resolves the sequence, yet it still returnstrue. This subsequent publish then overwritesDISARMEDwithDISARMING; because the machine is already IDLE, no retry/abort path corrects it. Keep the confirmed state when it arrived during the call.
if (this->parent_->disarm(code)) {
this->publish_state(alarm_control_panel::ACP_STATE_DISARMING);
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
An accepted arm/disarm call can resolve inside the call itself: the bus-idle wait yields into loop(), where a matching ARMED_STATE broadcast publishes the confirmed terminal state and resolves the sequence to IDLE. control() then overwrote that confirmed state with the optimistic ARMING/DISARMING — and with the machine already IDLE, no watchdog remained to correct it (Copilot review on PR #12). control() now publishes the transitional state only when the request was accepted AND the sequence is still unresolved on return, via a new is_arm_disarm_in_progress() accessor. The synchronous-resolution path is the only way the machine can reach IDLE during the call (the KEYPAD_COMMAND transitions and the watchdog are both gated while the key is in flight), and it always publishes the confirmed state first, so skipping the optimistic publish is correct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the latest review's suppressed findings in be5ed60: 🤖 Generated with Claude Code |
There was a problem hiding this comment.
🟡 Changes recommended
Re-entrant waits can let stale control calls overwrite a newer operation’s state.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
components/crow_alarm_panel/crow_alarm_control_panel.cpp:47
- As above, the global in-progress flag does not prove that the active sequence is this
arm_stay()request. Because the call yields, a resolved request can be replaced re-entrantly by a different operation before returning; this stale caller would then publishARMINGover the newer operation's state. Gate the publish with the identity/generation of the operation started by this call.
if (this->parent_->arm_stay(code) && this->parent_->is_arm_disarm_in_progress()) {
this->publish_state(alarm_control_panel::ACP_STATE_ARMING);
components/crow_alarm_panel/crow_alarm_control_panel.cpp:58
- A newer arm operation can make this global in-progress check true after this disarm already resolved during its bus-idle yield. The older control call would then publish
DISARMING, overwriting the newer request'sARMINGstate. Return/capture an operation generation and only publish when that same disarm operation is still active.
if (this->parent_->disarm(code) && this->parent_->is_arm_disarm_in_progress()) {
this->publish_state(alarm_control_panel::ACP_STATE_DISARMING);
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
| if (this->parent_->arm_away(code) && this->parent_->is_arm_disarm_in_progress()) { | ||
| this->publish_state(alarm_control_panel::ACP_STATE_ARMING); |
There was a problem hiding this comment.
Fixed in 372c3e9 with the suggested generation approach. control() captures arm_disarm_generation() before each call and publishes the transitional state only if it's unchanged after an accepted call. The counter bumps on every resolution to IDLE and a new operation can only start after the previous one resolved, so an unchanged token proves this call's operation is still the active one — covering the synchronous resolution and the re-entrant replacement (this comment, plus both suppressed variants for arm_stay/disarm). The global is_arm_disarm_in_progress() accessor is gone, replaced by the generation accessor.
The is_arm_disarm_in_progress() gate (be5ed60) is a global flag: after an accepted call's operation resolved during its bus-idle yield, a different arm/disarm operation could start re-entrantly (e.g. an on_message automation) before the original stack resumed, making the flag true for the newer operation — the stale caller would then publish ARMING/DISARMING over the newer operation's state (Copilot review on PR #12). control() now captures arm_disarm_generation() before each call and publishes only if it is unchanged after an accepted call. The counter bumps on every resolution to IDLE, and a new operation can only start after the previous one resolved, so an unchanged token proves this call's operation is still the active one — covering both the synchronous-resolution case (be5ed60's target) and the re-entrant replacement case. The in-progress accessor is replaced by the generation accessor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
A stale re-entrant digit continuation can overwrite a newer operation’s retry timestamp.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
components/crow_alarm_panel/crow_alarm_panel.cpp:701
arm_disarm_keypress_()can yield, resolve this operation, and let anon_messagecallback start a newer arm/disarm operation before returning. This stale continuation then overwrites the newer operation's watchdog/backoff timestamp. The helper already timestamps a successfully transmitted key, so remove this post-call assignment to avoid delaying or corrupting the newer operation's retry schedule.
this->arm_disarm_state_enter_ms_ = millis();
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
arm_disarm_keypress_() has refreshed arm_disarm_state_enter_ms_ on successful transmit since the in-flight gating change, so the assignment after the CODE_DIGIT_PENDING digit send was redundant on success — and harmful when stale: if the operation resolved during the call's bus-idle yield and a newer operation started re-entrantly, the resumed continuation overwrote the newer operation's watchdog/backoff timestamp, delaying or corrupting its retry schedule (Copilot review on PR #12). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the latest review's suppressed finding in 94f1dbe: removed the 🤖 Generated with Claude Code |
The 60s ping watchdog only checked last_ping_ms_, so a still-stale ping re-tripped it on the very next loop() pass right after a resend and fired a second announce ~1s later, before the controller had a chance to respond to the first. Gate re-trips on time since our own last announce too.
Three occurrences, consistent poll-cycle slot, address-like leading byte not matching any active keypad. Meaning not established; logged so it's recognized and not confused with the ff/fe or 0x8A corruption signatures if seen again.
There was a problem hiding this comment.
🟡 Changes recommended
The unmentioned registration retry-cadence change needs explicit scope and validation in the PR description.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Balanced
| if (this->registration_sent_ && this->last_ping_ms_ != 0 && | ||
| (now_ms - this->last_ping_ms_) >= 60000) { | ||
| (now_ms - this->last_ping_ms_) >= 60000 && | ||
| (now_ms - this->last_registration_announce_ms_) >= 60000) { |
Summary
disarm()/arm_away()/arm_stay()call can ride out a multi-second controller degradation episode instead of exhausting its whole retry budget in ~7 s. Motivated bytraces/home-assistant_2026-08-19T08-03-09.331Z.log, where two consecutive disarm calls each burned all 5 old-style retries in ~10 s during an episode where the controller later stopped pinging and both physical keypads re-registered.ARMED_STATEbroadcast from any non-IDLE arm/disarm state (not justCODE_ENTER_PENDING), but only when it matches the request's intent (Disarmedfor disarm;Arming/Armed Awayfor arm). Closes two latent hazards made more likely by longer retry windows: a late broadcast landing mid-retry could previously let a disarm retry keep typing code+ENTER into an already-disarmed panel (re-arming it), and a registration-handshake re-announce of the current state could previously false-positive a disarm as complete.-Wformatwarning (uint32_t/%umismatch) on the new backoff log line, verified against both the repo's pinned esphome 2026.7.2 and an isolated esphome 2026.8.0 install.docs/arm_disarm_state_machine.mdupdated with the full trace analysis and rationale for both changes.Test plan
uv run esphome config crow_alarm_panel_test.yamluv run esphome compile crow_alarm_panel_test.yaml(esphome 2026.7.2, pinned) — clean, no warningsesphome==2026.8.0compile of the same fixture — clean, no warnings