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:

  1. Manual IP entry - Terrible UX, IPs change
  2. mDNS/Bonjour - Complex, platform-specific quirks
  3. Cloud relay - Requires internet, adds latency
  4. 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

flowchart LR subgraph Device["Hoopi Device"] BC[UDP Broadcaster<br/>Port 16488] end subgraph Network["Local WiFi Network"] PKT[/"Broadcast Packet<br/>{name, type, status}"/] end subgraph App["Flutter App"] UDP[UDP Listener<br/>Port 16488] DS[Device Service<br/>Singleton] UI[Device List UI] ST[Local Storage] end BC -->|Every 1s| PKT PKT -->|Received| UDP UDP --> DS DS --> UI DS <--> ST

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

stateDiagram-v2 [*] --> Stored: App Launch Stored --> Online: UDP Received Online --> Online: UDP Received (update status) Online --> Offline: No UDP for 5s Offline --> Online: UDP Received Online --> [*]: App Close

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

sequenceDiagram participant Device as Hoopi Device participant Net as WiFi Network participant Socket as UDP Socket participant Service as Discovery Service participant Stream as Device Stream participant UI as Flutter UI participant Storage as Local Storage Device->>Net: Broadcast JSON (every 1s) Net->>Socket: Datagram received Socket->>Service: RawSocketEvent.read Service->>Service: Parse JSON Service->>Service: Update device map alt Status Changed Service->>Stream: Add updated list Service->>Storage: Persist device Stream->>UI: StreamBuilder rebuilds end Note over UI: User sees device<br/>online with status

Key Takeaways

  1. Singleton pattern keeps one UDP listener for the entire app lifecycle
  2. Broadcast streams allow multiple UI components to observe device changes
  3. Persist known devices so they appear (offline) before coming online
  4. Track lastSeenAt to detect disconnections
  5. Only notify on meaningful changes to avoid unnecessary rebuilds
  6. 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.