@@ -1486,15 +1486,19 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
14861486 bool slot_uses_jwt = (slot.preset && slot.preset ->auth_type == MQTT_AUTH_JWT ) ||
14871487 (!slot.preset && slot.audience [0 ] != ' \0 ' );
14881488 if (slot_uses_jwt) {
1489+ // Renew (and below, reconnect) this many seconds before the token's exp
1490+ // claim. Scaled to the slot's token lifetime — see tokenRenewalBufferSecs
1491+ // for why a flat 60 s lost the renewal race against brokers that enforce
1492+ // exp on live sessions (waev's 55-minute tokens).
1493+ const unsigned long renewal_buffer = tokenRenewalBufferSecs (slotTokenLifetime (index));
14891494 bool token_needs_renewal = false ;
14901495 if (!time_synced) {
14911496 token_needs_renewal = (slot.token_expires_at == 0 );
14921497 } else {
1493- const unsigned long RENEWAL_BUFFER = 60 ;
14941498 token_needs_renewal = (slot.token_expires_at == 0 ) ||
14951499 !(slot.token_expires_at >= 1000000000 ) ||
14961500 (current_time >= slot.token_expires_at ) ||
1497- (current_time >= (slot.token_expires_at - RENEWAL_BUFFER ));
1501+ (current_time >= (slot.token_expires_at - renewal_buffer ));
14981502 }
14991503
15001504 // Throttle renewal attempts to once per minute
@@ -1509,12 +1513,16 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
15091513 if (createSlotAuthToken (index)) {
15101514 MQTT_DEBUG_PRINTLN (" MQTT%d token renewed" , index + 1 );
15111515
1512- const unsigned long DISCONNECT_THRESHOLD = 60 ;
1516+ // Bounce the connection while WE control the timing whenever the old
1517+ // token is inside the renewal buffer — waiting for the broker to
1518+ // enforce exp mid-session means a FIN plus a trip through the backoff
1519+ // ladder instead of one clean reconnect. Same buffer as the renewal
1520+ // trigger above, so a renewal implies a proactive reconnect.
15131521 bool old_token_expired_or_imminent = !time_synced ||
15141522 (old_token_expires_at == 0 ) ||
15151523 (current_time >= old_token_expires_at) ||
15161524 (time_synced && old_token_expires_at >= 1000000000 &&
1517- current_time >= (old_token_expires_at - DISCONNECT_THRESHOLD ));
1525+ current_time >= (old_token_expires_at - renewal_buffer ));
15181526
15191527 if (old_token_expired_or_imminent || !slot.client ->connected ()) {
15201528 // Disconnect + reconnect with fresh credentials, reusing existing client
@@ -1632,17 +1640,52 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
16321640 }
16331641}
16341642
1643+ // Effective JWT lifetime for a slot: the preset's token_lifetime (or the 24 h
1644+ // default for custom/audience slots), minus the per-slot expiry stagger that
1645+ // keeps multiple JWT slots from renewing/reconnecting simultaneously. This is
1646+ // the exact value createSlotAuthToken() puts in the token's exp claim, so the
1647+ // renewal scheduling in maintainSlotConnection() can be derived from it.
1648+ unsigned long MQTTBridge::slotTokenLifetime (int index) const {
1649+ const MQTTSlot& slot = _slots[index];
1650+ unsigned long base_lifetime = 86400 ; // default 24h
1651+ if (slot.preset && slot.preset ->auth_type == MQTT_AUTH_JWT && slot.preset ->token_lifetime > 0 ) {
1652+ base_lifetime = slot.preset ->token_lifetime ;
1653+ }
1654+ // Stagger token expiry per slot to avoid simultaneous renewal/reconnect.
1655+ // Use 5% of lifetime per slot, capped at 300s, so short-lived tokens aren't over-reduced.
1656+ unsigned long stagger = index * min ((unsigned long )300 , base_lifetime / 20 );
1657+ return base_lifetime - stagger;
1658+ }
1659+
1660+ // How early (seconds before the token's exp claim) to renew the token AND
1661+ // proactively bounce the connection with fresh credentials. exp and the
1662+ // renewal schedule are locked together (both derive from slotTokenLifetime),
1663+ // so this buffer is the ONLY margin between "device re-authenticates" and
1664+ // "broker enforces exp and FIN-closes the session mid-stream" — shortening a
1665+ // preset's token_lifetime moves both times together and cannot widen it.
1666+ // The old flat 60 s lost that race whenever the device clock ran slow, or a
1667+ // single renewal attempt failed (the 60 s renewal throttle then ate the whole
1668+ // margin) — observed on the waev preset, whose 55-minute tokens are the only
1669+ // ones short enough for brokers to enforce exp against a live session.
1670+ // lifetime/10 with a 60 s floor and 300 s cap: 24 h tokens renew 5 min early
1671+ // (unchanged in practice), waev renews ~5 min early with ~5 throttled retry
1672+ // windows, and degenerate short lifetimes still renew inside their validity.
1673+ unsigned long MQTTBridge::tokenRenewalBufferSecs (unsigned long lifetime_secs) {
1674+ unsigned long buffer = lifetime_secs / 10 ;
1675+ if (buffer < 60 ) buffer = 60 ;
1676+ if (buffer > 300 ) buffer = 300 ;
1677+ return buffer;
1678+ }
1679+
16351680bool MQTTBridge::createSlotAuthToken (int index) {
16361681 if (index < 0 || index >= RUNTIME_MQTT_SLOTS ) return false ;
16371682 MQTTSlot& slot = _slots[index];
16381683 if (!_identity) return false ;
16391684
16401685 // Determine JWT audience: preset takes priority, then custom slot audience field
16411686 const char * audience = nullptr ;
1642- unsigned long base_lifetime = 86400 ; // default 24h
16431687 if (slot.preset && slot.preset ->auth_type == MQTT_AUTH_JWT ) {
16441688 audience = slot.preset ->jwt_audience ;
1645- if (slot.preset ->token_lifetime > 0 ) base_lifetime = slot.preset ->token_lifetime ;
16461689 } else if (slot.audience [0 ] != ' \0 ' ) {
16471690 audience = slot.audience ;
16481691 }
@@ -1672,10 +1715,7 @@ bool MQTTBridge::createSlotAuthToken(int index) {
16721715 const char * email = (_obs->mqtt_email [0 ] != ' \0 ' ) ? _obs->mqtt_email : nullptr ;
16731716
16741717 unsigned long current_time = time (nullptr );
1675- // Stagger token expiry per slot to avoid simultaneous renewal/reconnect
1676- // Use 5% of lifetime per slot, capped at 300s, so short-lived tokens aren't over-reduced
1677- unsigned long stagger = index * min ((unsigned long )300 , base_lifetime / 20 );
1678- unsigned long expires_in = base_lifetime - stagger;
1718+ unsigned long expires_in = slotTokenLifetime (index); // preset/default lifetime minus per-slot stagger
16791719 bool time_synced = (current_time >= 1000000000 );
16801720
16811721 if (JWTHelper::createAuthToken (
0 commit comments