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

281 lines
11 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:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:xml/xml.dart';
/// 轻量 ONVIF SOAP 客户端。默认账号 admin、密码为空(构造函数可覆盖)。
/// 提供 GetDeviceInformation / GetCapabilities(Media XAddr) / GetProfiles / GetStreamUri。
/// 认证采用 WS-Security UsernameToken + PasswordDigest。
class OnvifClient {
final String username;
final String password;
final Random _rnd = Random.secure();
OnvifClient({this.username = 'admin', this.password = ''});
String _uuid() {
final bytes = List<int>.generate(16, (_) => _rnd.nextInt(256));
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return 'urn:uuid:${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
}
/// WS-Security UsernameToken + PasswordDigest。
/// 无用户名则不加安全头(匿名)。密码为空时仍按规范计算摘要(Nonce+Created+"")。
String _wsseHeader() {
if (username.isEmpty) return '';
final nonceBytes = List<int>.generate(16, (_) => _rnd.nextInt(256));
final nonceB64 = base64.encode(nonceBytes);
final created = DateTime.now()
.toUtc()
.toIso8601String()
.replaceAll('.', '')
.replaceAll('+00:00', 'Z');
// digest = Base64(SHA1(Nonce + Created + Password))
final digestInput = <int>[
...nonceBytes,
...utf8.encode(created),
...utf8.encode(password),
];
final passwordDigest = base64.encode(sha1.convert(digestInput).bytes);
return '''<wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<wsse:UsernameToken>
<wsse:Username>$username</wsse:Username>
<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">$passwordDigest</wsse:Password>
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">$nonceB64</wsse:Nonce>
<wsu:Created>$created</wsu:Created>
</wsse:UsernameToken>
</wsse:Security>''';
}
String _envelope({
required String serviceNs,
required String actionTail,
required String bodyInner,
}) {
final action = '$serviceNs/$actionTail';
return '''<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:a="http://www.w3.org/2005/08/addressing">
<s:Header>
<a:Action s:mustUnderstand="1">$action</a:Action>
<a:MessageID>${_uuid()}</a:MessageID>
${_wsseHeader()}
</s:Header>
<s:Body>$bodyInner</s:Body>
</s:Envelope>''';
}
Future<String> _post(String url, String body) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 5);
try {
final req = await client.postUrl(Uri.parse(url));
req.headers.set(
HttpHeaders.contentTypeHeader,
'application/soap+xml; charset=utf-8',
);
req.add(utf8.encode(body));
final resp = await req.close().timeout(const Duration(seconds: 8));
final text = await resp.transform(utf8.decoder).join();
debugPrint(
'📡 [ONVIF] POST $url → ${resp.statusCode} · ${_oneLine(text, 600)}',
);
if (resp.statusCode != 200) {
throw OnvifException('ONVIF 服务返回状态码 ${resp.statusCode}');
}
return text;
} on SocketException {
debugPrint('📡 [ONVIF] POST $url → 无法连接');
throw OnvifException('无法连接摄像头 ONVIF 服务,请检查网络');
} finally {
client.close(force: true);
}
}
static String _oneLine(String s, int max) {
final flat = s.replaceAll(RegExp(r'\s+'), ' ').trim();
return flat.length <= max ? flat : '${flat.substring(0, max)}…';
}
static String _firstText(XmlDocument doc, String name) {
for (final e in doc.findAllElements(name)) {
final t = e.innerText.trim();
if (t.isNotEmpty) return t;
}
return '';
}
/// 取设备厂商/型号信息(用于列表展示)。
Future<({String manufacturer, String model})> getDeviceInformation(
String xAddr,
) async {
final body = _envelope(
serviceNs: 'http://www.onvif.org/ver10/device/wsdl',
actionTail: 'GetDeviceInformation',
bodyInner:
'<tds:GetDeviceInformation xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>',
);
final resp = await _post(xAddr, body);
final doc = XmlDocument.parse(resp);
return (
manufacturer: _firstText(doc, 'Manufacturer'),
model: _firstText(doc, 'Model'),
);
}
/// 获取 Media 服务地址。
Future<String> getMediaXAddr(String deviceXAddr) async {
final body = _envelope(
serviceNs: 'http://www.onvif.org/ver10/device/wsdl',
actionTail: 'GetCapabilities',
bodyInner:
'<tds:GetCapabilities xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Category>Media</tds:Category></tds:GetCapabilities>',
);
final resp = await _post(deviceXAddr, body);
var media = _extractMediaXAddr(resp);
if (media.isEmpty) {
// 兑底:部分摄像头忽略/不支持 Category 参数,不带 Category 再取一次
final allBody = _envelope(
serviceNs: 'http://www.onvif.org/ver10/device/wsdl',
actionTail: 'GetCapabilities',
bodyInner:
'<tds:GetCapabilities xmlns:tds="http://www.onvif.org/ver10/device/wsdl"/>',
);
final allResp = await _post(deviceXAddr, allBody);
media = _extractMediaXAddr(allResp);
}
if (media.isEmpty) {
throw OnvifException(
'未获取到 Media 服务地址(设备可能未实现完整 ONVIF,或 admin/空密码鉴权未通过;详见上方 📡 原始响应日志)',
);
}
return media;
}
/// 从 GetCapabilities 响应中提取 Media 服务地址(优先 Media 节点下的 XAddr)。
String _extractMediaXAddr(String resp) {
final doc = XmlDocument.parse(resp);
for (final media in doc.findAllElements('Media')) {
for (final x in media.findElements('XAddr')) {
final t = x.innerText.trim();
if (t.isNotEmpty) return t;
}
}
// 回退:任意 XAddr(很多设备只有 Media 一个服务地址)
return _firstText(doc, 'XAddr');
}
/// 获取第一个视频 Profile 的 token。
Future<String> getFirstProfileToken(String mediaXAddr) async {
final body = _envelope(
serviceNs: 'http://www.onvif.org/ver10/media/wsdl',
actionTail: 'GetProfiles',
bodyInner:
'<trt:GetProfiles xmlns:trt="http://www.onvif.org/ver10/media/wsdl"/>',
);
final resp = await _post(mediaXAddr, body);
final doc = XmlDocument.parse(resp);
// Profiles 元素的 token 属性即 ProfileToken
for (final p in doc.findAllElements('Profiles')) {
final token = p.getAttribute('token');
if (token != null && token.isNotEmpty) return token;
}
throw OnvifException('未获取到视频 Profile');
}
/// 选择“分辨率最小”的视频 Profile(即子码流)以降低延迟与码率;
/// 摄像头只报一个 Profile 或无分辨率信息时退回第一个。
Future<String> getLowLatencyProfileToken(String mediaXAddr) async {
final body = _envelope(
serviceNs: 'http://www.onvif.org/ver10/media/wsdl',
actionTail: 'GetProfiles',
bodyInner:
'<trt:GetProfiles xmlns:trt="http://www.onvif.org/ver10/media/wsdl"/>',
);
final resp = await _post(mediaXAddr, body);
final doc = XmlDocument.parse(resp);
String? firstToken;
String? bestToken;
int bestArea = -1;
for (final p in doc.findAllElements('Profiles')) {
final token = p.getAttribute('token');
if (token == null || token.isEmpty) continue;
firstToken ??= token;
final w = _firstIntIn(p, 'Width');
final h = _firstIntIn(p, 'Height');
if (w != null && h != null && w > 0 && h > 0) {
final area = w * h;
if (bestArea < 0 || area < bestArea) {
bestArea = area;
bestToken = token;
}
}
}
if (bestToken != null) {
debugPrint('🎚 [子码流] 选用最小分辨率 Profile(面积≈$bestArea)token=$bestToken');
return bestToken;
}
if (firstToken != null) return firstToken;
throw OnvifException('未获取到视频 Profile');
}
/// 在 [parent] 及其后代中找第一个可解析为 int 的 [name] 元素值。
int? _firstIntIn(XmlElement parent, String name) {
for (final e in parent.findAllElements(name)) {
final v = int.tryParse(e.innerText.trim());
if (v != null) return v;
}
return null;
}
/// 获取 RTSP 地址(已注入访问凭证)。
Future<String> getStreamUri(String mediaXAddr, String profileToken) async {
final body = _envelope(
serviceNs: 'http://www.onvif.org/ver10/media/wsdl',
actionTail: 'GetStreamUri',
bodyInner:
'''<trt:GetStreamUri xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
<trt:StreamSetup><tt:Stream xmlns:tt="http://www.onvif.org/ver10/schema">RTP-Unicast</tt:Stream>
<tt:Transport xmlns:tt="http://www.onvif.org/ver10/schema"><tt:Protocol>RTSP</tt:Protocol></tt:Transport></trt:StreamSetup>
<trt:ProfileToken>$profileToken</trt:ProfileToken>
</trt:GetStreamUri>''',
);
final resp = await _post(mediaXAddr, body);
final doc = XmlDocument.parse(resp);
final uri = _firstText(doc, 'Uri');
if (uri.isEmpty) throw OnvifException('未获取到 RTSP 地址');
return _injectCredentials(uri);
}
/// 一步到位:由 device xAddr 得到带凭证的 RTSP 地址。
Future<String> resolveRtsp(String deviceXAddr) async {
final mediaXAddr = await getMediaXAddr(deviceXAddr);
final token = await getLowLatencyProfileToken(mediaXAddr);
return getStreamUri(mediaXAddr, token);
}
/// rtsp://ip/path -> rtsp://admin:pass@ip/path。
/// 关键:即便密码为空也必须保留冒号(拼成 'admin:@'),因为 video_player 的
/// Android 后端 ExoPlayer(Media3) 是用 Uri.getUserInfo().split(':') 解析账号/密码,
/// 写成 'admin@'(缺冒号)会解析不到密码字段导致 RTSP 鉴权失败。
String _injectCredentials(String rtsp) {
if (username.isEmpty) return rtsp;
final uri = Uri.parse(rtsp);
if (uri.userInfo.isNotEmpty) return rtsp; // 已带凭证
final cred = '$username:$password'; // 始终带冒号:空密码→'admin:'
return uri.replace(userInfo: cred).toString();
}
}
class OnvifException implements Exception {
final String message;
OnvifException(this.message);
@override
String toString() => message;
}