From 96c8bb5db1a2e0ced999a366e3cf28f9895ec39f Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Tue, 24 Mar 2026 21:51:35 +0100
Subject: [PATCH] feat: Bonjour auto-discovery + VPN IP field in connection flow
---
lib/screens/settings_screen.dart | 20 ++++++
lib/models/server_config.dart | 6 ++
ios/Runner/Info.plist | 3
lib/services/mqtt_service.dart | 95 ++++++++++++++++++++++++-------
pubspec.lock | 48 ++++++++++++++++
pubspec.yaml | 1
6 files changed, 150 insertions(+), 23 deletions(-)
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 59f2f28..8103c41 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -67,10 +67,11 @@
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
- <string>PAILot connects to your local AI server</string>
+ <string>PAILot needs local network access to discover and connect to AIBroker</string>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
+ <string>_mqtt._tcp</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
diff --git a/lib/models/server_config.dart b/lib/models/server_config.dart
index 14fa6df..59023cd 100644
--- a/lib/models/server_config.dart
+++ b/lib/models/server_config.dart
@@ -2,6 +2,7 @@
final String host;
final int port;
final String? localHost;
+ final String? vpnHost;
final String? macAddress;
final String? mqttToken;
@@ -9,6 +10,7 @@
required this.host,
this.port = 8765,
this.localHost,
+ this.vpnHost,
this.macAddress,
this.mqttToken,
});
@@ -18,6 +20,7 @@
'host': host,
'port': port,
if (localHost != null) 'localHost': localHost,
+ if (vpnHost != null) 'vpnHost': vpnHost,
if (macAddress != null) 'macAddress': macAddress,
if (mqttToken != null) 'mqttToken': mqttToken,
};
@@ -28,6 +31,7 @@
host: json['host'] as String? ?? '',
port: json['port'] as int? ?? 8765,
localHost: json['localHost'] as String?,
+ vpnHost: json['vpnHost'] as String?,
macAddress: json['macAddress'] as String?,
mqttToken: json['mqttToken'] as String?,
);
@@ -37,6 +41,7 @@
String? host,
int? port,
String? localHost,
+ String? vpnHost,
String? macAddress,
String? mqttToken,
}) {
@@ -44,6 +49,7 @@
host: host ?? this.host,
port: port ?? this.port,
localHost: localHost ?? this.localHost,
+ vpnHost: vpnHost ?? this.vpnHost,
macAddress: macAddress ?? this.macAddress,
mqttToken: mqttToken ?? this.mqttToken,
);
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 0cfb0fc..2a41067 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -18,6 +18,7 @@
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _localHostController;
+ late final TextEditingController _vpnHostController;
late final TextEditingController _remoteHostController;
late final TextEditingController _portController;
late final TextEditingController _macController;
@@ -30,6 +31,8 @@
final config = ref.read(serverConfigProvider);
_localHostController =
TextEditingController(text: config?.localHost ?? '');
+ _vpnHostController =
+ TextEditingController(text: config?.vpnHost ?? '');
_remoteHostController =
TextEditingController(text: config?.host ?? '');
_portController =
@@ -43,6 +46,7 @@
@override
void dispose() {
_localHostController.dispose();
+ _vpnHostController.dispose();
_remoteHostController.dispose();
_portController.dispose();
_macController.dispose();
@@ -59,6 +63,9 @@
localHost: _localHostController.text.trim().isEmpty
? null
: _localHostController.text.trim(),
+ vpnHost: _vpnHostController.text.trim().isEmpty
+ ? null
+ : _vpnHostController.text.trim(),
macAddress: _macController.text.trim().isEmpty
? null
: _macController.text.trim(),
@@ -153,6 +160,19 @@
),
const SizedBox(height: 16),
+ // VPN address
+ Text('VPN Address',
+ style: Theme.of(context).textTheme.bodyMedium),
+ const SizedBox(height: 4),
+ TextFormField(
+ controller: _vpnHostController,
+ decoration: const InputDecoration(
+ hintText: '10.8.0.1 (OpenVPN static IP)',
+ ),
+ keyboardType: TextInputType.url,
+ ),
+ const SizedBox(height: 16),
+
// Remote address
Text('Remote Address',
style: Theme.of(context).textTheme.bodyMedium),
diff --git a/lib/services/mqtt_service.dart b/lib/services/mqtt_service.dart
index 87eafc9..200ba3c 100644
--- a/lib/services/mqtt_service.dart
+++ b/lib/services/mqtt_service.dart
@@ -2,6 +2,7 @@
import 'dart:convert';
import 'dart:io';
+import 'package:bonsoir/bonsoir.dart';
import 'package:flutter/widgets.dart';
import 'package:path_provider/path_provider.dart' as pp;
import 'package:mqtt_client/mqtt_client.dart';
@@ -102,29 +103,50 @@
}
final clientId = await _getClientId();
- final hosts = _getHosts();
- _mqttLog('MQTT: hosts=${hosts.join(", ")} port=${config.port}');
- for (final host in hosts) {
+ // Connection order: local → Bonjour → VPN → remote
+ final attempts = <MapEntry<String, int>>[]; // host → timeout ms
+ if (config.localHost != null && config.localHost!.isNotEmpty) {
+ attempts.add(MapEntry(config.localHost!, 2500));
+ }
+ // Bonjour placeholder — inserted dynamically below
+ if (config.vpnHost != null && config.vpnHost!.isNotEmpty) {
+ attempts.add(MapEntry(config.vpnHost!, 3000));
+ }
+ if (config.host.isNotEmpty) {
+ attempts.add(MapEntry(config.host, 5000));
+ }
+ _mqttLog('MQTT: attempts=${attempts.map((e) => e.key).join(", ")} port=${config.port}');
+
+ // Try configured local host first
+ for (final attempt in attempts) {
if (_intentionalClose) return;
-
- _mqttLog('MQTT: trying $host:${config.port}');
+ _mqttLog('MQTT: trying ${attempt.key}:${config.port}');
try {
- final connected = await _tryConnect(
- host,
- clientId,
- timeout: host == hosts.first && hosts.length > 1 ? 2500 : 5000,
- );
- _mqttLog('MQTT: $host result=$connected');
- if (connected) return;
+ if (await _tryConnect(attempt.key, clientId, timeout: attempt.value)) return;
} catch (e) {
- _mqttLog('MQTT: $host error=$e');
- continue;
+ _mqttLog('MQTT: ${attempt.key} error=$e');
+ }
+
+ // After local host fails, try Bonjour discovery before VPN/remote
+ if (attempt.key == config.localHost && !_intentionalClose) {
+ _mqttLog('MQTT: trying Bonjour discovery...');
+ final bonjourHost = await _discoverViaMdns();
+ if (bonjourHost != null && !_intentionalClose) {
+ _mqttLog('MQTT: Bonjour found $bonjourHost');
+ try {
+ if (await _tryConnect(bonjourHost, clientId, timeout: 3000)) return;
+ } catch (e) {
+ _mqttLog('MQTT: Bonjour host $bonjourHost error=$e');
+ }
+ } else {
+ _mqttLog('MQTT: Bonjour discovery returned nothing');
+ }
}
}
// All hosts failed — retry after delay
- _mqttLog('MQTT: all hosts failed, retrying in 5s');
+ _mqttLog('MQTT: all attempts failed, retrying in 5s');
_setStatus(ConnectionStatus.reconnecting);
Future.delayed(const Duration(seconds: 5), () {
if (!_intentionalClose && _status != ConnectionStatus.connected) {
@@ -133,14 +155,43 @@
});
}
- /// Returns [localHost, remoteHost] for dual-connect attempts.
- List<String> _getHosts() {
- if (config.localHost != null &&
- config.localHost!.isNotEmpty &&
- config.localHost != config.host) {
- return [config.localHost!, config.host];
+ /// Discover AIBroker on local network via Bonjour/mDNS.
+ /// Returns the IP address or null if not found within timeout.
+ Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async {
+ try {
+ final discovery = BonsoirDiscovery(type: '_mqtt._tcp');
+ await discovery.initialize();
+
+ final completer = Completer<String?>();
+ StreamSubscription? sub;
+
+ sub = discovery.eventStream?.listen((event) {
+ switch (event) {
+ case BonsoirDiscoveryServiceResolvedEvent():
+ final ip = event.service.host;
+ _mqttLog('MQTT: Bonjour resolved: ${event.service.name} at $ip:${event.service.port}');
+ if (ip != null && ip.isNotEmpty && !completer.isCompleted) {
+ completer.complete(ip);
+ }
+ case BonsoirDiscoveryServiceFoundEvent():
+ _mqttLog('MQTT: Bonjour found: ${event.service.name}');
+ default:
+ break;
+ }
+ });
+
+ await discovery.start();
+
+ final ip = await completer.future.timeout(timeout, onTimeout: () => null);
+
+ await sub?.cancel();
+ await discovery.stop();
+
+ return ip;
+ } catch (e) {
+ _mqttLog('MQTT: Bonjour discovery error: $e');
+ return null;
}
- return [config.host];
}
Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async {
diff --git a/pubspec.lock b/pubspec.lock
index ec7686d..9b819d8 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -73,6 +73,54 @@
url: "https://pub.dev"
source: hosted
version: "4.3.0"
+ bonsoir:
+ dependency: "direct main"
+ description:
+ name: bonsoir
+ sha256: "42f2c1eb55e833bcb541dfcb759851da0a703106646a0cf15a16c6de21f4a5a4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.2"
+ bonsoir_android:
+ dependency: transitive
+ description:
+ name: bonsoir_android
+ sha256: e19728f94a0d9813abf9e2edf644fede008e58ef539865a1be86ac5d8994154e
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.1"
+ bonsoir_darwin:
+ dependency: transitive
+ description:
+ name: bonsoir_darwin
+ sha256: e242a03a019fd474be657715826cfc13e43d02c88e46ec5611a20b9d4f72854d
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.1"
+ bonsoir_linux:
+ dependency: transitive
+ description:
+ name: bonsoir_linux
+ sha256: "9c326c572c241c6a38ab7a8a5dba27c82917ec12504f84308ce3b5706619e8d3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.2"
+ bonsoir_platform_interface:
+ dependency: transitive
+ description:
+ name: bonsoir_platform_interface
+ sha256: "3fa0c46b30eb2a2f48be6fa53591a5c0425bf00520be761b61763e58b51814ff"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.1"
+ bonsoir_windows:
+ dependency: transitive
+ description:
+ name: bonsoir_windows
+ sha256: "34c54802baaa2f00e3c4ab7ea46888f2a829876753778e2f40e3f273c3382d34"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.1"
boolean_selector:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index f4da55f..c54e2d0 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -31,6 +31,7 @@
collection: ^1.19.1
file_picker: ^10.3.10
flutter_markdown: ^0.7.7+1
+ bonsoir: ^6.0.2
dev_dependencies:
flutter_test:
--
Gitblit v1.3.1