Files
2026-07-13 13:13:17 +08:00

1069 lines
47 KiB
C#

using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading;
using System.Xml.Linq;
using OpenCvSharp;
using SharpDX;
using T3.Core.Animation;
using Utilities = T3.Core.Utils.Utilities;
namespace Lib.io.ptz
{
[Guid("8b23c93b-3b45-4c9b-9c23-4d5e6f7a8b9c")]
public class OnvifCamera : Instance<OnvifCamera>, IStatusProvider, ICustomDropdownHolder
{
[Output(Guid = "e5d0c2f1-4b3a-4829-9c5d-1e6f7a8b9c0d", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
public readonly Slot<Texture2D> Texture = new();
[Output(Guid = "9c34d04c-4c56-4d0c-ad34-5e6f7a8b9c0d")]
public readonly Slot<bool> IsConnected = new();
[Output(Guid = "0d45e15d-5d67-4e1d-be45-6f7a8b9c0d1e", DirtyFlagTrigger = DirtyFlagTrigger.Animated)]
public readonly Slot<Vector3> CurrentPtz = new();
public OnvifCamera()
{
IsConnected.UpdateAction += Update;
CurrentPtz.UpdateAction += Update;
Texture.UpdateAction += Update;
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
_httpClient.DefaultRequestHeaders.ExpectContinue = false;
_disposeCts = new CancellationTokenSource();
}
private void Update(EvaluationContext context)
{
_printToLog = PrintToLog.GetValue(context);
// --- Network Interface Management ---
var localIpString = LocalIpAddress.GetValue(context);
if (string.IsNullOrEmpty(localIpString))
{
if (Playback.RunTimeInSecs - _lastNetworkRefreshTime > 5.0)
{
_lastNetworkRefreshTime = Playback.RunTimeInSecs;
_networkInterfaces = GetNetworkInterfaces();
}
}
var address = Address.GetValue(context);
var username = Username.GetValue(context);
var password = Password.GetValue(context);
var shouldConnect = Connect.GetValue(context);
// PTZ Inputs
var targetPan = Pan.GetValue(context);
var targetTilt = Tilt.GetValue(context);
var targetZoom = Zoom.GetValue(context);
var doMove = Move.GetValue(context);
var discover = Discover.GetValue(context);
if (discover != _isDiscovering)
{
if (discover) StartDiscovery(localIpString);
else StopDiscovery();
}
if (shouldConnect != _lastConnectState)
{
if (shouldConnect)
{
if (_printToLog) Log.Debug($"OnvifCamera: Connecting to {address}...", this);
StartCaptureThread(address, username, password);
}
else
{
if (_printToLog) Log.Debug("OnvifCamera: Disconnecting...", this);
StopCaptureThread();
}
_lastConnectState = shouldConnect;
}
// --- Smoothing ---
_dampedPan = targetPan;
_dampedTilt = targetTilt;
_dampedZoom = targetZoom;
// --- PTZ Control ---
if (_connected && doMove)
{
var valuesChanged = Math.Abs(_dampedPan - _lastSentPan) > 0.001f ||
Math.Abs(_dampedTilt - _lastSentTilt) > 0.001f ||
Math.Abs(_dampedZoom - _lastSentZoom) > 0.001f;
var isRisingEdge = !_wasMoving;
// Limit update rate to avoid flooding the camera (e.g. max 10 times per second)
var isThrottled = (Playback.RunTimeInSecs - _lastPtzMoveTime) < 0.1;
if (isRisingEdge || (valuesChanged && !isThrottled))
{
if (!string.IsNullOrEmpty(_ptzServiceUrl) && !string.IsNullOrEmpty(_profileToken) && _ptzSupported)
{
if (!_isSendingPtz)
{
_lastSentPan = _dampedPan;
_lastSentTilt = _dampedTilt;
_lastSentZoom = _dampedZoom;
_lastPtzMoveTime = Playback.RunTimeInSecs;
_isSendingPtz = true;
var p = _dampedPan;
var t = _dampedTilt;
var z = _dampedZoom;
// Fire and forget to avoid blocking UI
Task.Run(async () =>
{
try
{
await SendPtzMoveAsync(p, t, z, username, password, _disposeCts.Token);
}
finally
{
_isSendingPtz = false;
}
});
}
}
else
{
// If we are connected via RTSP but haven't discovered PTZ services yet
if (isRisingEdge && _printToLog) Log.Warning("OnvifCamera: Cannot move. PTZ Service or Profile Token not resolved, or PTZ not supported.", this);
}
}
}
_wasMoving = doMove;
// Update outputs
IsConnected.Value = _connected;
// --- PTZ Status Polling ---
if (_connected && !string.IsNullOrEmpty(_ptzServiceUrl) && !string.IsNullOrEmpty(_profileToken) && _ptzSupported)
{
var pollInterval = _ptzErrorBackoff ? 2.0 : 0.1;
if (Playback.RunTimeInSecs - _lastPtzPollTime > pollInterval && !_isPollingPtz)
{
_lastPtzPollTime = Playback.RunTimeInSecs;
_isPollingPtz = true;
Task.Run(async () =>
{
try
{
await UpdatePtzStatusAsync(username, password, _disposeCts.Token);
}
finally
{
_isPollingPtz = false;
}
});
}
// Use the polled value
if (doMove)
{
CurrentPtz.Value = new Vector3(targetPan, targetTilt, targetZoom);
}
else
{
lock (_lockObject)
{
CurrentPtz.Value = _currentPtzValue;
}
}
}
else
{
// Fallback to target values if not connected or no PTZ
CurrentPtz.Value = new Vector3(targetPan, targetTilt, targetZoom);
}
// --- Texture Update ---
lock (_lockObject)
{
if (_sharedBgraMat != null && !_sharedBgraMat.Empty())
{
UploadMatToGpu(_sharedBgraMat);
Texture.Value = _gpuTexture;
}
else
{
Texture.Value = null;
}
}
}
protected override void Dispose(bool isDisposing)
{
if (!isDisposing)
return;
_disposeCts.Cancel();
StopDiscovery();
StopCaptureThread();
Utilities.Dispose(ref _gpuTexture);
lock (_lockObject)
{
_sharedBgraMat?.Dispose();
_sharedBgraMat = null;
}
_httpClient?.Dispose();
_disposeCts.Dispose();
}
#region Video Capture Logic
private void StartCaptureThread(string address, string username, string password)
{
if (_captureThread != null) return;
if (_printToLog) Log.Debug("OnvifCamera: Starting capture thread.", this);
_ptzSupported = true;
_ptzErrorBackoff = false;
_cancellationTokenSource = new CancellationTokenSource();
var token = _cancellationTokenSource.Token;
_captureThread = new Thread(() => CaptureLoop(address, username, password, token))
{
IsBackground = true,
Name = "OnvifCaptureThread"
};
_captureThread.Start();
}
private void StopCaptureThread()
{
if (_captureThread == null) return;
if (_printToLog) Log.Debug("OnvifCamera: Stopping capture thread.", this);
_cancellationTokenSource?.Cancel();
if (!_captureThread.Join(TimeSpan.FromSeconds(2)))
{
Log.Warning("OnvifCamera: Capture thread did not finish in time. Leaking VideoCapture to avoid crash.", this);
// Do not dispose _capture, as the thread is still using it.
}
else
{
_capture?.Dispose();
_capture = null;
}
_captureThread = null;
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = null;
_connected = false;
SetStatus("Disconnected");
}
private void CaptureLoop(string address, string username, string password, CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
SetStatus($"Connecting to {address}...");
if (_printToLog) Log.Debug($"OnvifCamera: Attempting connection to {address}", this);
string streamUri = address?.Trim() ?? string.Empty;
string host = GetConnectionAuthority(streamUri);
if (!string.IsNullOrEmpty(host))
{
SetStatus($"Probing ONVIF at {host}...");
var discoveredUri = DiscoverOnvifServices(host, username, password, token);
if (!string.IsNullOrEmpty(discoveredUri) && !streamUri.Contains("://"))
{
streamUri = discoveredUri;
if (_printToLog) Log.Debug($"OnvifCamera: Discovered Stream URI: {streamUri}", this);
}
}
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
{
if (streamUri.StartsWith("rtsp://", StringComparison.OrdinalIgnoreCase) && !streamUri.Contains('@'))
{
var uriWithoutScheme = streamUri.Substring(7);
streamUri = $"rtsp://{username}:{password}@{uriWithoutScheme}";
}
}
if (!streamUri.Contains("://"))
{
if (_printToLog) Log.Warning("OnvifCamera: Invalid RTSP URL. Retrying in 5s...", this);
SetStatus("Invalid URL. Retrying...");
if (WaitCancelled(5000, token)) return;
continue;
}
if (token.IsCancellationRequested) return;
_capture = new VideoCapture(streamUri, VideoCaptureAPIs.FFMPEG);
if (token.IsCancellationRequested || !_capture.IsOpened())
{
var msg = $"Error: Failed to open stream. Retrying in 5s...";
SetStatus(msg);
if (_printToLog) Log.Warning(msg, this);
_capture?.Dispose();
_capture = null;
if (WaitCancelled(5000, token)) return;
continue;
}
_connected = true;
SetStatus("Streaming");
if (_printToLog) Log.Debug("OnvifCamera: Stream opened.", this);
using var frame = new Mat();
int errorCount = 0;
while (!token.IsCancellationRequested)
{
if (_capture.Read(frame) && !frame.Empty())
{
errorCount = 0;
lock (_lockObject)
{
if (token.IsCancellationRequested) break;
if (_sharedBgraMat == null || _sharedBgraMat.IsDisposed || _sharedBgraMat.Width != frame.Width || _sharedBgraMat.Height != frame.Height)
{
_sharedBgraMat?.Dispose();
_sharedBgraMat = new Mat(frame.Rows, frame.Cols, MatType.CV_8UC4);
}
Cv2.CvtColor(frame, _sharedBgraMat, ColorConversionCodes.BGR2BGRA);
}
}
else
{
// Frame read failed
errorCount++;
if (errorCount > 10) // Allow a few dropped frames before reconnecting
{
Log.Warning("OnvifCamera: Stream lost. Reconnecting...", this);
break;
}
Thread.Sleep(10);
}
}
}
catch (Exception e)
{
if (!token.IsCancellationRequested)
{
Log.Error($"OnvifCamera stream error: {e.Message}", this);
SetStatus($"Error: {e.Message}");
}
}
finally
{
_connected = false;
_capture?.Dispose();
_capture = null;
}
if (!token.IsCancellationRequested)
{
// Wait before reconnecting
if (WaitCancelled(2000, token)) return;
}
}
SetStatus("Disconnected");
}
private bool WaitCancelled(int ms, CancellationToken token)
{
try { Task.Delay(ms, token).Wait(token); return false; }
catch { return true; }
}
private void UploadMatToGpu(Mat mat)
{
if (mat.Empty()) return;
var width = mat.Width;
var height = mat.Height;
if (_gpuTexture == null || _gpuTexture.Description.Width != width || _gpuTexture.Description.Height != height)
{
Utilities.Dispose(ref _gpuTexture);
var texDesc = new Texture2DDescription
{
Width = width,
Height = height,
MipLevels = 1,
ArraySize = 1,
Format = Format.B8G8R8A8_UNorm,
SampleDescription = new SampleDescription(1, 0),
Usage = ResourceUsage.Default,
BindFlags = BindFlags.ShaderResource,
CpuAccessFlags = CpuAccessFlags.None,
OptionFlags = ResourceOptionFlags.None
};
_gpuTexture = Texture2D.CreateTexture2D(texDesc);
}
var dataBox = new DataBox(mat.Data, (int)mat.Step(), 0);
ResourceManager.Device.ImmediateContext.UpdateSubresource(dataBox, _gpuTexture);
}
private string GetConnectionAuthority(string address)
{
if (string.IsNullOrEmpty(address)) return null;
if (address.Contains("://"))
{
// If it's a URL (e.g. RTSP), extract just the Host (IP), ignoring the RTSP port (554)
// because we want to connect to the ONVIF HTTP service (usually 80/8080).
try { return new Uri(address).Host; } catch { return null; }
}
// If it's just an address (IP or IP:Port), return as is to preserve non-standard ONVIF ports.
return address;
}
private string DiscoverOnvifServices(string host, string username, string password, CancellationToken token)
{
try
{
// 0. Sync Time (to avoid 401/500 on auth)
SyncTime(host, token);
// 1. GetCapabilities to find Media, PTZ, and Imaging Services
var deviceServiceUrl = $"http://{host}/onvif/device_service";
var (mediaUrl, ptzUrl) = GetOnvifCapabilities(deviceServiceUrl, username, password, true, token);
if (string.IsNullOrEmpty(mediaUrl))
{
// Fallback: Try without auth (some cameras don't like auth on GetCapabilities)
if (_printToLog) Log.Debug($"OnvifCamera: GetCapabilities failed with auth. Retrying without auth...", this);
(mediaUrl, ptzUrl) = GetOnvifCapabilities(deviceServiceUrl, null, null, false, token);
}
if (string.IsNullOrEmpty(mediaUrl))
{
if (_printToLog) Log.Debug($"OnvifCamera: Could not find Media Service at {deviceServiceUrl}", this);
return null;
}
_ptzServiceUrl = ptzUrl;
if (_printToLog && !string.IsNullOrEmpty(_ptzServiceUrl)) Log.Debug($"OnvifCamera: Found PTZ Service at {_ptzServiceUrl}", this);
// 2. GetProfiles to find a profile token and video source token
_profileToken = GetProfileToken(mediaUrl, username, password, token);
if (string.IsNullOrEmpty(_profileToken))
{
if (_printToLog) Log.Debug("OnvifCamera: Could not find any Media Profile", this);
return null;
}
// 4. GetStreamUri
return GetOnvifStreamUri(mediaUrl, _profileToken, username, password, token);
}
catch (Exception e)
{
if (_printToLog) Log.Debug($"OnvifCamera: Discovery exception: {e.Message}", this);
return null;
}
}
private (string mediaUrl, string ptzUrl) GetOnvifCapabilities(string serviceUrl, string username, string password, bool useAuth, CancellationToken token)
{
var body = @"<GetCapabilities xmlns=""http://www.onvif.org/ver10/device/wsdl""><Category>All</Category></GetCapabilities>";
var response = SendSoapRequestAsync(serviceUrl, body, useAuth ? username : null, useAuth ? password : null, "http://www.onvif.org/ver10/device/wsdl/GetCapabilities", token).GetAwaiter().GetResult();
string mediaUrl = null;
string ptzUrl = null;
if (response != null)
{
try
{
var xdoc = XDocument.Parse(response);
var ns = XNamespace.Get("http://www.onvif.org/ver10/schema");
mediaUrl = xdoc.Descendants(ns + "Media").FirstOrDefault()?.Element(ns + "XAddr")?.Value
?? xdoc.Descendants(ns + "Media").FirstOrDefault()?.Element("XAddr")?.Value; // Try without namespace if failed
ptzUrl = xdoc.Descendants(ns + "PTZ").FirstOrDefault()?.Element(ns + "XAddr")?.Value
?? xdoc.Descendants(ns + "PTZ").FirstOrDefault()?.Element("XAddr")?.Value;
}
catch (Exception e)
{
if (_printToLog) Log.Debug($"OnvifCamera: Error parsing capabilities: {e.Message}", this);
}
}
// Fallback: If Media not found (e.g. 'All' failed or didn't return it), try requesting it specifically
if (string.IsNullOrEmpty(mediaUrl))
{
var bodyMedia = @"<GetCapabilities xmlns=""http://www.onvif.org/ver10/device/wsdl""><Category>Media</Category></GetCapabilities>";
var responseMedia = SendSoapRequestAsync(serviceUrl, bodyMedia, useAuth ? username : null, useAuth ? password : null, "http://www.onvif.org/ver10/device/wsdl/GetCapabilities", token).GetAwaiter().GetResult();
if (!string.IsNullOrEmpty(responseMedia))
{
try
{
var xdoc = XDocument.Parse(responseMedia);
var ns = XNamespace.Get("http://www.onvif.org/ver10/schema");
mediaUrl = xdoc.Descendants(ns + "Media").FirstOrDefault()?.Element(ns + "XAddr")?.Value
?? xdoc.Descendants(ns + "Media").FirstOrDefault()?.Element("XAddr")?.Value;
}
catch
{
// ignored
}
}
}
// Fallback: If PTZ not found
if (string.IsNullOrEmpty(ptzUrl))
{
var bodyPtz = @"<GetCapabilities xmlns=""http://www.onvif.org/ver10/device/wsdl""><Category>PTZ</Category></GetCapabilities>";
var responsePtz = SendSoapRequestAsync(serviceUrl, bodyPtz, useAuth ? username : null, useAuth ? password : null, "http://www.onvif.org/ver10/device/wsdl/GetCapabilities", token).GetAwaiter().GetResult();
if (!string.IsNullOrEmpty(responsePtz))
{
try
{
var xdoc = XDocument.Parse(responsePtz);
var ns = XNamespace.Get("http://www.onvif.org/ver10/schema");
ptzUrl = xdoc.Descendants(ns + "PTZ").FirstOrDefault()?.Element(ns + "XAddr")?.Value
?? xdoc.Descendants(ns + "PTZ").FirstOrDefault()?.Element("XAddr")?.Value;
}
catch { }
}
}
return (mediaUrl, ptzUrl);
}
private string GetProfileToken(string serviceUrl, string username, string password, CancellationToken token)
{
var body = @"<GetProfiles xmlns=""http://www.onvif.org/ver10/media/wsdl""/>";
var response = SendSoapRequestAsync(serviceUrl, body, username, password, "http://www.onvif.org/ver10/media/wsdl/GetProfiles", token).GetAwaiter().GetResult();
if (response == null) return null;
var xdoc = XDocument.Parse(response);
var ns = XNamespace.Get("http://www.onvif.org/ver10/media/wsdl");
var profile = xdoc.Descendants(ns + "Profiles").FirstOrDefault();
if (profile == null) return null;
return profile.Attribute("token")?.Value;
}
private string GetOnvifStreamUri(string serviceUrl, string profileToken, string username, string password, CancellationToken token)
{
var body = $@"<GetStreamUri xmlns=""http://www.onvif.org/ver10/media/wsdl""><StreamSetup><Stream xmlns=""http://www.onvif.org/ver10/schema"">RTP-Unicast</Stream><Transport xmlns=""http://www.onvif.org/ver10/schema""><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>{profileToken}</ProfileToken></GetStreamUri>";
var response = SendSoapRequestAsync(serviceUrl, body, username, password, "http://www.onvif.org/ver10/media/wsdl/GetStreamUri", token).GetAwaiter().GetResult();
if (response == null) return null;
var xdoc = XDocument.Parse(response);
var ns = XNamespace.Get("http://www.onvif.org/ver10/media/wsdl");
var nsSch = XNamespace.Get("http://www.onvif.org/ver10/schema");
return xdoc.Descendants(ns + "MediaUri").FirstOrDefault()?.Element(nsSch + "Uri")?.Value;
}
private async Task SendPtzMoveAsync(float pan, float tilt, float zoom, string username, string password, CancellationToken token)
{
if (!_ptzSupported) return;
try
{
// Clamp values to -1..1 for Pan/Tilt and 0..1 for Zoom (ONVIF standard generic space)
// Note: Some cameras might use different ranges, but GenericSpace is usually normalized.
var p = Math.Clamp(pan, -1f, 1f);
var t = Math.Clamp(tilt, -1f, 1f);
var z = Math.Clamp(zoom, 0f, 1f);
var body = $@"<AbsoluteMove xmlns=""http://www.onvif.org/ver20/ptz/wsdl"">
<ProfileToken>{_profileToken}</ProfileToken>
<Position>
<PanTilt x=""{p}"" y=""{t}"" space=""http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace"" xmlns=""http://www.onvif.org/ver10/schema""/>
<Zoom x=""{z}"" space=""http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace"" xmlns=""http://www.onvif.org/ver10/schema""/>
</Position>
</AbsoluteMove>";
var response = await SendSoapRequestAsync(_ptzServiceUrl, body, username, password, "http://www.onvif.org/ver20/ptz/wsdl/AbsoluteMove", token);
if (response != null && (response.Contains("PTZNotSupported") || response.Contains("ActionNotSupported")))
{
if (_printToLog) Log.Warning("OnvifCamera: PTZ not supported by device (Move). Disabling PTZ.", this);
_ptzSupported = false;
}
}
catch (Exception e)
{
if (_printToLog) Log.Warning($"OnvifCamera: PTZ Move failed: {e.Message}", this);
}
}
private async Task UpdatePtzStatusAsync(string username, string password, CancellationToken token)
{
try
{
var body = $@"<GetStatus xmlns=""http://www.onvif.org/ver20/ptz/wsdl""><ProfileToken>{_profileToken}</ProfileToken></GetStatus>";
var response = await SendSoapRequestAsync(_ptzServiceUrl, body, username, password, "http://www.onvif.org/ver20/ptz/wsdl/GetStatus", token);
if (string.IsNullOrEmpty(response))
{
_ptzErrorBackoff = true;
return;
}
if (response.Contains("PTZNotSupported") || response.Contains("ActionNotSupported"))
{
if (_printToLog) Log.Warning("OnvifCamera: PTZ not supported by device. Disabling polling.", this);
_ptzSupported = false;
return;
}
_ptzErrorBackoff = false;
var xdoc = XDocument.Parse(response);
var ns = XNamespace.Get("http://www.onvif.org/ver10/schema");
var position = xdoc.Descendants(ns + "Position").FirstOrDefault();
if (position != null)
{
var panTilt = position.Element(ns + "PanTilt");
var zoom = position.Element(ns + "Zoom");
float p = 0, t = 0, z = 0;
if (panTilt != null)
{
float.TryParse(panTilt.Attribute("x")?.Value, out p);
float.TryParse(panTilt.Attribute("y")?.Value, out t);
}
if (zoom != null)
{
float.TryParse(zoom.Attribute("x")?.Value, out z);
}
lock (_lockObject)
{
_currentPtzValue = new Vector3(p, t, z);
}
}
}
catch
{
_ptzErrorBackoff = true;
}
}
private void SyncTime(string host, CancellationToken token)
{
try
{
var url = $"http://{host}/onvif/device_service";
var body = @"<GetSystemDateAndTime xmlns=""http://www.onvif.org/ver10/device/wsdl""/>";
// Send without auth first to get time
var response = SendSoapRequestAsync(url, body, null, null, null, token).GetAwaiter().GetResult();
if (string.IsNullOrEmpty(response)) return;
var xdoc = XDocument.Parse(response);
var ns = XNamespace.Get("http://www.onvif.org/ver10/schema");
var utc = xdoc.Descendants(ns + "UTCDateTime").FirstOrDefault();
if (utc != null)
{
var time = utc.Element(ns + "Time");
var date = utc.Element(ns + "Date");
if (time != null && date != null)
{
int y = int.Parse(date.Element(ns + "Year")?.Value ?? "0");
int m = int.Parse(date.Element(ns + "Month")?.Value ?? "0");
int d = int.Parse(date.Element(ns + "Day")?.Value ?? "0");
int h = int.Parse(time.Element(ns + "Hour")?.Value ?? "0");
int min = int.Parse(time.Element(ns + "Minute")?.Value ?? "0");
int s = int.Parse(time.Element(ns + "Second")?.Value ?? "0");
var deviceTime = new DateTime(y, m, d, h, min, s, DateTimeKind.Utc);
_timeOffset = deviceTime - DateTime.UtcNow;
if (_printToLog) Log.Debug($"OnvifCamera: Time synced. Offset: {_timeOffset.TotalSeconds:F1}s", this);
}
}
}
catch (Exception e)
{
if (_printToLog) Log.Debug($"OnvifCamera: Time sync failed: {e.Message}", this);
}
}
private async Task<string> SendSoapRequestAsync(string url, string body, string username, string password, string action, CancellationToken token)
{
var header = "";
if (!string.IsNullOrEmpty(username))
{
var created = (DateTime.UtcNow + _timeOffset).ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
var nonce = new byte[16];
using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(nonce);
var nonceBase64 = Convert.ToBase64String(nonce);
var createdBytes = Encoding.UTF8.GetBytes(created);
var passwordBytes = Encoding.UTF8.GetBytes(password);
var combined = new byte[nonce.Length + createdBytes.Length + passwordBytes.Length];
System.Buffer.BlockCopy(nonce, 0, combined, 0, nonce.Length);
System.Buffer.BlockCopy(createdBytes, 0, combined, nonce.Length, createdBytes.Length);
System.Buffer.BlockCopy(passwordBytes, 0, combined, nonce.Length + createdBytes.Length, passwordBytes.Length);
var digest = Convert.ToBase64String(SHA1.HashData(combined));
header = $@"<s:Header><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"">{digest}</wsse:Password><wsse:Nonce EncodingType=""http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary"">{nonceBase64}</wsse:Nonce><wsu:Created>{created}</wsu:Created></wsse:UsernameToken></wsse:Security></s:Header>";
}
var envelope = $@"<?xml version=""1.0"" encoding=""utf-8""?><s:Envelope xmlns:s=""http://www.w3.org/2003/05/soap-envelope"">{header}<s:Body xmlns:xsi=""http://www.w3.org/2001/XMLSchema-instance"" xmlns:xsd=""http://www.w3.org/2001/XMLSchema"">{body}</s:Body></s:Envelope>";
using var content = new StringContent(envelope, Encoding.UTF8, "application/soap+xml");
if (!string.IsNullOrEmpty(action))
{
content.Headers.ContentType.Parameters.Add(new System.Net.Http.Headers.NameValueHeaderValue("action", $"\"{action}\""));
}
try
{
using var result = await _httpClient.PostAsync(url, content, token);
var responseBody = await result.Content.ReadAsStringAsync(token);
if (!result.IsSuccessStatusCode)
{
if (_printToLog) Log.Debug($"OnvifCamera: SOAP request to {url} failed: {result.StatusCode}. Response: {responseBody}", this);
}
return responseBody;
}
catch (Exception e)
{
if (_printToLog && !token.IsCancellationRequested)
{
// Filter out cancellation exceptions from logs to avoid spam
if (e is TaskCanceledException || e is OperationCanceledException) return null;
if (e is AggregateException ae && ae.InnerExceptions.All(x => x is TaskCanceledException || x is OperationCanceledException)) return null;
Log.Debug($"OnvifCamera: Connection error to {url}: {e.Message}", this);
}
return null;
}
}
#region Discovery Logic
private void StartDiscovery(string localIpStr)
{
if (_isDiscovering) StopDiscovery();
if (!IPAddress.TryParse(localIpStr, out var localIp))
{
Log.Warning("OnvifCamera: Please select a valid Local IP for discovery.", this);
return;
}
try
{
_discoveryClient = new UdpClient(new IPEndPoint(localIp, 0));
_discoveryClient.EnableBroadcast = true;
_discoveryClient.MulticastLoopback = false;
_isDiscovering = true;
_discoveredDevices.Clear();
_discoveryCts = new CancellationTokenSource();
if (_printToLog) Log.Debug($"OnvifCamera: Starting discovery on {localIp}...", this);
Task.Run(() => ReceiveDiscoveryResponses(_discoveryCts.Token));
Task.Run(() => SendDiscoveryProbes(_discoveryCts.Token));
}
catch (Exception e)
{
Log.Warning($"OnvifCamera: Failed to start discovery: {e.Message}", this);
StopDiscovery();
}
}
private void StopDiscovery()
{
if (!_isDiscovering) return;
_isDiscovering = false;
_discoveryCts?.Cancel();
_discoveryClient?.Close();
_discoveryClient = null;
if (_printToLog) Log.Debug("OnvifCamera: Stopped discovery.", this);
}
private async Task SendDiscoveryProbes(CancellationToken token)
{
var multicastEndpoint = new IPEndPoint(IPAddress.Parse("239.255.255.250"), 3702);
var messageId = Guid.NewGuid().ToString();
var probe = string.Format(WsDiscoveryProbeMessage, messageId);
var bytes = Encoding.UTF8.GetBytes(probe);
while (!token.IsCancellationRequested)
{
try
{
if (_discoveryClient != null)
await _discoveryClient.SendAsync(bytes, bytes.Length, multicastEndpoint);
}
catch (Exception e)
{
if (_printToLog) Log.Debug($"OnvifCamera: Probe send error: {e.Message}", this);
}
await Task.Delay(3000, token); // Send probe every 3 seconds
}
}
private async Task ReceiveDiscoveryResponses(CancellationToken token)
{
while (!token.IsCancellationRequested && _discoveryClient != null)
{
try
{
var result = await _discoveryClient.ReceiveAsync(token);
var response = Encoding.UTF8.GetString(result.Buffer);
ParseDiscoveryResponse(response, result.RemoteEndPoint.Address.ToString());
}
catch (ObjectDisposedException) { break; }
catch (Exception e)
{
if (_printToLog && !token.IsCancellationRequested) Log.Debug($"OnvifCamera: Receive error: {e.Message}", this);
}
}
}
private void ParseDiscoveryResponse(string xml, string fallbackIp)
{
try
{
var xdoc = XDocument.Parse(xml);
var ns = XNamespace.Get("http://schemas.xmlsoap.org/ws/2005/04/discovery");
var probeMatch = xdoc.Descendants(ns + "ProbeMatch").FirstOrDefault();
if (probeMatch == null) return;
var xAddrs = probeMatch.Element(ns + "XAddrs")?.Value;
var scopes = probeMatch.Element(ns + "Scopes")?.Value;
// Extract IP/Host from XAddrs (space separated list of URLs)
string address = fallbackIp;
if (!string.IsNullOrEmpty(xAddrs))
{
var firstAddr = xAddrs.Split(' ')[0];
try { address = new Uri(firstAddr).Authority; }
catch
{
// ignored
}
}
// Extract Name from Scopes (onvif://www.onvif.org/name/MyCamera)
string name = "Unknown Device";
if (!string.IsNullOrEmpty(scopes))
{
foreach (var scope in scopes.Split(' '))
{
if (scope.StartsWith("onvif://www.onvif.org/name/"))
{
name = WebUtility.UrlDecode(scope.Substring("onvif://www.onvif.org/name/".Length));
break;
}
else if (scope.StartsWith("onvif://www.onvif.org/hardware/"))
{
name = WebUtility.UrlDecode(scope.Substring("onvif://www.onvif.org/hardware/".Length));
}
}
}
var key = address;
var display = $"{name} ({address})";
_discoveredDevices.TryAdd(key, display);
}
catch { }
}
private const string WsDiscoveryProbeMessage =
@"<?xml version=""1.0"" encoding=""UTF-8""?>
<e:Envelope xmlns:e=""http://www.w3.org/2003/05/soap-envelope""
xmlns:w=""http://schemas.xmlsoap.org/ws/2004/08/addressing""
xmlns:d=""http://schemas.xmlsoap.org/ws/2005/04/discovery""
xmlns:dn=""http://www.onvif.org/ver10/network/wsdl"">
<e:Header>
<w:MessageID>uuid:{0}</w:MessageID>
<w:To e:mustUnderstand=""true"">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To>
<w:Action a:mustUnderstand=""true"">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action>
</e:Header>
<e:Body>
<d:Probe>
<d:Types>dn:NetworkVideoTransmitter</d:Types>
</d:Probe>
</e:Body>
</e:Envelope>";
#endregion
#endregion
#region Network Interface Logic
private static List<NetworkAdapterInfo> _networkInterfaces = new();
private static List<NetworkAdapterInfo> GetNetworkInterfaces()
{
var list = new List<NetworkAdapterInfo>();
list.Add(new NetworkAdapterInfo(IPAddress.Any, IPAddress.Any, "Any"));
list.Add(new NetworkAdapterInfo(IPAddress.Loopback, IPAddress.Parse("255.0.0.0"), "Localhost"));
try
{
list.AddRange(from ni in NetworkInterface.GetAllNetworkInterfaces()
where ni.OperationalStatus == OperationalStatus.Up && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback
from ip in ni.GetIPProperties().UnicastAddresses
where ip.Address.AddressFamily == AddressFamily.InterNetwork
select new NetworkAdapterInfo(ip.Address, ip.IPv4Mask, ni.Name));
}
catch (Exception e)
{
Log.Warning("Could not enumerate network interfaces: " + e.Message);
}
return list;
}
private sealed record NetworkAdapterInfo(IPAddress IpAddress, IPAddress SubnetMask, string Name)
{
public string DisplayName => $"{Name} ({IpAddress})";
}
#endregion
#region IStatusProvider & ICustomDropdownHolder
public IStatusProvider.StatusLevel GetStatusLevel() => _lastStatusMessage.StartsWith("Error") ? IStatusProvider.StatusLevel.Error : IStatusProvider.StatusLevel.Success;
public string GetStatusMessage() => _lastStatusMessage;
private void SetStatus(string message) => _lastStatusMessage = message;
private volatile string _lastStatusMessage = "Not connected.";
string ICustomDropdownHolder.GetValueForInput(Guid inputId)
{
if (inputId == LocalIpAddress.Id) return LocalIpAddress.Value ?? string.Empty;
if (inputId == Address.Id) return Address.Value ?? string.Empty;
return string.Empty;
}
IEnumerable<string> ICustomDropdownHolder.GetOptionsForInput(Guid inputId)
{
if (inputId == LocalIpAddress.Id)
{
_networkInterfaces = GetNetworkInterfaces();
foreach (var adapter in _networkInterfaces) yield return adapter.DisplayName;
}
else if (inputId == Address.Id)
{
if (_discoveredDevices.IsEmpty) yield return _isDiscovering ? "Searching..." : "Enable 'Discover' to search...";
foreach (var device in _discoveredDevices.Values.OrderBy(d => d))
yield return device;
}
}
void ICustomDropdownHolder.HandleResultForInput(Guid inputId, string selected, bool isAListItem)
{
if (string.IsNullOrEmpty(selected) || !isAListItem) return;
if (inputId == LocalIpAddress.Id)
{
var foundAdapter = _networkInterfaces.FirstOrDefault(i => i.DisplayName == selected);
if (foundAdapter != null) LocalIpAddress.SetTypedInputValue(foundAdapter.IpAddress.ToString());
}
else if (inputId == Address.Id)
{
// Ignore status messages
if (selected.StartsWith("Enable 'Discover'") || selected.StartsWith("Searching")) return;
// Robustly extract address from "Name (Address)" format
var lastOpen = selected.LastIndexOf('(');
var lastClose = selected.LastIndexOf(')');
string ipToSet = selected;
if (lastOpen != -1 && lastClose > lastOpen)
{
ipToSet = selected.Substring(lastOpen + 1, lastClose - lastOpen - 1);
}
ipToSet = ipToSet.Trim();
if (string.IsNullOrEmpty(ipToSet)) return;
if (_printToLog) Log.Debug($"OnvifCamera: Selected '{selected}' -> Setting Address to '{ipToSet}'", this);
Address.SetTypedInputValue(ipToSet);
}
}
#endregion
private volatile bool _connected;
private bool _lastConnectState;
private Thread _captureThread;
private CancellationTokenSource _cancellationTokenSource;
private VideoCapture _capture;
private Texture2D _gpuTexture;
private Mat _sharedBgraMat;
private readonly object _lockObject = new();
private bool _printToLog;
private double _lastNetworkRefreshTime;
private string _ptzServiceUrl;
private string _profileToken;
private bool _wasMoving;
private double _lastPtzPollTime;
private Vector3 _currentPtzValue;
private float _dampedPan;
private float _dampedTilt;
private float _dampedZoom;
private float _lastSentPan;
private float _lastSentTilt;
private float _lastSentZoom;
private double _lastPtzMoveTime;
private volatile bool _isPollingPtz;
private volatile bool _ptzSupported = true;
private bool _ptzErrorBackoff;
private HttpClient _httpClient;
private volatile bool _isDiscovering;
private UdpClient _discoveryClient;
private CancellationTokenSource _discoveryCts;
private TimeSpan _timeOffset = TimeSpan.Zero;
private readonly ConcurrentDictionary<string, string> _discoveredDevices = new();
private CancellationTokenSource _disposeCts;
private volatile bool _isSendingPtz;
[Input(Guid = "74185692-2982-4895-8963-125478963214")]
public readonly InputSlot<string> LocalIpAddress = new();
[Input(Guid = "8451268E-3156-4F5C-8932-156842356842")]
public readonly InputSlot<bool> Discover = new();
[Input(Guid = "1e56f26e-6e78-4f2e-cf56-7f8a8b9c0d2f")]
public readonly InputSlot<string> Address = new();
[Input(Guid = "2f67a37f-7f89-403f-da67-8a9b0c1d2e3f")]
public readonly InputSlot<string> Username = new();
[Input(Guid = "3b78b48b-8b90-514b-eb78-9b0c1d2e3f4b")]
public readonly InputSlot<string> Password = new();
[Input(Guid = "4c89c59c-9c01-625c-fc89-0c1d2e3f4c5c")]
public readonly InputSlot<bool> Connect = new();
[Input(Guid = "8a23a93a-3a45-069a-da23-4a5a6a7a8a9a")]
public readonly InputSlot<bool> Move = new();
[Input(Guid = "5d90d60d-0d12-736d-ad90-1d2e3f4d5d6d")]
public readonly InputSlot<float> Pan = new();
[Input(Guid = "6e01e71e-1e23-847e-be01-2e3f4e5e6e7e")]
public readonly InputSlot<float> Tilt = new();
[Input(Guid = "7f12f82f-2f34-958f-cf12-3f4f5f6f7f8f")]
public readonly InputSlot<float> Zoom = new();
[Input(Guid = "9B8C7D6E-5F4A-3B2C-1D0E-9F8E7D6C5B4A")]
public readonly InputSlot<bool> PrintToLog = new();
}
}