Matthias Nott
2026-07-10 1d79dcb975ae6f606a783a3d0287d8dc473e1cc8
lib/services/mqtt_service.dart
....@@ -20,12 +20,7 @@
2020 import 'wol_service.dart';
2121
2222 /// Connection status for the MQTT client.
23
-enum ConnectionStatus {
24
- disconnected,
25
- connecting,
26
- connected,
27
- reconnecting,
28
-}
23
+enum ConnectionStatus { disconnected, connecting, connected, reconnecting }
2924
3025 // Debug log — writes to file only in debug builds, always prints via debugPrint.
3126 // Also adds entries to TraceService so they appear in the trace log viewer.
....@@ -77,10 +72,12 @@
7772
7873 // Callbacks
7974 void Function(ConnectionStatus status)? onStatusChanged;
80
- void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..."
75
+ void Function(String detail)?
76
+ onStatusDetail; // "Probing local...", "Scanning network..."
8177 String? connectedHost; // The host we're currently connected to
8278 String? connectedVia; // "Local", "VPN", "Remote", "Bonjour", "Scan"
8379 void Function(Map<String, dynamic> message)? onMessage;
80
+
8481 /// Called when the server sends a debug_state_request on pailot/control/out.
8582 /// The handler should read current session state and call [publishDebugStateResponse].
8683 void Function(String requestId)? onDebugStateRequest;
....@@ -112,6 +109,29 @@
112109 }
113110 _clientId = id;
114111 return id;
112
+ }
113
+
114
+ // The host that last connected successfully, persisted across app restarts so
115
+ // a cold start (iOS killed the backgrounded app) can reconnect fast instead of
116
+ // re-running the LAN race + network scan when only the VPN host is reachable.
117
+ static const String _kLastHostKey = 'mqtt_last_good_host';
118
+
119
+ Future<void> _saveLastGoodHost(String host) async {
120
+ try {
121
+ final prefs = await SharedPreferences.getInstance();
122
+ await prefs.setString(_kLastHostKey, host);
123
+ } catch (_) {}
124
+ }
125
+
126
+ Future<String?> _loadLastGoodHost() async {
127
+ try {
128
+ final h = (await SharedPreferences.getInstance()).getString(
129
+ _kLastHostKey,
130
+ );
131
+ return (h != null && h.isNotEmpty) ? h : null;
132
+ } catch (_) {
133
+ return null;
134
+ }
115135 }
116136
117137 /// Force reconnect — disconnect and reconnect to last known host.
....@@ -168,13 +188,41 @@
168188
169189 final clientId = await _getClientId();
170190
191
+ // Phase 0: Fast path — try the last host that worked (persisted across app
192
+ // restarts) before racing all hosts or scanning. On cellular/Tailscale the
193
+ // LAN host and mDNS are unreachable, so this avoids the slow scan every cold
194
+ // start. If it's stale/unreachable it times out quickly and we fall through.
195
+ final lastGood = await _loadLastGoodHost();
196
+ if (lastGood != null && !_intentionalClose) {
197
+ onStatusDetail?.call('Reconnecting…');
198
+ _mqttLog('MQTT: fast path — trying last-good host $lastGood');
199
+ if (await _tryConnect(lastGood, clientId, timeout: 2500)) {
200
+ if (lastGood == config.localHost) {
201
+ connectedVia = 'Local';
202
+ } else if (lastGood == config.vpnHost) {
203
+ connectedVia = 'VPN';
204
+ } else if (lastGood == config.host) {
205
+ connectedVia = 'Remote';
206
+ } else {
207
+ connectedVia = 'Reconnected';
208
+ }
209
+ _mqttLog('MQTT: fast path connected via $connectedVia');
210
+ return;
211
+ }
212
+ }
213
+
171214 // Phase 1: Race configured hosts (fast — just TLS probe, ~1s each)
172215 final hosts = <String>[];
173
- if (config.localHost != null && config.localHost!.isNotEmpty) hosts.add(config.localHost!);
174
- if (_lastDiscoveredHost != null && !hosts.contains(_lastDiscoveredHost)) hosts.add(_lastDiscoveredHost!);
175
- if (config.vpnHost != null && config.vpnHost!.isNotEmpty) hosts.add(config.vpnHost!);
216
+ if (config.localHost != null && config.localHost!.isNotEmpty)
217
+ hosts.add(config.localHost!);
218
+ if (_lastDiscoveredHost != null && !hosts.contains(_lastDiscoveredHost))
219
+ hosts.add(_lastDiscoveredHost!);
220
+ if (config.vpnHost != null && config.vpnHost!.isNotEmpty)
221
+ hosts.add(config.vpnHost!);
176222 if (config.host.isNotEmpty) hosts.add(config.host);
177
- _mqttLog('MQTT: racing ${hosts.length} configured hosts: ${hosts.join(", ")}');
223
+ _mqttLog(
224
+ 'MQTT: racing ${hosts.length} configured hosts: ${hosts.join(", ")}',
225
+ );
178226 onStatusDetail?.call('Connecting...');
179227
180228 // Race: first probe to succeed wins, don't wait for others
....@@ -250,7 +298,9 @@
250298 /// Discover AIBroker on local network via Bonjour/mDNS.
251299 /// Falls back to subnet scan if Bonjour fails (iOS blocks mDNS on Personal Hotspot).
252300 /// Returns the IP address or null if not found within timeout.
253
- Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async {
301
+ Future<String?> _discoverViaMdns({
302
+ Duration timeout = const Duration(seconds: 3),
303
+ }) async {
254304 // Try Bonjour first
255305 try {
256306 final discovery = BonsoirDiscovery(type: '_mqtt._tcp');
....@@ -263,7 +313,9 @@
263313 switch (event) {
264314 case BonsoirDiscoveryServiceResolvedEvent():
265315 final ip = event.service.host;
266
- _mqttLog('MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}');
316
+ _mqttLog(
317
+ 'MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}',
318
+ );
267319 if (ip != null && ip.isNotEmpty && !completer.isCompleted) {
268320 completer.complete(ip);
269321 }
....@@ -297,7 +349,9 @@
297349 Future<String?> _scanSubnetForMqtt() async {
298350 try {
299351 // Get device's own IP to determine the subnet
300
- final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
352
+ final interfaces = await NetworkInterface.list(
353
+ type: InternetAddressType.IPv4,
354
+ );
301355 for (final iface in interfaces) {
302356 for (final addr in iface.addresses) {
303357 final parts = addr.address.split('.');
....@@ -319,7 +373,10 @@
319373 futures.add(_probeHost(probe, config.port));
320374 }
321375 final results = await Future.wait(futures);
322
- final found = results.firstWhere((r) => r != null, orElse: () => null);
376
+ final found = results.firstWhere(
377
+ (r) => r != null,
378
+ orElse: () => null,
379
+ );
323380 if (found != null) {
324381 _mqttLog('MQTT: subnet scan found broker at $found');
325382 return found;
....@@ -342,7 +399,9 @@
342399 final prefs = await SharedPreferences.getInstance();
343400 _trustedFingerprint = prefs.getString('trustedCertFingerprint');
344401 if (_trustedFingerprint != null) {
345
- _mqttLog('TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...');
402
+ _mqttLog(
403
+ 'TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...',
404
+ );
346405 }
347406 }
348407
....@@ -365,7 +424,9 @@
365424 SharedPreferences.getInstance().then((prefs) {
366425 prefs.setString('trustedCertFingerprint', fingerprint);
367426 });
368
- _mqttLog('TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...');
427
+ _mqttLog(
428
+ 'TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...',
429
+ );
369430 return true;
370431 }
371432
....@@ -374,7 +435,9 @@
374435 }
375436
376437 // Fingerprint mismatch — possible MITM or server reinstall
377
- _mqttLog('TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...');
438
+ _mqttLog(
439
+ 'TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...',
440
+ );
378441 // Reject the connection. User must reset trust in settings.
379442 return false;
380443 }
....@@ -404,11 +467,17 @@
404467 }
405468 }
406469
407
- Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
470
+ Future<bool> _tryConnect(
471
+ String host,
472
+ String clientId, {
473
+ int timeout = 5000,
474
+ }) async {
408475 try {
409476 final client = MqttServerClient.withPort(host, clientId, config.port);
410
- client.keepAlivePeriod = 120; // 2 min — iOS throttles bg network, short keepalive causes drops
411
- client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success
477
+ client.keepAlivePeriod =
478
+ 120; // 2 min — iOS throttles bg network, short keepalive causes drops
479
+ client.autoReconnect =
480
+ false; // Don't auto-reconnect during trial — enable after success
412481 client.connectTimeoutPeriod = timeout;
413482 // client.maxConnectionAttempts is final — can't set it
414483 client.logging(on: false);
....@@ -440,7 +509,9 @@
440509 // Set _client BEFORE connect() so _onConnected can subscribe
441510 _client = client;
442511
443
- _mqttLog('MQTT: connecting to $host:${config.port} as $clientId (timeout=${timeout}ms)');
512
+ _mqttLog(
513
+ 'MQTT: connecting to $host:${config.port} as $clientId (timeout=${timeout}ms)',
514
+ );
444515 final result = await client.connect().timeout(
445516 Duration(milliseconds: timeout + 1000),
446517 onTimeout: () {
....@@ -453,6 +524,8 @@
453524 // Don't use autoReconnect — it has no backoff and causes tight reconnect loops.
454525 // We handle reconnection manually in _onDisconnected with exponential backoff.
455526 _reconnectAttempt = 0;
527
+ connectedHost = host;
528
+ _saveLastGoodHost(host); // remember for a fast reconnect after restart
456529 return true;
457530 }
458531 _client = null;
....@@ -471,12 +544,17 @@
471544 // STABLE for 10+ seconds. This prevents flap loops where each brief connect
472545 // resets the backoff and we hammer the server every 5s forever.
473546 _stabilityTimer?.cancel();
474
- _stabilityTimer = Timer(const Duration(milliseconds: _stabilityThresholdMs), () {
475
- if (_status == ConnectionStatus.connected) {
476
- _mqttLog('MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff');
477
- _reconnectAttempt = 0;
478
- }
479
- });
547
+ _stabilityTimer = Timer(
548
+ const Duration(milliseconds: _stabilityThresholdMs),
549
+ () {
550
+ if (_status == ConnectionStatus.connected) {
551
+ _mqttLog(
552
+ 'MQTT: connection stable for ${_stabilityThresholdMs}ms — resetting backoff',
553
+ );
554
+ _reconnectAttempt = 0;
555
+ }
556
+ },
557
+ );
480558 _setStatus(ConnectionStatus.connected);
481559 _subscribe();
482560 _listenMessages();
....@@ -501,9 +579,14 @@
501579 void _scheduleReconnect() {
502580 _reconnectTimer?.cancel();
503581 // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s cap
504
- final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(1000, _maxReconnectDelay);
582
+ final delayMs = (1000 * (1 << _reconnectAttempt)).clamp(
583
+ 1000,
584
+ _maxReconnectDelay,
585
+ );
505586 _reconnectAttempt++;
506
- _mqttLog('MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)');
587
+ _mqttLog(
588
+ 'MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)',
589
+ );
507590 _reconnectTimer = Timer(Duration(milliseconds: delayMs), () async {
508591 if (_intentionalClose || _status == ConnectionStatus.connected) return;
509592 final host = connectedHost ?? _lastDiscoveredHost;
....@@ -675,7 +758,9 @@
675758 /// Publish raw bytes to a topic. Used by TraceService for log streaming.
676759 void publishRaw(String topic, Uint8Buffer payload, MqttQos qos) {
677760 final client = _client;
678
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) return;
761
+ if (client == null ||
762
+ client.connectionStatus?.state != MqttConnectionState.connected)
763
+ return;
679764 try {
680765 client.publishMessage(topic, qos, payload);
681766 } catch (_) {}
....@@ -684,7 +769,8 @@
684769 /// Publish a JSON payload to an MQTT topic.
685770 void _publish(String topic, Map<String, dynamic> payload, MqttQos qos) {
686771 final client = _client;
687
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
772
+ if (client == null ||
773
+ client.connectionStatus?.state != MqttConnectionState.connected) {
688774 onError?.call('Not connected');
689775 return;
690776 }
....@@ -718,7 +804,9 @@
718804 if (platform != null) 'platform': platform,
719805 'ts': DateTime.now().millisecondsSinceEpoch,
720806 }, MqttQos.atLeastOnce);
721
- _mqttLog('debug_state_response sent for requestId=$requestId sessions=${sessions.length}');
807
+ _mqttLog(
808
+ 'debug_state_response sent for requestId=$requestId sessions=${sessions.length}',
809
+ );
722810 }
723811
724812 /// Send a message — routes to the appropriate MQTT topic based on content.
....@@ -774,8 +862,10 @@
774862 'type': 'bundle',
775863 'sessionId': sessionId,
776864 'caption': message['caption'] ?? '',
777
- if (message['audioBase64'] != null) 'audioBase64': message['audioBase64'],
778
- if (message['voiceMessageId'] != null) 'voiceMessageId': message['voiceMessageId'],
865
+ if (message['audioBase64'] != null)
866
+ 'audioBase64': message['audioBase64'],
867
+ if (message['voiceMessageId'] != null)
868
+ 'voiceMessageId': message['voiceMessageId'],
779869 'attachments': message['attachments'] ?? [],
780870 'ts': _now(),
781871 }, MqttQos.atLeastOnce);
....@@ -830,13 +920,20 @@
830920 /// no MQTT clients are connected (app is backgrounded or offline).
831921 void sendDeviceToken(String token) {
832922 final client = _client;
833
- if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) {
923
+ if (client == null ||
924
+ client.connectionStatus?.state != MqttConnectionState.connected) {
834925 return;
835926 }
836927 try {
837928 final builder = MqttClientPayloadBuilder();
838
- builder.addString('{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}');
839
- client.publishMessage('pailot/device/token', MqttQos.atLeastOnce, builder.payload!);
929
+ builder.addString(
930
+ '{"token":"$token","ts":${DateTime.now().millisecondsSinceEpoch}}',
931
+ );
932
+ client.publishMessage(
933
+ 'pailot/device/token',
934
+ MqttQos.atLeastOnce,
935
+ builder.payload!,
936
+ );
840937 _mqttLog('Push: device token published to pailot/device/token');
841938 } catch (e) {
842939 _mqttLog('Push: failed to publish device token: $e');