Files
flutterApp/lib/features/v2/local_camera/data/onvif_subnet_scanner.dart

225 lines
7.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../domain/entities/onvif_camera.dart';
import 'onvif_client.dart';
/// 网段扫描兜底发现(对标 IP Cam Viewer 的 "Network IP Scanner")。
///
/// 不依赖 WS-Discovery 组播,避免 Android 组播过滤 / USB 以太网组播不确定性:
/// 取本机所在 /24,逐个 IP 直连常见 ONVIF HTTP 端口,命中后用
/// [OnvifClient.getDeviceInformation] 有 SOAP 回包即视为 ONVIF 设备并列出。
///
/// **不做过严校验**:很多"没正规名字"的杂牌摄像头 DeviceInformation 会返回
/// 空 manufacturer/model、或 GetCapabilities(Media) 未实现,但**它就是摄像头本物**——
/// 只要 SOAP 有回包就列入结果;播放环节由 cubit 通过 RTSP 猜测表兜底。
class OnvifSubnetScanner {
/// 常见 ONVIF device_service 端口(海康 80/8000/8080,大华 80/880,杂牌 8899/81 等)。
static const List<int> _candidatePorts = [
80,
8000,
8080,
880,
8081,
8899,
81,
808,
];
/// 常见 device_service 路径。
static const List<String> _servicePaths = [
'/onvif/device_service',
'/onvif',
'/device_service',
];
final OnvifClient client;
/// 单端口 TCP 连通超时(扫描要快,设短)。
final Duration connectTimeout;
/// 一次 ONVIF 校验的超时。
final Duration probeTimeout;
/// 并发扫描的主机数。
final int maxConcurrent;
OnvifSubnetScanner({
required this.client,
this.connectTimeout = const Duration(milliseconds: 350),
this.probeTimeout = const Duration(seconds: 3),
this.maxConcurrent = 40,
});
/// 扫描本机所在的所有 /24 网段,返回 ONVIF 摄像头候选。
Future<List<OnvifCamera>> scan() async {
final localIps = <String>{};
final prefixes = <String>{};
try {
final ifs = await NetworkInterface.list(
type: InternetAddressType.IPv4,
includeLoopback: false,
);
// 摄像头固定走 USB 以太网 → 只扫以太网网卡所在网段(更快、不误捞 WiFi/移动)。
// 认不出以太网命名时,退成“排除 WiFi/移动后的网卡”;仍空则全部(保守兜底)。
var pick = ifs.where((i) => _looksEthernet(i.name)).toList();
if (pick.isEmpty) {
pick = ifs.where((i) => !_looksWifiOrCellular(i.name)).toList();
}
if (pick.isEmpty) pick = ifs;
// localIps 收集全部本机 IP(避免扫到自己),prefixes 只取选中网卡所在网段
for (final i in ifs) {
for (final a in i.addresses) {
localIps.add(a.address.toString());
}
}
for (final i in pick) {
debugPrint('🛰 [网段扫描] 采用网卡 ${i.name}');
for (final a in i.addresses) {
final parts = a.address.toString().split('.');
if (parts.length == 4) {
prefixes.add('${parts[0]}.${parts[1]}.${parts[2]}');
}
}
}
} catch (e) {
debugPrint('🛰 [网段扫描] ❌ 枚举本机网卡失败: $e');
return const [];
}
if (prefixes.isEmpty) {
debugPrint('🛰 [网段扫描] ⚠️ 未识别到本机 IPv4 网段,跳过扫描');
return const [];
}
debugPrint(
'🛰 [网段扫描] 本机 IP=${localIps.join(", ")},将扫描网段 ${prefixes.join(", ")}.*',
);
// 生成候选主机(排除本机自身)
final hosts = <String>[];
for (final p in prefixes) {
for (var h = 1; h <= 254; h++) {
final ip = '$p.$h';
if (!localIps.contains(ip)) hosts.add(ip);
}
}
final results = <String, OnvifCamera>{};
final stopwatch = Stopwatch()..start();
await _runPool(hosts, maxConcurrent, (ip) async {
final cam = await _probeHost(ip);
if (cam != null) {
results[cam.ip] = cam;
debugPrint('🛰 [网段扫描] ✅ 命中 $ip → ${cam.displayName}(${cam.xAddr})');
}
});
stopwatch.stop();
debugPrint(
'🛰 [网段扫描] 结束:扫描 ${hosts.length} 个 IP,用时 ${stopwatch.elapsedMilliseconds}ms,发现 ${results.length} 个 ONVIF 设备',
);
return results.values.toList();
}
/// 对单个 IP:并发探所有候选端口;只要任一 (port,path) 的
/// `GetDeviceInformation` 有 SOAP 回包,立即返回该端点作为候选。
/// **不再要求 Media 校验通过**,杂牌/精简 ONVIF 也能列出。
Future<OnvifCamera?> _probeHost(String ip) async {
final results = await Future.wait(
_candidatePorts.map(
(p) => _isTcpOpen(ip, p).then((ok) => MapEntry(p, ok)),
),
);
// 按 _candidatePorts 优先顺序保留开放端口
final openPorts = [for (final e in results) if (e.value) e.key];
for (final port in openPorts) {
for (final path in _servicePaths) {
final xAddr = 'http://$ip:$port$path';
try {
final info = await client
.getDeviceInformation(xAddr)
.timeout(probeTimeout);
// 只要 SOAP 拿到 DeviceInformation(哪怕字段为空)就认作 ONVIF 设备。
debugPrint(
'🛰 [网段扫描] ✔ $ip:$port$path 应答 DeviceInformation(型号=${info.model.isEmpty ? info.manufacturer : info.model})',
);
return OnvifCamera(
ip: ip,
port: port,
xAddr: xAddr,
manufacturer: info.manufacturer,
model: info.model,
);
} catch (e) {
debugPrint('🛰 [网段扫描] ✖ $ip:$port$path: ${e.runtimeType}');
}
}
}
return null;
}
Future<bool> _isTcpOpen(String ip, int port) async {
Socket? s;
try {
s = await Socket.connect(ip, port, timeout: connectTimeout);
return true;
} catch (_) {
return false;
} finally {
s?.destroy();
}
}
/// 简易有界并发:同时最多 [max] 个任务。
Future<void> _runPool<T>(
List<T> items,
int max,
Future<void> Function(T) worker,
) async {
var index = 0;
Future<void> runner() async {
while (true) {
final i = index++;
if (i >= items.length) return;
await worker(items[i]);
}
}
final workers = <Future<void>>[];
for (var w = 0; w < max && w < items.length; w++) {
workers.add(runner());
}
await Future.wait(workers);
}
static bool _looksEthernet(String raw) {
final n = raw.toLowerCase();
return n.startsWith('eth') ||
n.startsWith('en') ||
n.startsWith('usb') ||
n.contains('rndis') ||
n.contains('asix') ||
n.contains('ax88') ||
n.contains('mtk_eth');
}
static bool _looksWifiOrCellular(String raw) {
final n = raw.toLowerCase();
const blocked = [
'wlan', 'ap', 'rmnet', 'ccmni', 'gbr', 'qrtr', 'mobile',
'vnet', 'dummy', 'lo', 'tun', 'tap', 'ppp', 'p2p', 'wpan',
'bt', 'swlan', 'utun', 'ipsec',
];
for (final p in blocked) {
if (n.startsWith(p)) return true;
}
return false;
}
}