Matthias Nott
2026-03-24 96c8bb5db1a2e0ced999a366e3cf28f9895ec39f
feat: Bonjour auto-discovery + VPN IP field in connection flow
6 files modified
changed files
ios/Runner/Info.plist patch | view | blame | history
lib/models/server_config.dart patch | view | blame | history
lib/screens/settings_screen.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
ios/Runner/Info.plist
....@@ -67,10 +67,11 @@
6767 <true/>
6868 </dict>
6969 <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>
7171 <key>NSBonjourServices</key>
7272 <array>
7373 <string>_http._tcp</string>
74
+ <string>_mqtt._tcp</string>
7475 </array>
7576 <key>UISupportedInterfaceOrientations</key>
7677 <array>
lib/models/server_config.dart
....@@ -2,6 +2,7 @@
22 final String host;
33 final int port;
44 final String? localHost;
5
+ final String? vpnHost;
56 final String? macAddress;
67 final String? mqttToken;
78
....@@ -9,6 +10,7 @@
910 required this.host,
1011 this.port = 8765,
1112 this.localHost,
13
+ this.vpnHost,
1214 this.macAddress,
1315 this.mqttToken,
1416 });
....@@ -18,6 +20,7 @@
1820 'host': host,
1921 'port': port,
2022 if (localHost != null) 'localHost': localHost,
23
+ if (vpnHost != null) 'vpnHost': vpnHost,
2124 if (macAddress != null) 'macAddress': macAddress,
2225 if (mqttToken != null) 'mqttToken': mqttToken,
2326 };
....@@ -28,6 +31,7 @@
2831 host: json['host'] as String? ?? '',
2932 port: json['port'] as int? ?? 8765,
3033 localHost: json['localHost'] as String?,
34
+ vpnHost: json['vpnHost'] as String?,
3135 macAddress: json['macAddress'] as String?,
3236 mqttToken: json['mqttToken'] as String?,
3337 );
....@@ -37,6 +41,7 @@
3741 String? host,
3842 int? port,
3943 String? localHost,
44
+ String? vpnHost,
4045 String? macAddress,
4146 String? mqttToken,
4247 }) {
....@@ -44,6 +49,7 @@
4449 host: host ?? this.host,
4550 port: port ?? this.port,
4651 localHost: localHost ?? this.localHost,
52
+ vpnHost: vpnHost ?? this.vpnHost,
4753 macAddress: macAddress ?? this.macAddress,
4854 mqttToken: mqttToken ?? this.mqttToken,
4955 );
lib/screens/settings_screen.dart
....@@ -18,6 +18,7 @@
1818 class _SettingsScreenState extends ConsumerState<SettingsScreen> {
1919 final _formKey = GlobalKey<FormState>();
2020 late final TextEditingController _localHostController;
21
+ late final TextEditingController _vpnHostController;
2122 late final TextEditingController _remoteHostController;
2223 late final TextEditingController _portController;
2324 late final TextEditingController _macController;
....@@ -30,6 +31,8 @@
3031 final config = ref.read(serverConfigProvider);
3132 _localHostController =
3233 TextEditingController(text: config?.localHost ?? '');
34
+ _vpnHostController =
35
+ TextEditingController(text: config?.vpnHost ?? '');
3336 _remoteHostController =
3437 TextEditingController(text: config?.host ?? '');
3538 _portController =
....@@ -43,6 +46,7 @@
4346 @override
4447 void dispose() {
4548 _localHostController.dispose();
49
+ _vpnHostController.dispose();
4650 _remoteHostController.dispose();
4751 _portController.dispose();
4852 _macController.dispose();
....@@ -59,6 +63,9 @@
5963 localHost: _localHostController.text.trim().isEmpty
6064 ? null
6165 : _localHostController.text.trim(),
66
+ vpnHost: _vpnHostController.text.trim().isEmpty
67
+ ? null
68
+ : _vpnHostController.text.trim(),
6269 macAddress: _macController.text.trim().isEmpty
6370 ? null
6471 : _macController.text.trim(),
....@@ -153,6 +160,19 @@
153160 ),
154161 const SizedBox(height: 16),
155162
163
+ // VPN address
164
+ 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
+
156176 // Remote address
157177 Text('Remote Address',
158178 style: Theme.of(context).textTheme.bodyMedium),
lib/services/mqtt_service.dart
....@@ -2,6 +2,7 @@
22 import 'dart:convert';
33 import 'dart:io';
44
5
+import 'package:bonsoir/bonsoir.dart';
56 import 'package:flutter/widgets.dart';
67 import 'package:path_provider/path_provider.dart' as pp;
78 import 'package:mqtt_client/mqtt_client.dart';
....@@ -102,29 +103,50 @@
102103 }
103104
104105 final clientId = await _getClientId();
105
- final hosts = _getHosts();
106
- _mqttLog('MQTT: hosts=${hosts.join(", ")} port=${config.port}');
107106
108
- for (final host in hosts) {
107
+ // Connection order: local → Bonjour → VPN → remote
108
+ final attempts = <MapEntry<String, int>>[]; // host → timeout ms
109
+ if (config.localHost != null && config.localHost!.isNotEmpty) {
110
+ attempts.add(MapEntry(config.localHost!, 2500));
111
+ }
112
+ // Bonjour placeholder — inserted dynamically below
113
+ 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 first
122
+ for (final attempt in attempts) {
109123 if (_intentionalClose) return;
110
-
111
- _mqttLog('MQTT: trying $host:${config.port}');
124
+ _mqttLog('MQTT: trying ${attempt.key}:${config.port}');
112125 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;
120127 } 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/remote
132
+ 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
+ }
123145 }
124146 }
125147
126148 // All hosts failed — retry after delay
127
- _mqttLog('MQTT: all hosts failed, retrying in 5s');
149
+ _mqttLog('MQTT: all attempts failed, retrying in 5s');
128150 _setStatus(ConnectionStatus.reconnecting);
129151 Future.delayed(const Duration(seconds: 5), () {
130152 if (!_intentionalClose && _status != ConnectionStatus.connected) {
....@@ -133,14 +155,43 @@
133155 });
134156 }
135157
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;
142194 }
143
- return [config.host];
144195 }
145196
146197 Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
pubspec.lock
....@@ -73,6 +73,54 @@
7373 url: "https://pub.dev"
7474 source: hosted
7575 version: "4.3.0"
76
+ bonsoir:
77
+ dependency: "direct main"
78
+ description:
79
+ name: bonsoir
80
+ sha256: "42f2c1eb55e833bcb541dfcb759851da0a703106646a0cf15a16c6de21f4a5a4"
81
+ url: "https://pub.dev"
82
+ source: hosted
83
+ version: "6.0.2"
84
+ bonsoir_android:
85
+ dependency: transitive
86
+ description:
87
+ name: bonsoir_android
88
+ sha256: e19728f94a0d9813abf9e2edf644fede008e58ef539865a1be86ac5d8994154e
89
+ url: "https://pub.dev"
90
+ source: hosted
91
+ version: "6.0.1"
92
+ bonsoir_darwin:
93
+ dependency: transitive
94
+ description:
95
+ name: bonsoir_darwin
96
+ sha256: e242a03a019fd474be657715826cfc13e43d02c88e46ec5611a20b9d4f72854d
97
+ url: "https://pub.dev"
98
+ source: hosted
99
+ version: "6.0.1"
100
+ bonsoir_linux:
101
+ dependency: transitive
102
+ description:
103
+ name: bonsoir_linux
104
+ sha256: "9c326c572c241c6a38ab7a8a5dba27c82917ec12504f84308ce3b5706619e8d3"
105
+ url: "https://pub.dev"
106
+ source: hosted
107
+ version: "6.0.2"
108
+ bonsoir_platform_interface:
109
+ dependency: transitive
110
+ description:
111
+ name: bonsoir_platform_interface
112
+ sha256: "3fa0c46b30eb2a2f48be6fa53591a5c0425bf00520be761b61763e58b51814ff"
113
+ url: "https://pub.dev"
114
+ source: hosted
115
+ version: "6.0.1"
116
+ bonsoir_windows:
117
+ dependency: transitive
118
+ description:
119
+ name: bonsoir_windows
120
+ sha256: "34c54802baaa2f00e3c4ab7ea46888f2a829876753778e2f40e3f273c3382d34"
121
+ url: "https://pub.dev"
122
+ source: hosted
123
+ version: "6.0.1"
76124 boolean_selector:
77125 dependency: transitive
78126 description:
pubspec.yaml
....@@ -31,6 +31,7 @@
3131 collection: ^1.19.1
3232 file_picker: ^10.3.10
3333 flutter_markdown: ^0.7.7+1
34
+ bonsoir: ^6.0.2
3435
3536 dev_dependencies:
3637 flutter_test: