- Clear roaming_scan_end_ on successful reconnect to prevent grace
period from incorrectly applying to a second disconnect
- Log target BSSID in "Roam successful (via retry)" message
- Move bssid_t roaming_target_bssid_ to 1-byte section to avoid
2 bytes struct padding before ap_timeout_
When a roam connection fails on the first attempt, retry_connect()
transitions to RECONNECTING. The subsequent scan-based retry may
connect to the same better AP, but the success handler treated all
RECONNECTING connections as failed roams and preserved the attempts
counter. This meant a successful roam (that needed two tries) would
incorrectly consume an attempt.
Fix by storing the roam target BSSID and checking it on reconnection.
If the device connected to the intended target, reset the counter
(successful roam). If it fell back to a different AP, preserve the
counter (failed roam, prevent ping-pong).
Use 100-300ms dwell times for roaming scans instead of 400-500ms,
matching the ESP32 IDF scan times. This reduces total off-channel
time from ~6.5s to ~3.9s (13 channels), which reduces the chance
of triggering Beacon Timeout disconnects on aggressive APs.
The longer dwell times are preserved for initial connection scans
where a thorough survey is needed.
On ESP8266, the roaming scan goes off-channel for ~3 seconds. Some
APs aggressively disconnect the client (Beacon Timeout) 8-20 seconds
after the scan finishes. By that time, process_roaming_scan_() has
already set roaming_state_ back to IDLE. When retry_connect() runs,
it sees IDLE and calls clear_roaming_state_(), resetting the attempts
counter to 0. This creates an infinite loop of "attempt 1/3" every
5 minutes.
The aggressive AP behavior cannot be fixed from the ESP side, but we
can prevent the counter from resetting so roaming eventually stops
after 3 attempts.
Fix by recording when the roaming scan completes and adding a 30s
grace period. If a disconnect occurs within that window, the state
transitions to RECONNECTING (preserving the counter) instead of
clearing it.
Closes https://github.com/esphome/esphome/issues/15124
Remove last_state_ from GPIOBinarySensorStore — it was always set to
the same value as state_ in the ISR and never written elsewhere, making
the comparison `new_state != last_state_` equivalent to
`new_state != state_`.
Move use_interrupt_ and interrupt_type_ into the store to fill the
padding freed by removing last_state_, eliminating all internal padding.
Store layout (12B, zero padding):
isr_pin_.arg_* 4B
component_* 4B
state_ 1B (volatile)
changed_ 1B (volatile)
use_interrupt_ 1B
interrupt_type_ 1B
Saves 4 bytes per GPIOBinarySensor instance (64B → 60B).
Move restore_mode_ (uint8_t) next to next_write_ and
is_transformer_active_ (both bool) so all three pack into
a single 4-byte slot. Previously restore_mode_ sat alone
at the end of the struct with 3 bytes of trailing padding.
Saves 4 bytes per LightState instance.
Save 16 bytes per GPIOSwitch instance (FixedVector<Switch*> 12B +
uint32_t 4B) by guarding interlock fields and logic behind
USE_GPIO_SWITCH_INTERLOCK, which is only defined when the interlock
config option is present.
- Test empty name returns 0
- Test deduplication returns same index
- Test overflow warns and returns 0
- Remove duplicate index==0 check in get_component_log_str() since
component_source_lookup() already handles it