using System.Net.NetworkInformation; using System.Net.Sockets; using System.Threading; using T3.Core.Animation; namespace Lib.io.ptz { [Guid("7c90d60d-0d12-736d-ad90-1d2e3f4d5d6d")] public class ViscaCamera : Instance, ICustomDropdownHolder { [Output(Guid = "A1B2C3D4-E5F6-4789-0123-456789ABCDEF")] public readonly Slot TextureOutput = new(); [Output(Guid = "8d01e71e-1e23-847e-be01-2e3f4e5e6e7e")] public readonly Slot IsConnected = new(); [Output(Guid = "E1F2A3B4-C5D6-E7F8-9012-3456789ABCDE", DirtyFlagTrigger = DirtyFlagTrigger.Animated)] public readonly Slot CurrentPtz = new(); public ViscaCamera() { IsConnected.UpdateAction += Update; TextureOutput.UpdateAction += Update; CurrentPtz.UpdateAction += Update; } private void Update(EvaluationContext context) { // Pass-through texture TextureOutput.Value = TextureInput.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 port = Port.GetValue(context); var shouldConnect = Connect.GetValue(context); var printToLog = PrintToLog.GetValue(context); var pan = Pan.GetValue(context); var tilt = Tilt.GetValue(context); var zoom = Zoom.GetValue(context); var doMove = Move.GetValue(context); var panRange = PanRange.GetValue(context); var tiltRange = TiltRange.GetValue(context); // Update ranges for the receive loop to use _currentPanRange = panRange > 0 ? panRange : 1; _currentTiltRange = tiltRange > 0 ? tiltRange : 1; if (shouldConnect != _isConnected) { if (shouldConnect) { ConnectToCamera(address, port, localIpString, printToLog); } else { Disconnect(printToLog); } } else if (shouldConnect && !_isConnected) { if (Playback.RunTimeInSecs - _lastRetryTime > 2.0) { _lastRetryTime = Playback.RunTimeInSecs; ConnectToCamera(address, port, localIpString, printToLog); } } if (_isConnected) { if (doMove) { // Throttling to avoid flooding the camera if ((DateTime.Now - _lastSendTime).TotalMilliseconds > 50) // Max 20Hz { _lastSendTime = DateTime.Now; // Fire and forget, but on a thread pool to avoid blocking Update _ = SendAbsoluteMoveAsync(pan, tilt, zoom, panRange, tiltRange, printToLog); } } // Polling for position if (Playback.RunTimeInSecs - _lastPtzPollTime > 0.1) // 10Hz polling { _lastPtzPollTime = Playback.RunTimeInSecs; _ = PollPtzAsync(printToLog); } } IsConnected.Value = _isConnected; CurrentPtz.Value = _currentPtz; } private void ConnectToCamera(string address, int port, string localIpStr, bool log) { try { if (string.IsNullOrEmpty(address)) return; // Close existing if any _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); _udpClient?.Dispose(); // Bind to specific local IP if provided IPEndPoint localEndPoint = null; if (!string.IsNullOrEmpty(localIpStr) && IPAddress.TryParse(localIpStr, out var localIp)) { localEndPoint = new IPEndPoint(localIp, 0); } _udpClient = localEndPoint != null ? new UdpClient(localEndPoint) : new UdpClient(); _udpClient.Connect(address, port); _sequenceNumber = 1; _cancellationTokenSource = new CancellationTokenSource(); // Start Receive Loop Task.Run(() => ReceiveLoop(_cancellationTokenSource.Token, log)); // Reset sequence number Task.Run(() => ResetSequenceNumber(log)); _isConnected = true; if (log) Log.Debug($"ViscaCamera: Connected to {address}:{port} via {localIpStr ?? "Default Interface"}", this); } catch (Exception e) { if (log) Log.Warning($"ViscaCamera: Connection failed: {e.Message}", this); _isConnected = false; } } private void Disconnect(bool log) { try { _cancellationTokenSource?.Cancel(); _udpClient?.Close(); _udpClient?.Dispose(); } catch { // ignored } _udpClient = null; _isConnected = false; if (log) Log.Debug("ViscaCamera: Disconnected", this); } private async Task ReceiveLoop(CancellationToken token, bool log) { while (!token.IsCancellationRequested) { try { if (_udpClient == null) break; var result = await _udpClient.ReceiveAsync(); ProcessPacket(result.Buffer); } catch (ObjectDisposedException) { break; } catch (Exception e) { if (log && !token.IsCancellationRequested) Log.Warning($"ViscaCamera: Receive error: {e.Message}", this); // Wait a bit before retrying to avoid tight loop on error await Task.Delay(100, token); } } } private void ProcessPacket(byte[] data) { // VISCA over IP packet structure: // 00-01: Payload Type (0x0111 for Reply) // 02-03: Payload Length // 04-07: Sequence Number // 08-..: Payload (VISCA response) if (data.Length < 9) return; // Header (8) + Min Payload (1) // Check for VISCA Reply (0x0111) if (data[0] != 0x01 || data[1] != 0x11) return; int payloadIndex = 8; // Check for Pan/Tilt Inquiry Response // Response: Y0 50 0w 0w 0w 0w 0z 0z 0z 0z FF (11 bytes payload) if (data.Length >= payloadIndex + 11) { if (data[payloadIndex + 1] == 0x50 && data[payloadIndex + 10] == 0xFF) { // Let's check payload length from header int payloadLen = (data[2] << 8) | data[3]; if (payloadLen == 11) { // Parse Pan/Tilt with masking for safety int panRaw = ((data[payloadIndex + 2] & 0x0F) << 12) | ((data[payloadIndex + 3] & 0x0F) << 8) | ((data[payloadIndex + 4] & 0x0F) << 4) | (data[payloadIndex + 5] & 0x0F); int tiltRaw = ((data[payloadIndex + 6] & 0x0F) << 12) | ((data[payloadIndex + 7] & 0x0F) << 8) | ((data[payloadIndex + 8] & 0x0F) << 4) | (data[payloadIndex + 9] & 0x0F); short panSigned = (short)panRaw; short tiltSigned = (short)tiltRaw; // Normalize to -1..1 based on Range float pan = (float)panSigned / _currentPanRange; float tilt = (float)tiltSigned / _currentTiltRange; _currentPtz.X = pan; _currentPtz.Y = tilt; } } } // Check for Zoom Inquiry Response // Response: Y0 50 0p 0q 0r 0s FF (7 bytes payload) if (data.Length >= payloadIndex + 7) { int payloadLen = (data[2] << 8) | data[3]; if (payloadLen == 7 && data[payloadIndex + 1] == 0x50 && data[payloadIndex + 6] == 0xFF) { int zoomRaw = ((data[payloadIndex + 2] & 0x0F) << 12) | ((data[payloadIndex + 3] & 0x0F) << 8) | ((data[payloadIndex + 4] & 0x0F) << 4) | (data[payloadIndex + 5] & 0x0F); // Normalize Zoom (0x0000 to 0x4000 usually) float zoom = (float)zoomRaw / 0x4000; _currentPtz.Z = zoom; } } } private async Task ResetSequenceNumber(bool log) { byte[] packet = new byte[] { 0x02, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01 }; await SendPacketAsync(packet, log); _sequenceNumber = 1; } private async Task PollPtzAsync(bool log) { // Pan-Tilt Pos Inquiry: 81 09 06 12 FF byte[] ptInq = new byte[] { 0x81, 0x09, 0x06, 0x12, 0xFF }; await SendViscaCommandAsync(ptInq, 0x0110, log); // 0x0110 = VISCA Inquiry // Zoom Pos Inquiry: 81 09 04 47 FF byte[] zoomInq = new byte[] { 0x81, 0x09, 0x04, 0x47, 0xFF }; await SendViscaCommandAsync(zoomInq, 0x0110, log); // 0x0110 = VISCA Inquiry } private async Task SendAbsoluteMoveAsync(float pan, float tilt, float zoom, int panRange, int tiltRange, bool log) { // Map -1..1 to Range int p = (int)(Math.Clamp(pan, -1f, 1f) * panRange); int t = (int)(Math.Clamp(tilt, -1f, 1f) * tiltRange); // Zoom is 0..1 -> 0x0000 to 0x4000 (standard Sony range) int z = (int)(Math.Clamp(zoom, 0f, 1f) * 0x4000); byte speed = 0x18; byte[] viscaCmd = new byte[15]; viscaCmd[0] = 0x81; viscaCmd[1] = 0x01; viscaCmd[2] = 0x06; viscaCmd[3] = 0x02; // Absolute Move viscaCmd[4] = speed; // Pan Speed viscaCmd[5] = speed; // Tilt Speed Set4ByteVal(viscaCmd, 6, p); Set4ByteVal(viscaCmd, 10, t); viscaCmd[14] = 0xFF; await SendViscaCommandAsync(viscaCmd, 0x0100, log); // 0x0100 = VISCA Command // Zoom Absolute: 81 01 04 47 0p 0q 0r 0s FF byte[] zoomCmd = new byte[9]; zoomCmd[0] = 0x81; zoomCmd[1] = 0x01; zoomCmd[2] = 0x04; zoomCmd[3] = 0x47; Set4ByteVal(zoomCmd, 4, z); zoomCmd[8] = 0xFF; await SendViscaCommandAsync(zoomCmd, 0x0100, log); // 0x0100 = VISCA Command } private void Set4ByteVal(byte[] buffer, int offset, int value) { short v = (short)value; buffer[offset] = (byte)((v >> 12) & 0x0F); buffer[offset+1] = (byte)((v >> 8) & 0x0F); buffer[offset+2] = (byte)((v >> 4) & 0x0F); buffer[offset+3] = (byte)((v) & 0x0F); } private async Task SendViscaCommandAsync(byte[] cmd, ushort payloadType, bool log) { byte[] packet = new byte[8 + cmd.Length]; packet[0] = (byte)((payloadType >> 8) & 0xFF); // Payload Type High packet[1] = (byte)(payloadType & 0xFF); // Payload Type Low packet[2] = (byte)((cmd.Length >> 8) & 0xFF); // Length High packet[3] = (byte)(cmd.Length & 0xFF); // Length Low uint seq = _sequenceNumber++; packet[4] = (byte)((seq >> 24) & 0xFF); packet[5] = (byte)((seq >> 16) & 0xFF); packet[6] = (byte)((seq >> 8) & 0xFF); packet[7] = (byte)(seq & 0xFF); Array.Copy(cmd, 0, packet, 8, cmd.Length); await SendPacketAsync(packet, log); } private async Task SendPacketAsync(byte[] packet, bool log) { if (_udpClient == null) return; try { await _udpClient.SendAsync(packet, packet.Length); } catch (ObjectDisposedException) { // Client was closed, ignore } catch (Exception e) { if (log) Log.Warning($"ViscaCamera: Send failed: {e.Message}", this); } } protected override void Dispose(bool isDisposing) { if (!isDisposing) return; Disconnect(false); } #region Network Interface Logic private static List _networkInterfaces = new(); private static List GetNetworkInterfaces() { var list = new List(); 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 ICustomDropdownHolder string ICustomDropdownHolder.GetValueForInput(Guid inputId) { if (inputId == LocalIpAddress.Id) return LocalIpAddress.Value ?? string.Empty; return string.Empty; } IEnumerable ICustomDropdownHolder.GetOptionsForInput(Guid inputId) { if (inputId == LocalIpAddress.Id) { _networkInterfaces = GetNetworkInterfaces(); foreach (var adapter in _networkInterfaces) yield return adapter.DisplayName; } } 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()); } } #endregion private UdpClient _udpClient; private bool _isConnected; private uint _sequenceNumber; private DateTime _lastSendTime; private double _lastNetworkRefreshTime; private double _lastRetryTime; private double _lastPtzPollTime; private CancellationTokenSource _cancellationTokenSource; private Vector3 _currentPtz; private int _currentPanRange = 1; private int _currentTiltRange = 1; [Input(Guid = "B2C3D4E5-F6A7-4890-1234-567890ABCDEF")] public readonly InputSlot TextureInput = new(); [Input(Guid = "34185692-2982-4895-8963-125478963214")] public readonly InputSlot LocalIpAddress = new(); [Input(Guid = "9a23a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot Address = new(); [Input(Guid = "a123a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot Port = new(); [Input(Guid = "b223a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot Connect = new(); [Input(Guid = "c323a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot Pan = new(); [Input(Guid = "d423a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot Tilt = new(); [Input(Guid = "e523a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot Zoom = new(); [Input(Guid = "f623a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot Move = new(); [Input(Guid = "0723a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot PanRange = new(); [Input(Guid = "1823a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot TiltRange = new(); [Input(Guid = "2923a93a-3a45-069a-da23-4a5a6a7a8a9a")] public readonly InputSlot PrintToLog = new(); } }