304 lines
12 KiB
Dart
304 lines
12 KiB
Dart
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;
|
||
}
|