@@ -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+
5060const 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}
0 commit comments