225 lines
7.0 KiB
Dart
225 lines
7.0 KiB
Dart
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;
|
||
}
|
||
}
|