From 440959b10d92b651df33a6e4153b45c2ee846a32 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Mon, 1 Jun 2026 18:13:33 -0400 Subject: [PATCH] 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 --- frontend/lib/model/device_event.dart | 1 + frontend/lib/model/ssdp_scanner_status.dart | 23 ++++++++ frontend/lib/status/status_screen.dart | 3 + frontend/lib/utils/oott_api.dart | 11 ++++ .../lib/widgets/scanners_status_card.dart | 56 ++++++++++++++++++- frontend/lib/widgets/ssdp_scanner_card.dart | 47 ++++++++++++++++ 6 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 frontend/lib/model/ssdp_scanner_status.dart create mode 100644 frontend/lib/widgets/ssdp_scanner_card.dart diff --git a/frontend/lib/model/device_event.dart b/frontend/lib/model/device_event.dart index 8ebf826..b19043b 100644 --- a/frontend/lib/model/device_event.dart +++ b/frontend/lib/model/device_event.dart @@ -32,6 +32,7 @@ class DeviceEvent { String get scannerLabel => switch (scanner) { 'Arp' => 'ARP', 'Mdns' => 'mDNS', + 'Ssdp' => 'SSDP/UPnP', _ => scanner, }; } diff --git a/frontend/lib/model/ssdp_scanner_status.dart b/frontend/lib/model/ssdp_scanner_status.dart new file mode 100644 index 0000000..eb55f0f --- /dev/null +++ b/frontend/lib/model/ssdp_scanner_status.dart @@ -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 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(), + ); + } +} diff --git a/frontend/lib/status/status_screen.dart b/frontend/lib/status/status_screen.dart index dd717f3..787fa9a 100644 --- a/frontend/lib/status/status_screen.dart +++ b/frontend/lib/status/status_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../widgets/arp_scanner_card.dart'; import '../widgets/mdns_scanner_card.dart'; +import '../widgets/ssdp_scanner_card.dart'; class StatusScreen extends StatelessWidget { const StatusScreen({super.key}); @@ -16,6 +17,8 @@ class StatusScreen extends StatelessWidget { const ArpScannerCard(), const SizedBox(height: 8), const MdnsScannerCard(), + const SizedBox(height: 8), + const SsdpScannerCard(), ], ); } diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index ce3ace5..20e9b3d 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -8,6 +8,7 @@ import 'package:frontend/utils/backend_reachability.dart'; import 'package:frontend/utils/pref_utils.dart'; import '../model/arp_scanner_status.dart'; import '../model/mdns_scanner_status.dart'; +import '../model/ssdp_scanner_status.dart'; import '../model/device.dart'; import '../model/device_event.dart'; import '../model/device_summary.dart'; @@ -311,6 +312,16 @@ class BackendAPI { return MdnsScannerStatus.fromJson(response.data as Map); } + Future getSsdpScannerStatus({ + CancelToken? cancelToken, + }) async { + final response = await _dio.get( + '/ssdp_scanner/status', + cancelToken: cancelToken, + ); + return SsdpScannerStatus.fromJson(response.data as Map); + } + Future<({List items, bool hasNextPage})> listNotifications( bool? isNew, { int page = 0, diff --git a/frontend/lib/widgets/scanners_status_card.dart b/frontend/lib/widgets/scanners_status_card.dart index c258404..2a2f0c0 100644 --- a/frontend/lib/widgets/scanners_status_card.dart +++ b/frontend/lib/widgets/scanners_status_card.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../model/arp_scanner_status.dart'; import '../model/mdns_scanner_status.dart'; +import '../model/ssdp_scanner_status.dart'; import '../navigation.dart'; import '../theme/app_colors.dart'; import '../utils/backend_reachability.dart'; @@ -24,6 +25,7 @@ class _ScannersStatusCardState extends State with RouteAware, WidgetsBindingObserver { late final PolledValue _arp; late final PolledValue _mdns; + late final PolledValue _ssdp; Timer? _tickTimer; @override @@ -42,6 +44,12 @@ class _ScannersStatusCardState extends State pollInterval: const Duration(seconds: 5), staleErrorAfter: const Duration(seconds: 30), ); + _ssdp = PolledValue( + fetch: ({cancelToken}) => + BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken), + pollInterval: const Duration(seconds: 5), + staleErrorAfter: const Duration(seconds: 30), + ); _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); @@ -78,6 +86,7 @@ class _ScannersStatusCardState extends State void _pausePolling() { _arp.pause(); _mdns.pause(); + _ssdp.pause(); _tickTimer?.cancel(); _tickTimer = null; } @@ -85,6 +94,7 @@ class _ScannersStatusCardState extends State void _resumePolling() { _arp.resume(); _mdns.resume(); + _ssdp.resume(); _tickTimer ??= Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); @@ -97,6 +107,7 @@ class _ScannersStatusCardState extends State _tickTimer?.cancel(); _arp.dispose(); _mdns.dispose(); + _ssdp.dispose(); super.dispose(); } @@ -106,13 +117,16 @@ class _ScannersStatusCardState extends State listenable: Listenable.merge([ _arp, _mdns, + _ssdp, BackendReachability.instance, ]), builder: (context, _) { final arpFreshness = effectiveFreshness(_arp); final mdnsFreshness = effectiveFreshness(_mdns); + final ssdpFreshness = effectiveFreshness(_ssdp); if (arpFreshness == PolledFreshness.initialLoading || - mdnsFreshness == PolledFreshness.initialLoading) { + mdnsFreshness == PolledFreshness.initialLoading || + ssdpFreshness == PolledFreshness.initialLoading) { return const Card( child: Padding( padding: EdgeInsets.all(16), @@ -123,6 +137,7 @@ class _ScannersStatusCardState extends State final (arpColor, arpText) = _resolveArp(context, arpFreshness); final (mdnsColor, mdnsText) = _resolveMdns(context, mdnsFreshness); + final (ssdpColor, ssdpText) = _resolveSsdp(context, ssdpFreshness); return Card( clipBehavior: Clip.antiAlias, @@ -155,6 +170,15 @@ class _ScannersStatusCardState extends State _mdns, mdnsFreshness, ), + const SizedBox(height: 8), + _scannerRow( + context, + ssdpColor, + 'SSDP/UPnP', + ssdpText, + _ssdp, + ssdpFreshness, + ), ], ), ), @@ -228,7 +252,10 @@ class _ScannersStatusCardState extends State 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 successColor = theme.extension()!.success; final neutralColor = theme.colorScheme.outline; @@ -249,4 +276,29 @@ class _ScannersStatusCardState extends State } return (neutralColor, 'Not yet started'); } + + (Color, String) _resolveSsdp( + BuildContext context, + PolledFreshness freshness, + ) { + final theme = Theme.of(context); + final successColor = theme.extension()!.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'); + } } diff --git a/frontend/lib/widgets/ssdp_scanner_card.dart b/frontend/lib/widgets/ssdp_scanner_card.dart new file mode 100644 index 0000000..a9ec432 --- /dev/null +++ b/frontend/lib/widgets/ssdp_scanner_card.dart @@ -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( + 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 = [ + 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()!.success, + label: 'Listening', + sublabels: sublabels, + ); + } +}