| .. | .. |
|---|
| 68 | 68 | // (Per-session subscriptions removed — single pailot/out topic now) |
|---|
| 69 | 69 | static const int _maxSeenIds = 500; |
|---|
| 70 | 70 | |
|---|
| 71 | + // Reconnect backoff |
|---|
| 72 | + Timer? _reconnectTimer; |
|---|
| 73 | + Timer? _stabilityTimer; |
|---|
| 74 | + int _reconnectAttempt = 0; |
|---|
| 75 | + static const int _maxReconnectDelay = 30000; // 30s cap |
|---|
| 76 | + static const int _stabilityThresholdMs = 10000; // 10s stable = reset backoff |
|---|
| 77 | + |
|---|
| 71 | 78 | // Callbacks |
|---|
| 72 | 79 | void Function(ConnectionStatus status)? onStatusChanged; |
|---|
| 73 | 80 | void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..." |
|---|
| .. | .. |
|---|
| 120 | 127 | } |
|---|
| 121 | 128 | |
|---|
| 122 | 129 | /// Fast reconnect to a known host — skips discovery, short timeout. |
|---|
| 123 | | - Future<void> _fastReconnect(String host) async { |
|---|
| 130 | + /// Returns true if connected, false if failed. |
|---|
| 131 | + Future<bool> _fastReconnect(String host) async { |
|---|
| 124 | 132 | _mqttLog('MQTT: fast reconnect to $host'); |
|---|
| 125 | 133 | final clientId = await _getClientId(); |
|---|
| 126 | 134 | if (await _tryConnect(host, clientId, timeout: 2000)) { |
|---|
| 127 | 135 | connectedHost = host; |
|---|
| 128 | | - return; |
|---|
| 136 | + return true; |
|---|
| 129 | 137 | } |
|---|
| 130 | | - // Fast path failed — fall back to full connect |
|---|
| 131 | | - _mqttLog('MQTT: fast reconnect failed, full connect...'); |
|---|
| 132 | | - connect(); |
|---|
| 138 | + _mqttLog('MQTT: fast reconnect failed'); |
|---|
| 139 | + return false; |
|---|
| 133 | 140 | } |
|---|
| 134 | 141 | |
|---|
| 135 | 142 | /// Connect to the MQTT broker. |
|---|
| .. | .. |
|---|
| 440 | 447 | ); |
|---|
| 441 | 448 | _mqttLog('MQTT: connect result=${result?.state}'); |
|---|
| 442 | 449 | if (result?.state == MqttConnectionState.connected) { |
|---|
| 443 | | - client.autoReconnect = true; |
|---|
| 450 | + // Don't use autoReconnect — it has no backoff and causes tight reconnect loops. |
|---|
| 451 | + // We handle reconnection manually in _onDisconnected with exponential backoff. |
|---|
| 452 | + _reconnectAttempt = 0; |
|---|
| 444 | 453 | return true; |
|---|
| 445 | 454 | } |
|---|
| 446 | 455 | _client = null; |
|---|
| .. | .. |
|---|
| 454 | 463 | |
|---|
| 455 | 464 | void _onConnected() { |
|---|
| 456 | 465 | _mqttLog('MQTT: _onConnected fired'); |
|---|
| 466 | + _reconnectTimer?.cancel(); |
|---|
| 467 | + // Don't reset _reconnectAttempt here — only after the connection has been |
|---|
| 468 | + // STABLE for 10+ seconds. This prevents flap loops where each brief connect |
|---|
| 469 | + // resets the backoff and we hammer the server every 5s forever. |
|---|
| 470 | + _stabilityTimer?.cancel(); |
|---|
| 471 | + _stabilityTimer = Timer(const Duration(milliseconds: _stabilityThresholdMs), () { |
|---|
| 472 | + if (_status == ConnectionStatus.connected) { |
|---|
| 473 | + _mqttLog('MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff'); |
|---|
| 474 | + _reconnectAttempt = 0; |
|---|
| 475 | + } |
|---|
| 476 | + }); |
|---|
| 457 | 477 | _setStatus(ConnectionStatus.connected); |
|---|
| 458 | 478 | _subscribe(); |
|---|
| 459 | 479 | _listenMessages(); |
|---|
| .. | .. |
|---|
| 461 | 481 | } |
|---|
| 462 | 482 | |
|---|
| 463 | 483 | void _onDisconnected() { |
|---|
| 484 | + _stabilityTimer?.cancel(); |
|---|
| 464 | 485 | _updatesSub?.cancel(); |
|---|
| 465 | 486 | _updatesSub = null; |
|---|
| 466 | 487 | |
|---|
| .. | .. |
|---|
| 470 | 491 | } else { |
|---|
| 471 | 492 | _setStatus(ConnectionStatus.reconnecting); |
|---|
| 472 | 493 | onReconnecting?.call(); |
|---|
| 494 | + _scheduleReconnect(); |
|---|
| 473 | 495 | } |
|---|
| 474 | 496 | } |
|---|
| 475 | 497 | |
|---|
| 498 | + void _scheduleReconnect() { |
|---|
| 499 | + _reconnectTimer?.cancel(); |
|---|
| 500 | + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s cap |
|---|
| 501 | + final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(1000, _maxReconnectDelay); |
|---|
| 502 | + _reconnectAttempt++; |
|---|
| 503 | + _mqttLog('MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)'); |
|---|
| 504 | + _reconnectTimer = Timer(Duration(milliseconds: delayMs), () async { |
|---|
| 505 | + if (_intentionalClose || _status == ConnectionStatus.connected) return; |
|---|
| 506 | + final host = connectedHost ?? _lastDiscoveredHost; |
|---|
| 507 | + if (host != null) { |
|---|
| 508 | + _mqttLog('MQTT: reconnect attempt $_reconnectAttempt to $host'); |
|---|
| 509 | + final ok = await _fastReconnect(host); |
|---|
| 510 | + if (!ok && !_intentionalClose) { |
|---|
| 511 | + _scheduleReconnect(); // Try again with increased backoff |
|---|
| 512 | + } |
|---|
| 513 | + } else { |
|---|
| 514 | + _mqttLog('MQTT: no known host, running full connect'); |
|---|
| 515 | + await connect(); |
|---|
| 516 | + } |
|---|
| 517 | + }); |
|---|
| 518 | + } |
|---|
| 519 | + |
|---|
| 476 | 520 | void _onAutoReconnect() { |
|---|
| 521 | + // Unused — autoReconnect is disabled, but keep callback for safety |
|---|
| 477 | 522 | _setStatus(ConnectionStatus.reconnecting); |
|---|
| 478 | 523 | onReconnecting?.call(); |
|---|
| 479 | 524 | } |
|---|
| 480 | 525 | |
|---|
| 481 | 526 | void _onAutoReconnected() { |
|---|
| 527 | + // Unused — autoReconnect is disabled, but keep callback for safety |
|---|
| 528 | + _reconnectAttempt = 0; |
|---|
| 482 | 529 | _setStatus(ConnectionStatus.connected); |
|---|
| 483 | 530 | _subscribe(); |
|---|
| 484 | 531 | _listenMessages(); |
|---|
| .. | .. |
|---|
| 764 | 811 | /// Disconnect intentionally. |
|---|
| 765 | 812 | void disconnect() { |
|---|
| 766 | 813 | _intentionalClose = true; |
|---|
| 814 | + _reconnectTimer?.cancel(); |
|---|
| 815 | + _reconnectTimer = null; |
|---|
| 816 | + _stabilityTimer?.cancel(); |
|---|
| 817 | + _stabilityTimer = null; |
|---|
| 818 | + _reconnectAttempt = 0; |
|---|
| 767 | 819 | _updatesSub?.cancel(); |
|---|
| 768 | 820 | _updatesSub = null; |
|---|
| 769 | 821 | _connectivitySub?.cancel(); |
|---|
| .. | .. |
|---|
| 799 | 851 | case AppLifecycleState.resumed: |
|---|
| 800 | 852 | if (_intentionalClose) break; |
|---|
| 801 | 853 | _mqttLog('MQTT: app resumed'); |
|---|
| 802 | | - // Let autoReconnect handle dead connections (keepalive timeout). |
|---|
| 803 | | - // Just trigger catch_up to fetch missed messages and rebuild UI. |
|---|
| 854 | + // If disconnected, trigger immediate reconnect (reset backoff). |
|---|
| 855 | + if (_status != ConnectionStatus.connected) { |
|---|
| 856 | + _reconnectAttempt = 0; |
|---|
| 857 | + _scheduleReconnect(); |
|---|
| 858 | + } |
|---|
| 859 | + // Trigger catch_up to fetch missed messages and rebuild UI. |
|---|
| 804 | 860 | onResume?.call(); |
|---|
| 805 | 861 | case AppLifecycleState.paused: |
|---|
| 806 | 862 | break; |
|---|