ios/Runner/Info.plist
.. .. @@ -67,10 +67,11 @@ 67 67 <true/> 68 68 </dict> 69 69 <key>NSLocalNetworkUsageDescription</key> 70 - <string>PAILot connects to your local AI server</string>70 + <string>PAILot needs local network access to discover and connect to AIBroker</string>71 71 <key>NSBonjourServices</key> 72 72 <array> 73 73 <string>_http._tcp</string> 74 + <string>_mqtt._tcp</string>74 75 </array> 75 76 <key>UISupportedInterfaceOrientations</key> 76 77 <array> lib/models/server_config.dart
.. .. @@ -2,6 +2,7 @@ 2 2 final String host; 3 3 final int port; 4 4 final String? localHost; 5 + final String? vpnHost;5 6 final String? macAddress; 6 7 final String? mqttToken; 7 8 .. .. @@ -9,6 +10,7 @@ 9 10 required this.host, 10 11 this.port = 8765, 11 12 this.localHost, 13 + this.vpnHost,12 14 this.macAddress, 13 15 this.mqttToken, 14 16 }); .. .. @@ -18,6 +20,7 @@ 18 20 'host': host, 19 21 'port': port, 20 22 if (localHost != null) 'localHost': localHost, 23 + if (vpnHost != null) 'vpnHost': vpnHost,21 24 if (macAddress != null) 'macAddress': macAddress, 22 25 if (mqttToken != null) 'mqttToken': mqttToken, 23 26 }; .. .. @@ -28,6 +31,7 @@ 28 31 host: json['host'] as String? ?? '', 29 32 port: json['port'] as int? ?? 8765, 30 33 localHost: json['localHost'] as String?, 34 + vpnHost: json['vpnHost'] as String?,31 35 macAddress: json['macAddress'] as String?, 32 36 mqttToken: json['mqttToken'] as String?, 33 37 ); .. .. @@ -37,6 +41,7 @@ 37 41 String? host, 38 42 int? port, 39 43 String? localHost, 44 + String? vpnHost,40 45 String? macAddress, 41 46 String? mqttToken, 42 47 }) { .. .. @@ -44,6 +49,7 @@ 44 49 host: host ?? this.host, 45 50 port: port ?? this.port, 46 51 localHost: localHost ?? this.localHost, 52 + vpnHost: vpnHost ?? this.vpnHost,47 53 macAddress: macAddress ?? this.macAddress, 48 54 mqttToken: mqttToken ?? this.mqttToken, 49 55 ); lib/screens/settings_screen.dart
.. .. @@ -18,6 +18,7 @@ 18 18 class _SettingsScreenState extends ConsumerState<SettingsScreen> { 19 19 final _formKey = GlobalKey<FormState>(); 20 20 late final TextEditingController _localHostController; 21 + late final TextEditingController _vpnHostController;21 22 late final TextEditingController _remoteHostController; 22 23 late final TextEditingController _portController; 23 24 late final TextEditingController _macController; .. .. @@ -30,6 +31,8 @@ 30 31 final config = ref.read(serverConfigProvider); 31 32 _localHostController = 32 33 TextEditingController(text: config?.localHost ?? ''); 34 + _vpnHostController =35 + TextEditingController(text: config?.vpnHost ?? '');33 36 _remoteHostController = 34 37 TextEditingController(text: config?.host ?? ''); 35 38 _portController = .. .. @@ -43,6 +46,7 @@ 43 46 @override 44 47 void dispose() { 45 48 _localHostController.dispose(); 49 + _vpnHostController.dispose();46 50 _remoteHostController.dispose(); 47 51 _portController.dispose(); 48 52 _macController.dispose(); .. .. @@ -59,6 +63,9 @@ 59 63 localHost: _localHostController.text.trim().isEmpty 60 64 ? null 61 65 : _localHostController.text.trim(), 66 + vpnHost: _vpnHostController.text.trim().isEmpty67 + ? null68 + : _vpnHostController.text.trim(),62 69 macAddress: _macController.text.trim().isEmpty 63 70 ? null 64 71 : _macController.text.trim(), .. .. @@ -153,6 +160,19 @@ 153 160 ), 154 161 const SizedBox(height: 16), 155 162 163 + // VPN address164 + Text('VPN Address',165 + style: Theme.of(context).textTheme.bodyMedium),166 + const SizedBox(height: 4),167 + TextFormField(168 + controller: _vpnHostController,169 + decoration: const InputDecoration(170 + hintText: '10.8.0.1 (OpenVPN static IP)',171 + ),172 + keyboardType: TextInputType.url,173 + ),174 + const SizedBox(height: 16),175 +156 176 // Remote address 157 177 Text('Remote Address', 158 178 style: Theme.of(context).textTheme.bodyMedium), lib/services/mqtt_service.dart
.. .. @@ -2,6 +2,7 @@ 2 2 import 'dart:convert'; 3 3 import 'dart:io'; 4 4 5 +import 'package:bonsoir/bonsoir.dart';5 6 import 'package:flutter/widgets.dart'; 6 7 import 'package:path_provider/path_provider.dart' as pp; 7 8 import 'package:mqtt_client/mqtt_client.dart'; .. .. @@ -102,29 +103,50 @@ 102 103 } 103 104 104 105 final clientId = await _getClientId(); 105 - final hosts = _getHosts();106 - _mqttLog('MQTT: hosts=${hosts.join(", ")} port=${config.port}');107 106 108 - for (final host in hosts) {107 + // Connection order: local → Bonjour → VPN → remote108 + final attempts = <MapEntry<String, int>>[]; // host → timeout ms109 + if (config.localHost != null && config.localHost!.isNotEmpty) {110 + attempts.add(MapEntry(config.localHost!, 2500));111 + }112 + // Bonjour placeholder — inserted dynamically below113 + if (config.vpnHost != null && config.vpnHost!.isNotEmpty) {114 + attempts.add(MapEntry(config.vpnHost!, 3000));115 + }116 + if (config.host.isNotEmpty) {117 + attempts.add(MapEntry(config.host, 5000));118 + }119 + _mqttLog('MQTT: attempts=${attempts.map((e) => e.key).join(", ")} port=${config.port}');120 +121 + // Try configured local host first122 + for (final attempt in attempts) {109 123 if (_intentionalClose) return; 110 -111 - _mqttLog('MQTT: trying $host:${config.port}');124 + _mqttLog('MQTT: trying ${attempt.key}:${config.port}');112 125 try { 113 - final connected = await _tryConnect(114 - host,115 - clientId,116 - timeout: host == hosts.first && hosts.length > 1 ? 2500 : 5000,117 - );118 - _mqttLog('MQTT: $host result=$connected');119 - if (connected) return;126 + if (await _tryConnect(attempt.key, clientId, timeout: attempt.value)) return;120 127 } catch (e) { 121 - _mqttLog('MQTT: $host error=$e');122 - continue;128 + _mqttLog('MQTT: ${attempt.key} error=$e');129 + }130 +131 + // After local host fails, try Bonjour discovery before VPN/remote132 + if (attempt.key == config.localHost && !_intentionalClose) {133 + _mqttLog('MQTT: trying Bonjour discovery...');134 + final bonjourHost = await _discoverViaMdns();135 + if (bonjourHost != null && !_intentionalClose) {136 + _mqttLog('MQTT: Bonjour found $bonjourHost');137 + try {138 + if (await _tryConnect(bonjourHost, clientId, timeout: 3000)) return;139 + } catch (e) {140 + _mqttLog('MQTT: Bonjour host $bonjourHost error=$e');141 + }142 + } else {143 + _mqttLog('MQTT: Bonjour discovery returned nothing');144 + }123 145 } 124 146 } 125 147 126 148 // All hosts failed — retry after delay 127 - _mqttLog('MQTT: all hosts failed, retrying in 5s');149 + _mqttLog('MQTT: all attempts failed, retrying in 5s');128 150 _setStatus(ConnectionStatus.reconnecting); 129 151 Future.delayed(const Duration(seconds: 5), () { 130 152 if (!_intentionalClose && _status != ConnectionStatus.connected) { .. .. @@ -133,14 +155,43 @@ 133 155 }); 134 156 } 135 157 136 - /// Returns [localHost, remoteHost] for dual-connect attempts.137 - List<String> _getHosts() {138 - if (config.localHost != null &&139 - config.localHost!.isNotEmpty &&140 - config.localHost != config.host) {141 - return [config.localHost!, config.host];158 + /// Discover AIBroker on local network via Bonjour/mDNS.159 + /// Returns the IP address or null if not found within timeout.160 + Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async {161 + try {162 + final discovery = BonsoirDiscovery(type: '_mqtt._tcp');163 + await discovery.initialize();164 +165 + final completer = Completer<String?>();166 + StreamSubscription? sub;167 +168 + sub = discovery.eventStream?.listen((event) {169 + switch (event) {170 + case BonsoirDiscoveryServiceResolvedEvent():171 + final ip = event.service.host;172 + _mqttLog('MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}');173 + if (ip != null && ip.isNotEmpty && !completer.isCompleted) {174 + completer.complete(ip);175 + }176 + case BonsoirDiscoveryServiceFoundEvent():177 + _mqttLog('MQTT: Bonjour found: ${event.service.name}');178 + default:179 + break;180 + }181 + });182 +183 + await discovery.start();184 +185 + final ip = await completer.future.timeout(timeout, onTimeout: () => null);186 +187 + await sub?.cancel();188 + await discovery.stop();189 +190 + return ip;191 + } catch (e) {192 + _mqttLog('MQTT: Bonjour discovery error: $e');193 + return null;142 194 } 143 - return [config.host];144 195 } 145 196 146 197 Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async { pubspec.lock
.. .. @@ -73,6 +73,54 @@ 73 73 url: "https://pub.dev" 74 74 source: hosted 75 75 version: "4.3.0" 76 + bonsoir:77 + dependency: "direct main"78 + description:79 + name: bonsoir80 + sha256: "42f2c1eb55e833bcb541dfcb759851da0a703106646a0cf15a16c6de21f4a5a4"81 + url: "https://pub.dev"82 + source: hosted83 + version: "6.0.2"84 + bonsoir_android:85 + dependency: transitive86 + description:87 + name: bonsoir_android88 + sha256: e19728f94a0d9813abf9e2edf644fede008e58ef539865a1be86ac5d8994154e89 + url: "https://pub.dev"90 + source: hosted91 + version: "6.0.1"92 + bonsoir_darwin:93 + dependency: transitive94 + description:95 + name: bonsoir_darwin96 + sha256: e242a03a019fd474be657715826cfc13e43d02c88e46ec5611a20b9d4f72854d97 + url: "https://pub.dev"98 + source: hosted99 + version: "6.0.1"100 + bonsoir_linux:101 + dependency: transitive102 + description:103 + name: bonsoir_linux104 + sha256: "9c326c572c241c6a38ab7a8a5dba27c82917ec12504f84308ce3b5706619e8d3"105 + url: "https://pub.dev"106 + source: hosted107 + version: "6.0.2"108 + bonsoir_platform_interface:109 + dependency: transitive110 + description:111 + name: bonsoir_platform_interface112 + sha256: "3fa0c46b30eb2a2f48be6fa53591a5c0425bf00520be761b61763e58b51814ff"113 + url: "https://pub.dev"114 + source: hosted115 + version: "6.0.1"116 + bonsoir_windows:117 + dependency: transitive118 + description:119 + name: bonsoir_windows120 + sha256: "34c54802baaa2f00e3c4ab7ea46888f2a829876753778e2f40e3f273c3382d34"121 + url: "https://pub.dev"122 + source: hosted123 + version: "6.0.1"76 124 boolean_selector: 77 125 dependency: transitive 78 126 description: pubspec.yaml
.. .. @@ -31,6 +31,7 @@ 31 31 collection: ^1.19.1 32 32 file_picker: ^10.3.10 33 33 flutter_markdown: ^0.7.7+1 34 + bonsoir: ^6.0.234 35 35 36 dev_dependencies: 36 37 flutter_test: