Add SSDP/UPnP scanner to frontend status displays

Surface the SSDP/UPnP scanner alongside ARP and mDNS: a dedicated card on
the Status screen and a row in the consolidated Status card on Home. Mirrors
the existing mDNS implementation and reuses the generic ScannerStatusCard and
PolledValue infrastructure. Also maps the 'Ssdp' device-event scanner to the
"SSDP/UPnP" label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-01 18:13:33 -04:00
co-authored by Claude Opus 4.8
parent e8159f2b40
commit 440959b10d
6 changed files with 139 additions and 2 deletions
+1
View File
@@ -32,6 +32,7 @@ class DeviceEvent {
String get scannerLabel => switch (scanner) { String get scannerLabel => switch (scanner) {
'Arp' => 'ARP', 'Arp' => 'ARP',
'Mdns' => 'mDNS', 'Mdns' => 'mDNS',
'Ssdp' => 'SSDP/UPnP',
_ => scanner, _ => scanner,
}; };
} }
@@ -0,0 +1,23 @@
class SsdpScannerStatus {
final bool isListening;
final double? listeningForSeconds;
final int devicesSeen;
final double? lastDeviceSeenSecondsAgo;
const SsdpScannerStatus({
required this.isListening,
this.listeningForSeconds,
required this.devicesSeen,
this.lastDeviceSeenSecondsAgo,
});
factory SsdpScannerStatus.fromJson(Map<String, dynamic> json) {
return SsdpScannerStatus(
isListening: json['is_listening'] as bool,
listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(),
devicesSeen: (json['devices_seen'] as num).toInt(),
lastDeviceSeenSecondsAgo: (json['last_device_seen_seconds_ago'] as num?)
?.toDouble(),
);
}
}
+3
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../widgets/arp_scanner_card.dart'; import '../widgets/arp_scanner_card.dart';
import '../widgets/mdns_scanner_card.dart'; import '../widgets/mdns_scanner_card.dart';
import '../widgets/ssdp_scanner_card.dart';
class StatusScreen extends StatelessWidget { class StatusScreen extends StatelessWidget {
const StatusScreen({super.key}); const StatusScreen({super.key});
@@ -16,6 +17,8 @@ class StatusScreen extends StatelessWidget {
const ArpScannerCard(), const ArpScannerCard(),
const SizedBox(height: 8), const SizedBox(height: 8),
const MdnsScannerCard(), const MdnsScannerCard(),
const SizedBox(height: 8),
const SsdpScannerCard(),
], ],
); );
} }
+11
View File
@@ -8,6 +8,7 @@ import 'package:frontend/utils/backend_reachability.dart';
import 'package:frontend/utils/pref_utils.dart'; import 'package:frontend/utils/pref_utils.dart';
import '../model/arp_scanner_status.dart'; import '../model/arp_scanner_status.dart';
import '../model/mdns_scanner_status.dart'; import '../model/mdns_scanner_status.dart';
import '../model/ssdp_scanner_status.dart';
import '../model/device.dart'; import '../model/device.dart';
import '../model/device_event.dart'; import '../model/device_event.dart';
import '../model/device_summary.dart'; import '../model/device_summary.dart';
@@ -311,6 +312,16 @@ class BackendAPI {
return MdnsScannerStatus.fromJson(response.data as Map<String, dynamic>); return MdnsScannerStatus.fromJson(response.data as Map<String, dynamic>);
} }
Future<SsdpScannerStatus> getSsdpScannerStatus({
CancelToken? cancelToken,
}) async {
final response = await _dio.get(
'/ssdp_scanner/status',
cancelToken: cancelToken,
);
return SsdpScannerStatus.fromJson(response.data as Map<String, dynamic>);
}
Future<({List<Notification> items, bool hasNextPage})> listNotifications( Future<({List<Notification> items, bool hasNextPage})> listNotifications(
bool? isNew, { bool? isNew, {
int page = 0, int page = 0,
+54 -2
View File
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../model/arp_scanner_status.dart'; import '../model/arp_scanner_status.dart';
import '../model/mdns_scanner_status.dart'; import '../model/mdns_scanner_status.dart';
import '../model/ssdp_scanner_status.dart';
import '../navigation.dart'; import '../navigation.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../utils/backend_reachability.dart'; import '../utils/backend_reachability.dart';
@@ -24,6 +25,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
with RouteAware, WidgetsBindingObserver { with RouteAware, WidgetsBindingObserver {
late final PolledValue<ArpScannerStatus> _arp; late final PolledValue<ArpScannerStatus> _arp;
late final PolledValue<MdnsScannerStatus> _mdns; late final PolledValue<MdnsScannerStatus> _mdns;
late final PolledValue<SsdpScannerStatus> _ssdp;
Timer? _tickTimer; Timer? _tickTimer;
@override @override
@@ -42,6 +44,12 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
pollInterval: const Duration(seconds: 5), pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30), staleErrorAfter: const Duration(seconds: 30),
); );
_ssdp = PolledValue<SsdpScannerStatus>(
fetch: ({cancelToken}) =>
BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken),
pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30),
);
_tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {}); if (mounted) setState(() {});
}); });
@@ -78,6 +86,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
void _pausePolling() { void _pausePolling() {
_arp.pause(); _arp.pause();
_mdns.pause(); _mdns.pause();
_ssdp.pause();
_tickTimer?.cancel(); _tickTimer?.cancel();
_tickTimer = null; _tickTimer = null;
} }
@@ -85,6 +94,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
void _resumePolling() { void _resumePolling() {
_arp.resume(); _arp.resume();
_mdns.resume(); _mdns.resume();
_ssdp.resume();
_tickTimer ??= Timer.periodic(const Duration(seconds: 1), (_) { _tickTimer ??= Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {}); if (mounted) setState(() {});
}); });
@@ -97,6 +107,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
_tickTimer?.cancel(); _tickTimer?.cancel();
_arp.dispose(); _arp.dispose();
_mdns.dispose(); _mdns.dispose();
_ssdp.dispose();
super.dispose(); super.dispose();
} }
@@ -106,13 +117,16 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
listenable: Listenable.merge([ listenable: Listenable.merge([
_arp, _arp,
_mdns, _mdns,
_ssdp,
BackendReachability.instance, BackendReachability.instance,
]), ]),
builder: (context, _) { builder: (context, _) {
final arpFreshness = effectiveFreshness(_arp); final arpFreshness = effectiveFreshness(_arp);
final mdnsFreshness = effectiveFreshness(_mdns); final mdnsFreshness = effectiveFreshness(_mdns);
final ssdpFreshness = effectiveFreshness(_ssdp);
if (arpFreshness == PolledFreshness.initialLoading || if (arpFreshness == PolledFreshness.initialLoading ||
mdnsFreshness == PolledFreshness.initialLoading) { mdnsFreshness == PolledFreshness.initialLoading ||
ssdpFreshness == PolledFreshness.initialLoading) {
return const Card( return const Card(
child: Padding( child: Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
@@ -123,6 +137,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
final (arpColor, arpText) = _resolveArp(context, arpFreshness); final (arpColor, arpText) = _resolveArp(context, arpFreshness);
final (mdnsColor, mdnsText) = _resolveMdns(context, mdnsFreshness); final (mdnsColor, mdnsText) = _resolveMdns(context, mdnsFreshness);
final (ssdpColor, ssdpText) = _resolveSsdp(context, ssdpFreshness);
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -155,6 +170,15 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
_mdns, _mdns,
mdnsFreshness, mdnsFreshness,
), ),
const SizedBox(height: 8),
_scannerRow(
context,
ssdpColor,
'SSDP/UPnP',
ssdpText,
_ssdp,
ssdpFreshness,
),
], ],
), ),
), ),
@@ -228,7 +252,10 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
return (neutralColor, 'Not yet started'); return (neutralColor, 'Not yet started');
} }
(Color, String) _resolveMdns(BuildContext context, PolledFreshness freshness) { (Color, String) _resolveMdns(
BuildContext context,
PolledFreshness freshness,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success; final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline; final neutralColor = theme.colorScheme.outline;
@@ -249,4 +276,29 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
} }
return (neutralColor, 'Not yet started'); return (neutralColor, 'Not yet started');
} }
(Color, String) _resolveSsdp(
BuildContext context,
PolledFreshness freshness,
) {
final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _ssdp.value!;
final elapsed = DateTime.now()
.difference(_ssdp.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isListening) {
if (status.lastDeviceSeenSecondsAgo != null) {
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
return (successColor, 'Last device seen ${formatSeconds(secs)} ago');
}
return (successColor, 'No devices seen yet');
}
return (neutralColor, 'Not yet started');
}
} }
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../model/ssdp_scanner_status.dart';
import '../theme/app_colors.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import 'scanner_status_card.dart';
class SsdpScannerCard extends StatelessWidget {
const SsdpScannerCard({super.key});
@override
Widget build(BuildContext context) {
return ScannerStatusCard<SsdpScannerStatus>(
title: 'SSDP/UPnP Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken),
resolver: _resolve,
);
}
static ScannerStatus _resolve(
BuildContext context,
SsdpScannerStatus status,
double elapsed,
) {
if (!status.isListening) {
return (
color: Theme.of(context).colorScheme.outline,
label: 'Not yet started',
sublabels: [],
);
}
final sublabels = <String>[
status.listeningForSeconds != null
? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices seen'
: '${status.devicesSeen} devices seen',
if (status.lastDeviceSeenSecondsAgo != null)
'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago',
];
return (
color: Theme.of(context).extension<AppColorExtension>()!.success,
label: 'Listening',
sublabels: sublabels,
);
}
}