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

304 lines
12 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';
import 'rtsp_playback_tuning.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');
}
/// 按 [select] 挑一个视频 Profile,返回 `(token, 声明码率kbps)`。
///
/// 码率一并带出来是给【链路探针】当分母用的:声明码率是“应该收到多少”,
/// 实测进给率是“实际到了多少”,两者一比就能把“图传不够”和“内容坏了”分开。
/// 设备不报 Bitrate(杂牌机常见)时给 null,探针退成只看比例。
///
/// 只报一个 Profile、或压根没报分辨率时,退回第一个(因“选不出”而放弃拉流)。
Future<(String, int?)> getProfileToken(
String mediaXAddr,
RtspProfileSelect select,
) 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;
int? firstKbps;
String? bestToken;
int? bestKbps;
int bestArea = -1;
final listed = <String>[];
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');
final bitrateKbps = _firstIntIn(p, 'Bitrate');
if (firstToken == token) firstKbps = bitrateKbps;
listed.add(
'$token ${w ?? '?'}x${h ?? '?'}'
'${bitrateKbps != null ? ' ${bitrateKbps}kbps' : ''}',
);
if (w == null || h == null || w <= 0 || h <= 0) continue;
if (select == RtspProfileSelect.first) continue;
final area = w * h;
final better = select == RtspProfileSelect.smallest
? (bestArea < 0 || area < bestArea)
: (bestArea < 0 || area > bestArea);
if (better) {
bestArea = area;
bestToken = token;
bestKbps = bitrateKbps;
}
}
if (listed.isNotEmpty) {
debugPrint('🎚 [码流] ${select.label};候选:${listed.join(' | ')}');
}
if (firstToken == null) throw OnvifException('未获取到视频 Profile');
// 选不出合适的那一路(设备只报一个 / 没报分辨率 / 选了“设备首个”)→ 退回第一个。
final String picked;
final int? kbps;
if (select == RtspProfileSelect.first || bestToken == null) {
picked = firstToken;
kbps = firstKbps;
} else {
picked = bestToken;
kbps = bestKbps;
}
debugPrint(
'🎚 [码流] 最终使用 token=$picked(面积≈$bestArea'
'${kbps != null ? ', 声明 ${kbps}kbps' : ', 设备未报码率'})',
);
return (picked, kbps);
}
/// 在 [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 地址与声明码率。
/// [profileSelect] 由调参面板控制:取子码流还是主码流。
Future<(String, int?)> resolveRtsp(
String deviceXAddr, {
RtspProfileSelect profileSelect = RtspProfileSelect.smallest,
}) async {
final mediaXAddr = await getMediaXAddr(deviceXAddr);
final (token, bitrateKbps) = await getProfileToken(mediaXAddr, profileSelect);
final uri = await getStreamUri(mediaXAddr, token);
return (uri, bitrateKbps);
}
/// 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;
}