UDP Device Discovery in Flutter: Building a Local Network Scanner
When building a companion app for an IoT device (like our Hoopi Pedal), you face a fundamental challenge: how does the app find the device on the local network?
Options include:
- Manual IP entry - Terrible UX, IPs change
- mDNS/Bonjour - Complex, platform-specific quirks
- Cloud relay - Requires internet, adds latency
- UDP broadcast - Simple, fast, works offline ✓
We chose UDP broadcast because our device already announces itself on the network. The app just needs to listen.
Architecture Overview
The Broadcast Packet
Our device broadcasts a JSON packet every second:
{
"name": "Hoopi-A1B2C3",
"type": "hoopi",
"is_recording": false,
"error_count": 0,
"filename": null,
"duration": null
}
| Field | Description |
|---|---|
name |
Unique device identifier |
type |
Device variant (hoopi, Hoopi Core, riffpod) |
is_recording |
Current recording state |
error_count |
SD card errors |
filename |
Current recording filename |
duration |
Recording duration in seconds |
Implementation
The Discovery Service
We use a singleton pattern to maintain a single UDP listener throughout the app lifecycle:
class DeviceDiscoveryService {
static const int _udpPort = 16488;
static DeviceDiscoveryService? _instance;
RawDatagramSocket? _socket;
final StreamController<List<Device>> _devicesController =
StreamController<List<Device>>.broadcast();
final Map<String, Device> _discoveredDevices = {};
bool _isRunning = false;
// Singleton access
static DeviceDiscoveryService get instance {
_instance ??= DeviceDiscoveryService._internal();
return _instance!;
}
DeviceDiscoveryService._internal();
Stream<List<Device>> get devicesStream => _devicesController.stream;
List<Device> get currentDevices => _discoveredDevices.values.toList();
}
Starting the Listener
Future<void> initialize() async {
if (_isRunning) return;
try {
log('Starting UDP discovery on port $_udpPort');
// Load previously known devices (shown as offline)
await _loadStoredDevices();
// Bind to UDP port
_socket = await RawDatagramSocket.bind(
InternetAddress.anyIPv4,
_udpPort
);
_socket!.listen(_handleUdpPacket);
_isRunning = true;
} catch (e) {
log('Failed to start UDP discovery: $e');
rethrow;
}
}
Processing Incoming Packets
void _handleUdpPacket(RawSocketEvent event) {
if (event == RawSocketEvent.read) {
final datagram = _socket!.receive();
if (datagram != null) {
try {
final message = utf8.decode(datagram.data);
final json = jsonDecode(message) as Map<String, dynamic>;
if (json.containsKey('name')) {
final deviceName = json['name'] as String;
final deviceIp = datagram.address.address; // IP from packet source
final device = Device(
ipAddress: deviceIp,
name: deviceName,
isConnected: true,
lastSeenAt: DateTime.now(),
type: DeviceType.fromString(json['type'] ?? 'hoopi'),
isRecording: json['is_recording'] ?? false,
errorCount: json['error_count'] ?? 0,
currentFilename: json['filename'],
recordingDuration: json['duration'],
);
_updateDevice(device);
}
} catch (e) {
log('Failed to parse UDP packet: $e');
}
}
}
}
Handling Device State Changes
Detecting Status Changes
We only notify the UI when something meaningful changes:
void _updateDevice(Device device) {
final existing = _discoveredDevices[device.name];
final statusChanged = existing == null ||
!existing.isConnected ||
existing.ipAddress != device.ipAddress ||
existing.isRecording != device.isRecording ||
existing.currentFilename != device.currentFilename;
_discoveredDevices[device.name] = device;
if (statusChanged) {
_devicesController.add(currentDevices);
_storageService?.saveDiscoveredDevice(device);
log('Device ${existing == null ? 'discovered' : 'updated'}: ${device.name}');
}
}
Persisting Discovered Devices
Users expect to see their devices even before they come online. We persist device info to local storage:
Future<void> _loadStoredDevices() async {
final storedDevices = await _storageService.getDiscoveredDevices();
for (final device in storedDevices) {
// Mark as offline until we receive UDP
_discoveredDevices[device.name] = device.copyWith(
isConnected: false
);
}
if (storedDevices.isNotEmpty) {
_devicesController.add(currentDevices);
log('Loaded ${storedDevices.length} stored devices');
}
}
The Device Model
class Device {
final String ipAddress;
final String name;
final bool isConnected;
final DateTime? lastSeenAt;
final DeviceType type;
final bool isRecording;
final int errorCount;
final String? currentFilename;
final int? recordingDuration;
Device({
required this.ipAddress,
required this.name,
this.isConnected = false,
this.lastSeenAt,
this.type = DeviceType.hoopi,
this.isRecording = false,
this.errorCount = 0,
this.currentFilename,
this.recordingDuration,
});
Device copyWith({
String? ipAddress,
String? name,
bool? isConnected,
DateTime? lastSeenAt,
DeviceType? type,
bool? isRecording,
int? errorCount,
String? currentFilename,
int? recordingDuration,
}) {
return Device(
ipAddress: ipAddress ?? this.ipAddress,
name: name ?? this.name,
isConnected: isConnected ?? this.isConnected,
lastSeenAt: lastSeenAt ?? this.lastSeenAt,
type: type ?? this.type,
isRecording: isRecording ?? this.isRecording,
errorCount: errorCount ?? this.errorCount,
currentFilename: currentFilename ?? this.currentFilename,
recordingDuration: recordingDuration ?? this.recordingDuration,
);
}
}
enum DeviceType {
hoopi,
hoopiCore,
riffpod;
static DeviceType fromString(String value) {
switch (value.toLowerCase()) {
case 'hoopi core':
return DeviceType.hoopiCore;
case 'riffpod':
return DeviceType.riffpod;
default:
return DeviceType.hoopi;
}
}
}
UI Integration
Consuming the Device Stream
class DeviceListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<List<Device>>(
stream: DeviceDiscoveryService.instance.devicesStream,
initialData: DeviceDiscoveryService.instance.currentDevices,
builder: (context, snapshot) {
final devices = snapshot.data ?? [];
if (devices.isEmpty) {
return _buildEmptyState();
}
return ListView.builder(
itemCount: devices.length,
itemBuilder: (context, index) {
final device = devices[index];
return DeviceListTile(device: device);
},
);
},
);
}
}
Device List Tile
class DeviceListTile extends StatelessWidget {
final Device device;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(
device.isConnected ? Icons.wifi : Icons.wifi_off,
color: device.isConnected ? Colors.green : Colors.grey,
),
title: Text(device.name),
subtitle: Text(device.isConnected
? device.ipAddress
: 'Offline'),
trailing: device.isRecording
? _buildRecordingIndicator()
: null,
onTap: device.isConnected
? () => _navigateToDevice(context, device)
: null,
);
}
Widget _buildRecordingIndicator() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.fiber_manual_record, color: Colors.red, size: 12),
SizedBox(width: 4),
Text('REC'),
],
);
}
}
Handling Edge Cases
WiFi Switching
When the user switches WiFi networks, we may need to rebind:
Future<void> handleNetworkChange() async {
// Close existing socket
_socket?.close();
_socket = null;
// Mark all devices offline
for (final name in _discoveredDevices.keys) {
_discoveredDevices[name] = _discoveredDevices[name]!.copyWith(
isConnected: false
);
}
_devicesController.add(currentDevices);
// Rebind to new network
await Future.delayed(Duration(seconds: 1));
_socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, _udpPort);
_socket!.listen(_handleUdpPacket);
}
Manual Refresh
Future<void> refreshDevices() async {
// Set all devices offline
for (final name in _discoveredDevices.keys) {
_discoveredDevices[name] = _discoveredDevices[name]!.copyWith(
isConnected: false
);
}
_devicesController.add(currentDevices);
// Devices will come back online when UDP packets arrive
}
Device Timeout Detection
In the device detail screen, we track when we last received a packet:
class DeviceDetailScreen extends StatefulWidget {
Timer? _timeoutTimer;
@override
void initState() {
super.initState();
_startTimeoutDetection();
}
void _startTimeoutDetection() {
_timeoutTimer = Timer.periodic(Duration(seconds: 5), (_) {
final device = DeviceDiscoveryService.instance
.currentDevices
.firstWhere((d) => d.name == widget.deviceName);
if (device.lastSeenAt != null) {
final elapsed = DateTime.now().difference(device.lastSeenAt!);
if (elapsed.inSeconds > 10) {
// Device hasn't broadcast in 10 seconds
DeviceDiscoveryService.instance.updateDeviceStatus(
widget.deviceName,
false
);
}
}
});
}
}
Data Flow Summary
Key Takeaways
- Singleton pattern keeps one UDP listener for the entire app lifecycle
- Broadcast streams allow multiple UI components to observe device changes
- Persist known devices so they appear (offline) before coming online
- Track
lastSeenAtto detect disconnections - Only notify on meaningful changes to avoid unnecessary rebuilds
- Handle network switches by rebinding the socket
Platform Considerations
Android
Add to AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
iOS
UDP works out of the box. For local network access prompt (iOS 14+), add to Info.plist:
<key>NSLocalNetworkUsageDescription</key>
<string>Discover Hoopi devices on your network</string>
<key>NSBonjourServices</key>
<array>
<string>_hoopi._udp</string>
</array>
This approach scales to multiple device types and handles the messy realities of mobile networking - WiFi switches, backgrounding, and unreliable connections.