mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.8
parent
e8159f2b40
commit
440959b10d
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user