| .. | .. |
|---|
| 20 | 20 | import 'wol_service.dart'; |
|---|
| 21 | 21 | |
|---|
| 22 | 22 | /// 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 } |
|---|
| 29 | 24 | |
|---|
| 30 | 25 | // Debug log — writes to file only in debug builds, always prints via debugPrint. |
|---|
| 31 | 26 | // Also adds entries to TraceService so they appear in the trace log viewer. |
|---|
| .. | .. |
|---|
| 77 | 72 | |
|---|
| 78 | 73 | // Callbacks |
|---|
| 79 | 74 | 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..." |
|---|
| 81 | 77 | String? connectedHost; // The host we're currently connected to |
|---|
| 82 | 78 | String? connectedVia; // "Local", "VPN", "Remote", "Bonjour", "Scan" |
|---|
| 83 | 79 | void Function(Map<String, dynamic> message)? onMessage; |
|---|
| 80 | + |
|---|
| 84 | 81 | /// Called when the server sends a debug_state_request on pailot/control/out. |
|---|
| 85 | 82 | /// The handler should read current session state and call [publishDebugStateResponse]. |
|---|
| 86 | 83 | void Function(String requestId)? onDebugStateRequest; |
|---|
| .. | .. |
|---|
| 112 | 109 | } |
|---|
| 113 | 110 | _clientId = id; |
|---|
| 114 | 111 | 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 | + } |
|---|
| 115 | 135 | } |
|---|
| 116 | 136 | |
|---|
| 117 | 137 | /// Force reconnect — disconnect and reconnect to last known host. |
|---|
| .. | .. |
|---|
| 168 | 188 | |
|---|
| 169 | 189 | final clientId = await _getClientId(); |
|---|
| 170 | 190 | |
|---|
| 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 | + |
|---|
| 171 | 214 | // Phase 1: Race configured hosts (fast — just TLS probe, ~1s each) |
|---|
| 172 | 215 | 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!); |
|---|
| 176 | 222 | 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 | + ); |
|---|
| 178 | 226 | onStatusDetail?.call('Connecting...'); |
|---|
| 179 | 227 | |
|---|
| 180 | 228 | // Race: first probe to succeed wins, don't wait for others |
|---|
| .. | .. |
|---|
| 250 | 298 | /// Discover AIBroker on local network via Bonjour/mDNS. |
|---|
| 251 | 299 | /// Falls back to subnet scan if Bonjour fails (iOS blocks mDNS on Personal Hotspot). |
|---|
| 252 | 300 | /// 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 { |
|---|
| 254 | 304 | // Try Bonjour first |
|---|
| 255 | 305 | try { |
|---|
| 256 | 306 | final discovery = BonsoirDiscovery(type: '_mqtt._tcp'); |
|---|
| .. | .. |
|---|
| 263 | 313 | switch (event) { |
|---|
| 264 | 314 | case BonsoirDiscoveryServiceResolvedEvent(): |
|---|
| 265 | 315 | 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 | + ); |
|---|
| 267 | 319 | if (ip != null && ip.isNotEmpty && !completer.isCompleted) { |
|---|
| 268 | 320 | completer.complete(ip); |
|---|
| 269 | 321 | } |
|---|
| .. | .. |
|---|
| 297 | 349 | Future<String?> _scanSubnetForMqtt() async { |
|---|
| 298 | 350 | try { |
|---|
| 299 | 351 | // 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 | + ); |
|---|
| 301 | 355 | for (final iface in interfaces) { |
|---|
| 302 | 356 | for (final addr in iface.addresses) { |
|---|
| 303 | 357 | final parts = addr.address.split('.'); |
|---|
| .. | .. |
|---|
| 319 | 373 | futures.add(_probeHost(probe, config.port)); |
|---|
| 320 | 374 | } |
|---|
| 321 | 375 | 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 | + ); |
|---|
| 323 | 380 | if (found != null) { |
|---|
| 324 | 381 | _mqttLog('MQTT: subnet scan found broker at $found'); |
|---|
| 325 | 382 | return found; |
|---|
| .. | .. |
|---|
| 342 | 399 | final prefs = await SharedPreferences.getInstance(); |
|---|
| 343 | 400 | _trustedFingerprint = prefs.getString('trustedCertFingerprint'); |
|---|
| 344 | 401 | 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 | + ); |
|---|
| 346 | 405 | } |
|---|
| 347 | 406 | } |
|---|
| 348 | 407 | |
|---|
| .. | .. |
|---|
| 365 | 424 | SharedPreferences.getInstance().then((prefs) { |
|---|
| 366 | 425 | prefs.setString('trustedCertFingerprint', fingerprint); |
|---|
| 367 | 426 | }); |
|---|
| 368 | | - _mqttLog('TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...'); |
|---|
| 427 | + _mqttLog( |
|---|
| 428 | + 'TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...', |
|---|
| 429 | + ); |
|---|
| 369 | 430 | return true; |
|---|
| 370 | 431 | } |
|---|
| 371 | 432 | |
|---|
| .. | .. |
|---|
| 374 | 435 | } |
|---|
| 375 | 436 | |
|---|
| 376 | 437 | // 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 | + ); |
|---|
| 378 | 441 | // Reject the connection. User must reset trust in settings. |
|---|
| 379 | 442 | return false; |
|---|
| 380 | 443 | } |
|---|
| .. | .. |
|---|
| 404 | 467 | } |
|---|
| 405 | 468 | } |
|---|
| 406 | 469 | |
|---|
| 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 { |
|---|
| 408 | 475 | try { |
|---|
| 409 | 476 | 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 |
|---|
| 412 | 481 | client.connectTimeoutPeriod = timeout; |
|---|
| 413 | 482 | // client.maxConnectionAttempts is final — can't set it |
|---|
| 414 | 483 | client.logging(on: false); |
|---|
| .. | .. |
|---|
| 440 | 509 | // Set _client BEFORE connect() so _onConnected can subscribe |
|---|
| 441 | 510 | _client = client; |
|---|
| 442 | 511 | |
|---|
| 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 | + ); |
|---|
| 444 | 515 | final result = await client.connect().timeout( |
|---|
| 445 | 516 | Duration(milliseconds: timeout + 1000), |
|---|
| 446 | 517 | onTimeout: () { |
|---|
| .. | .. |
|---|
| 453 | 524 | // Don't use autoReconnect — it has no backoff and causes tight reconnect loops. |
|---|
| 454 | 525 | // We handle reconnection manually in _onDisconnected with exponential backoff. |
|---|
| 455 | 526 | _reconnectAttempt = 0; |
|---|
| 527 | + connectedHost = host; |
|---|
| 528 | + _saveLastGoodHost(host); // remember for a fast reconnect after restart |
|---|
| 456 | 529 | return true; |
|---|
| 457 | 530 | } |
|---|
| 458 | 531 | _client = null; |
|---|
| .. | .. |
|---|
| 471 | 544 | // STABLE for 10+ seconds. This prevents flap loops where each brief connect |
|---|
| 472 | 545 | // resets the backoff and we hammer the server every 5s forever. |
|---|
| 473 | 546 | _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 | + ); |
|---|
| 480 | 558 | _setStatus(ConnectionStatus.connected); |
|---|
| 481 | 559 | _subscribe(); |
|---|
| 482 | 560 | _listenMessages(); |
|---|
| .. | .. |
|---|
| 501 | 579 | void _scheduleReconnect() { |
|---|
| 502 | 580 | _reconnectTimer?.cancel(); |
|---|
| 503 | 581 | // 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 | + ); |
|---|
| 505 | 586 | _reconnectAttempt++; |
|---|
| 506 | | - _mqttLog('MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)'); |
|---|
| 587 | + _mqttLog( |
|---|
| 588 | + 'MQTT: scheduling reconnect in ${delayMs}ms (attempt $_reconnectAttempt)', |
|---|
| 589 | + ); |
|---|
| 507 | 590 | _reconnectTimer = Timer(Duration(milliseconds: delayMs), () async { |
|---|
| 508 | 591 | if (_intentionalClose || _status == ConnectionStatus.connected) return; |
|---|
| 509 | 592 | final host = connectedHost ?? _lastDiscoveredHost; |
|---|
| .. | .. |
|---|
| 675 | 758 | /// Publish raw bytes to a topic. Used by TraceService for log streaming. |
|---|
| 676 | 759 | void publishRaw(String topic, Uint8Buffer payload, MqttQos qos) { |
|---|
| 677 | 760 | 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; |
|---|
| 679 | 764 | try { |
|---|
| 680 | 765 | client.publishMessage(topic, qos, payload); |
|---|
| 681 | 766 | } catch (_) {} |
|---|
| .. | .. |
|---|
| 684 | 769 | /// Publish a JSON payload to an MQTT topic. |
|---|
| 685 | 770 | void _publish(String topic, Map<String, dynamic> payload, MqttQos qos) { |
|---|
| 686 | 771 | final client = _client; |
|---|
| 687 | | - if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) { |
|---|
| 772 | + if (client == null || |
|---|
| 773 | + client.connectionStatus?.state != MqttConnectionState.connected) { |
|---|
| 688 | 774 | onError?.call('Not connected'); |
|---|
| 689 | 775 | return; |
|---|
| 690 | 776 | } |
|---|
| .. | .. |
|---|
| 718 | 804 | if (platform != null) 'platform': platform, |
|---|
| 719 | 805 | 'ts': DateTime.now().millisecondsSinceEpoch, |
|---|
| 720 | 806 | }, 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 | + ); |
|---|
| 722 | 810 | } |
|---|
| 723 | 811 | |
|---|
| 724 | 812 | /// Send a message — routes to the appropriate MQTT topic based on content. |
|---|
| .. | .. |
|---|
| 774 | 862 | 'type': 'bundle', |
|---|
| 775 | 863 | 'sessionId': sessionId, |
|---|
| 776 | 864 | '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'], |
|---|
| 779 | 869 | 'attachments': message['attachments'] ?? [], |
|---|
| 780 | 870 | 'ts': _now(), |
|---|
| 781 | 871 | }, MqttQos.atLeastOnce); |
|---|
| .. | .. |
|---|
| 830 | 920 | /// no MQTT clients are connected (app is backgrounded or offline). |
|---|
| 831 | 921 | void sendDeviceToken(String token) { |
|---|
| 832 | 922 | final client = _client; |
|---|
| 833 | | - if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) { |
|---|
| 923 | + if (client == null || |
|---|
| 924 | + client.connectionStatus?.state != MqttConnectionState.connected) { |
|---|
| 834 | 925 | return; |
|---|
| 835 | 926 | } |
|---|
| 836 | 927 | try { |
|---|
| 837 | 928 | 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 | + ); |
|---|
| 840 | 937 | _mqttLog('Push: device token published to pailot/device/token'); |
|---|
| 841 | 938 | } catch (e) { |
|---|
| 842 | 939 | _mqttLog('Push: failed to publish device token: $e'); |
|---|