Skip to content

Commit 6ccd808

Browse files
authored
Merge pull request #10 from dan-s-github/dan-dev
feat(crow_alarm_panel): raw bit-trace logging, arm/disarm retry, time-glitch recovery
2 parents 0a49734 + 112d5e4 commit 6ccd808

9 files changed

Lines changed: 760 additions & 26 deletions

components/crow_alarm_panel/crow_alarm_panel.cpp

Lines changed: 122 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ std::string keypad_label(const CrowAlarmPanelKeypad &keypad, uint8_t address) {
4747
return str_sprintf("Keypad 0x%02X", address);
4848
}
4949

50+
// Sakamoto's algorithm. Returns the protocol's day_of_week encoding directly (1=Sunday..7=Saturday,
51+
// matching DAYS[] and CURRENT_TIME's data[0]) rather than the usual 0=Sunday.
52+
uint8_t day_of_week_from_date(uint16_t year, uint8_t month, uint8_t day) {
53+
static const uint8_t OFFSETS[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};
54+
if (month < 3) {
55+
year--;
56+
}
57+
return static_cast<uint8_t>((year + year / 4 - year / 100 + year / 400 + OFFSETS[month - 1] + day) % 7) + 1;
58+
}
59+
5060
const char *controller_status_profile(uint8_t flags) {
5161
if (flags == 0x80) {
5262
return "zone_activity";
@@ -140,6 +150,21 @@ void IRAM_ATTR HOT CrowAlarmPanelStore::interrupt(CrowAlarmPanelStore *arg) {
140150
// Check for boundary
141151
arg->boundary_buffer_ = (uint8_t) ((arg->boundary_buffer_ << 1) | data_bit);
142152

153+
// Gated on !bit_trace_ready_ so bit_trace_buffer2_ stays untouched while loop() (running on
154+
// the other core) is still copying it out — loop()'s InterruptLock only protects against
155+
// same-core reentrancy, not this ISR on the other core, so the flag itself is what prevents
156+
// the overwrite. Bits are simply dropped while the consumer is behind; accumulation resumes
157+
// as soon as it clears the flag (normally sub-millisecond later).
158+
if (arg->bit_trace_enabled_ && !arg->bit_trace_ready_) {
159+
arg->bit_trace_buffer_[arg->bit_trace_len_++] = data_bit ? '1' : '0';
160+
if (arg->bit_trace_len_ >= BIT_TRACE_BUFFER_BITS) {
161+
memcpy(arg->bit_trace_buffer2_, arg->bit_trace_buffer_, BIT_TRACE_BUFFER_BITS);
162+
arg->bit_trace_buffer2_[BIT_TRACE_BUFFER_BITS] = '\0';
163+
arg->bit_trace_len_ = 0;
164+
arg->bit_trace_ready_ = true;
165+
}
166+
}
167+
143168
if (arg->inside_) {
144169
uint8_t idx = arg->num_bits_ / 8;
145170
arg->buffer[idx] = (arg->buffer[idx] >> 1) | ((data_bit ? 1 : 0) << 7);
@@ -251,6 +276,16 @@ void CrowAlarmPanel::loop() {
251276
this->store_.ack_pending_ = false;
252277
}
253278

279+
if (this->store_.bit_trace_ready_) {
280+
char local_bits[CrowAlarmPanelStore::BIT_TRACE_BUFFER_BITS + 1];
281+
{
282+
InterruptLock lock;
283+
memcpy(local_bits, this->store_.bit_trace_buffer2_, sizeof(local_bits));
284+
this->store_.bit_trace_ready_ = false;
285+
}
286+
ESP_LOGI(TAG, "Raw bit trace: %s", local_bits);
287+
}
288+
254289
if (this->store_.data_length) {
255290
if (this->store_.data_length < 2) {
256291
ESP_LOGW(TAG, "Discarding short frame (%d bytes)", this->store_.data_length);
@@ -493,19 +528,44 @@ void CrowAlarmPanel::loop() {
493528
CONTROLLER_LABEL, data[3], type, format_hex_pretty(data).c_str());
494529
break;
495530
}
496-
if (data[4] == 0 || data[4] > 31) {
497-
ESP_LOGW(TAG, "[%-*s] Current time has invalid day-of-month value %u [%02x.%s]", this->keypad_label_width_,
498-
CONTROLLER_LABEL, data[4], type, format_hex_pretty(data).c_str());
499-
break;
500-
}
501-
if (data[5] == 0 || data[5] > 12) {
502-
ESP_LOGW(TAG, "[%-*s] Current time has invalid month value %u [%02x.%s]", this->keypad_label_width_,
503-
CONTROLLER_LABEL, data[5], type, format_hex_pretty(data).c_str());
504-
break;
531+
uint8_t day = data[4];
532+
uint8_t month = data[5];
533+
uint8_t year = data[6];
534+
if (day == 0 || day > 31 || month == 0 || month > 12) {
535+
// The documented CURRENT_TIME bit-corruption glitch (protocol_investigations.md) shifts
536+
// day/month/year one bit left together (a single spurious 0 bit inserted right after the
537+
// seconds byte) — i.e. each is exactly double its true value. Halving all three and
538+
// cross-checking the recovered date's weekday against the untouched day_of_week field
539+
// (data[0], from earlier in the frame, before the glitch's insertion point) makes a false
540+
// recovery astronomically unlikely, addressing the coincidental-valid-range risk noted in
541+
// that doc. Validated against real HA log timestamps in the 2026-08-05 traces: the
542+
// recovered date matched the true date/time exactly in every sample checked.
543+
bool recovered = false;
544+
if ((data[4] % 2) == 0 && (data[5] % 2) == 0 && (data[6] % 2) == 0) {
545+
uint8_t rec_day = data[4] / 2;
546+
uint8_t rec_month = data[5] / 2;
547+
uint8_t rec_year = data[6] / 2;
548+
if (rec_day >= 1 && rec_day <= 31 && rec_month >= 1 && rec_month <= 12 &&
549+
day_of_week_from_date(2000 + rec_year, rec_month, rec_day) == data[0]) {
550+
ESP_LOGI(TAG,
551+
"[%-*s] Current time: recovered doubled-bit glitch, using 20%02u-%02u-%02u [%02x.%s]",
552+
this->keypad_label_width_, CONTROLLER_LABEL, rec_year, rec_month, rec_day, type,
553+
format_hex_pretty(data).c_str());
554+
day = rec_day;
555+
month = rec_month;
556+
year = rec_year;
557+
recovered = true;
558+
}
559+
}
560+
if (!recovered) {
561+
ESP_LOGW(TAG, "[%-*s] Current time has invalid day/month value %u/%u [%02x.%s]",
562+
this->keypad_label_width_, CONTROLLER_LABEL, data[4], data[5], type,
563+
format_hex_pretty(data).c_str());
564+
break;
565+
}
505566
}
506567
ESP_LOGD(TAG, "[%-*s] Controller time update: %s 20%02d-%02d-%02d %02d:%02d:%02d",
507-
this->keypad_label_width_, CONTROLLER_LABEL, day_of_week, data[6], data[5], data[4], hour, minute,
508-
data[3]);
568+
this->keypad_label_width_, CONTROLLER_LABEL, day_of_week, year, month, day, hour, minute, data[3]);
509569
break;
510570
}
511571
case RESPONSE_TIME:
@@ -832,23 +892,57 @@ void CrowAlarmPanel::loop() {
832892
}
833893
}
834894

835-
// Arm/disarm watchdog: abort if any non-IDLE state exceeds 1s without progress.
895+
// Arm/disarm watchdog: abort if any non-IDLE state exceeds 1s without progress. See
896+
// ARM_DISARM_MAX_RETRIES in crow_alarm_panel.h for why retrying here (unlike output-select/
897+
// zone-bypass above) is safe: a timeout reliably means the panel's state did not change.
836898
if (this->arm_disarm_state_ != ArmDisarmState::IDLE) {
837899
const uint32_t now_ms = millis();
838900
if (now_ms - this->arm_disarm_state_enter_ms_ > 1000) {
839-
ESP_LOGW(TAG, "Arm/disarm: timeout in state %u, aborting",
840-
static_cast<uint8_t>(this->arm_disarm_state_));
841-
this->arm_disarm_state_ = ArmDisarmState::IDLE;
842-
this->arm_disarm_code_digits_.clear();
843-
this->arm_disarm_code_idx_ = 0;
844-
// CrowAlarmControlPanel::control() optimistically publishes ACP_STATE_ARMING/DISARMING
845-
// before this sequence resolves. On abort no ARMED_STATE broadcast is coming to correct
846-
// that, so without this the entity would be stuck in the transitional state forever,
847-
// rejecting both future arm and disarm calls (ESPHome's alarm_control_panel validate_()
848-
// requires DISARMED to arm and an armed/pending state to disarm). Restore it to the last
849-
// state the controller itself actually confirmed.
850-
if (this->alarm_control_panel_ != nullptr) {
851-
this->alarm_control_panel_->publish_state(this->last_confirmed_acp_state_);
901+
if (this->arm_disarm_retry_count_ < ARM_DISARM_MAX_RETRIES) {
902+
this->arm_disarm_retry_count_++;
903+
ESP_LOGW(TAG, "Arm/disarm: timeout in state %u, retrying (%u/%u)",
904+
static_cast<uint8_t>(this->arm_disarm_state_), this->arm_disarm_retry_count_,
905+
ARM_DISARM_MAX_RETRIES);
906+
// Refresh the watchdog timer BEFORE any keypress() below — keypress()->send_packet()
907+
// can delay()/yield() and re-enter this loop(), and with the old timestamp still in
908+
// place the watchdog would see itself as still timed out, firing again and sending an
909+
// extra keypress (same race the output-select retry above avoids the same way).
910+
this->arm_disarm_state_enter_ms_ = millis();
911+
switch (this->arm_disarm_state_) {
912+
case ArmDisarmState::ARM_AWAY_PENDING:
913+
this->keypress(KEY_ARM);
914+
break;
915+
case ArmDisarmState::ARM_STAY_PENDING:
916+
this->keypress(KEY_STAY);
917+
break;
918+
case ArmDisarmState::CODE_DIGIT_PENDING:
919+
case ArmDisarmState::CODE_ENTER_PENDING:
920+
// Restart the whole code+terminal-key sequence from scratch, exactly like a fresh
921+
// manual retry (proven reliable across every session in arm_disarm_state_machine.md).
922+
this->arm_disarm_code_idx_ = 1;
923+
this->arm_disarm_state_ = ArmDisarmState::CODE_DIGIT_PENDING;
924+
this->arm_disarm_digit_ack_byte_set_ = false;
925+
this->keypress(this->arm_disarm_code_digits_[0]);
926+
break;
927+
default:
928+
break;
929+
}
930+
} else {
931+
ESP_LOGW(TAG, "Arm/disarm: timeout in state %u, aborting after %u retries",
932+
static_cast<uint8_t>(this->arm_disarm_state_), this->arm_disarm_retry_count_);
933+
this->arm_disarm_state_ = ArmDisarmState::IDLE;
934+
this->arm_disarm_code_digits_.clear();
935+
this->arm_disarm_code_idx_ = 0;
936+
this->arm_disarm_retry_count_ = 0;
937+
// CrowAlarmControlPanel::control() optimistically publishes ACP_STATE_ARMING/DISARMING
938+
// before this sequence resolves. On abort no ARMED_STATE broadcast is coming to correct
939+
// that, so without this the entity would be stuck in the transitional state forever,
940+
// rejecting both future arm and disarm calls (ESPHome's alarm_control_panel validate_()
941+
// requires DISARMED to arm and an armed/pending state to disarm). Restore it to the last
942+
// state the controller itself actually confirmed.
943+
if (this->alarm_control_panel_ != nullptr) {
944+
this->alarm_control_panel_->publish_state(this->last_confirmed_acp_state_);
945+
}
852946
}
853947
}
854948
}
@@ -892,6 +986,7 @@ void CrowAlarmPanel::start_code_sequence_(const std::string &code, uint8_t termi
892986
this->arm_disarm_code_idx_ = 1;
893987
this->arm_disarm_state_ = ArmDisarmState::CODE_DIGIT_PENDING;
894988
this->arm_disarm_state_enter_ms_ = millis();
989+
this->arm_disarm_retry_count_ = 0;
895990
this->arm_disarm_digit_ack_byte_set_ = false;
896991
this->keypress(this->arm_disarm_code_digits_[0]);
897992
}
@@ -912,6 +1007,7 @@ void CrowAlarmPanel::arm_away(const std::string &code) {
9121007
ESP_LOGI(TAG, "Arm away");
9131008
this->arm_disarm_state_ = ArmDisarmState::ARM_AWAY_PENDING;
9141009
this->arm_disarm_state_enter_ms_ = millis();
1010+
this->arm_disarm_retry_count_ = 0;
9151011
this->keypress(KEY_ARM);
9161012
}
9171013
}
@@ -932,6 +1028,7 @@ void CrowAlarmPanel::arm_stay(const std::string &code) {
9321028
ESP_LOGI(TAG, "Arm stay");
9331029
this->arm_disarm_state_ = ArmDisarmState::ARM_STAY_PENDING;
9341030
this->arm_disarm_state_enter_ms_ = millis();
1031+
this->arm_disarm_retry_count_ = 0;
9351032
this->keypress(KEY_STAY);
9361033
}
9371034
}

components/crow_alarm_panel/crow_alarm_panel.h

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,20 @@ class CrowAlarmPanelStore {
165165
// The controller then keeps the keypad stuck in "output-select mode", rejecting all
166166
// subsequent KEY_OUTPUT attempts with KEYPAD_COMMAND [07].
167167
static const uint32_t OUTPUT_SELECT_ENTER_DELAY_MS = 60;
168+
169+
// Raw bit trace (diagnostic): batches every clock-sampled DAT bit that reaches the
170+
// frame boundary-search logic (i.e. after glitch filtering, same bits that feed
171+
// boundary_buffer_) into a fixed-size string and hands it to loop() once full.
172+
// Independent of frame decoding, so mis-alignment/framing issues can be diagnosed
173+
// by hand from the literal bitstream instead of the already-decoded bytes.
174+
static const uint16_t BIT_TRACE_BUFFER_BITS = 128;
175+
// Read in the ISR, written from the main loop via set_raw_bit_trace_enabled() — volatile like
176+
// ack_pending_/is_transmitting_ above, for the same cross-core visibility reason.
177+
volatile bool bit_trace_enabled_{false};
178+
char bit_trace_buffer_[BIT_TRACE_BUFFER_BITS + 1]{};
179+
char bit_trace_buffer2_[BIT_TRACE_BUFFER_BITS + 1]{};
180+
uint16_t bit_trace_len_{0};
181+
volatile bool bit_trace_ready_{false};
168182
};
169183

170184
struct CrowAlarmPanelZone {
@@ -252,6 +266,20 @@ class CrowAlarmPanel : public Component {
252266
// be toggled at runtime without recompiling with a higher logger level.
253267
void set_raw_frame_logging_enabled(bool enabled) { this->raw_frame_logging_enabled_ = enabled; }
254268

269+
// Enables the ISR-side raw bit trace (see CrowAlarmPanelStore::bit_trace_enabled_). Disables
270+
// first, then resets the in-progress buffer position and any pending-but-unconsumed batch
271+
// before (re-)enabling, so a toggle never mixes bits captured before/after it into one trace
272+
// chunk, and never emits a stale ready buffer left over from before the toggle.
273+
void set_raw_bit_trace_enabled(bool enabled) {
274+
this->store_.bit_trace_enabled_ = false;
275+
{
276+
InterruptLock lock;
277+
this->store_.bit_trace_len_ = 0;
278+
this->store_.bit_trace_ready_ = false;
279+
}
280+
this->store_.bit_trace_enabled_ = enabled;
281+
}
282+
255283
protected:
256284
CrowAlarmPanelKeypad find_keypad_(uint8_t address);
257285
bool is_bus_idle_();
@@ -290,6 +318,13 @@ class CrowAlarmPanel : public Component {
290318
std::vector<uint8_t> arm_disarm_code_digits_; // code digits consumed one per KEYPAD_COMMAND
291319
uint8_t arm_disarm_code_idx_{0};
292320
uint8_t arm_disarm_terminal_key_{KEY_ENTER}; // KEY_ENTER (disarm), KEY_ARM or KEY_STAY (arm-with-code)
321+
// A watchdog timeout here reliably means the panel's state did not change (see
322+
// docs/arm_disarm_state_machine.md — multiple sessions with an independent monitor capture
323+
// confirm no ARMED_STATE broadcast occurred around the timeout), so unlike output-select/
324+
// zone-bypass a blind retry can't undo a change that already landed. 5 covers the worst
325+
// consecutive-failure streak observed so far (logs-24, logs-42: 5 failures before success).
326+
static const uint8_t ARM_DISARM_MAX_RETRIES = 5;
327+
uint8_t arm_disarm_retry_count_{0};
293328
// "Digit accepted" display_code (KEYPAD_COMMAND byte[1]) learned from the first digit's
294329
// response each sequence. Physical keypad types disagree on this value (0x01 is common,
295330
// but address 0x05 has been observed sending 0x07 for the same "more digits expected"

0 commit comments

Comments
 (0)