using System.Net; using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Logging; using TeamsISO.App.ViewModels; using TeamsISO.Engine.Controller; namespace TeamsISO.App.Services; /// /// OSC over UDP. Companion, TouchOSC, Bome, and most lighting consoles speak /// OSC natively, so wrapping the same command surface in OSC opens the /// product to the broader live-show ecosystem without a Companion bridge. /// /// Protocol — minimal OSC 1.0: /// - Address pattern (null-terminated string, padded to 4-byte boundary) /// - Type tag (",iiisf" etc., null-terminated, padded to 4) /// - Args in order /// /// We don't implement bundles, time tags, blob args, or pattern matching /// — none are needed for the verbs we support. If a sender uses bundles /// we ignore them; if a sender uses a wildcard address ("/teamsiso/*") we /// ignore it. Operators get a clear log line in either case. /// /// Routes: /// /teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name /// /teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id /// /teamsiso/preset "Name" — apply preset /// /teamsiso/teams/mute — UIA toggle mute /// /teamsiso/teams/camera — UIA toggle camera /// /teamsiso/teams/leave — UIA leave /// /teamsiso/teams/share — UIA share tray /// /teamsiso/teams/raise-hand — UIA raise hand /// /teamsiso/refresh-discovery — rebuild NDI finder /// /teamsiso/stop-all — disable every ISO /// /teamsiso/recording {0|1} — recording on/off (default dir) /// public sealed class OscBridge : IAsyncDisposable { public const int DefaultPort = 9000; private readonly IIsoController _controller; private readonly Func _viewModel; private readonly ILogger? _logger; private UdpClient? _udp; private CancellationTokenSource? _cts; private Task? _receiveTask; public bool IsRunning { get; private set; } public int Port { get; private set; } = DefaultPort; /// True when the listener is bound to all interfaces rather than just loopback. public bool BoundToLan { get; private set; } public OscBridge( IIsoController controller, Func viewModel, ILogger? logger = null) { _controller = controller; _viewModel = viewModel; _logger = logger; } /// /// Start the OSC listener on the given UDP port. /// flag selects between loopback (default — only this machine) and any- /// interface binding (LAN-reachable, for thin-client controllers). /// Unlike the REST surface, UDP doesn't need a URL ACL — binding 0.0.0.0 /// is just an unprivileged port reservation. /// public void Start(int port, bool bindToLan = false) { if (IsRunning && Port == port && BoundToLan == bindToLan) return; Stop(); Port = port; BoundToLan = bindToLan; var bindAddr = bindToLan ? IPAddress.Any : IPAddress.Loopback; try { _udp = new UdpClient(new IPEndPoint(bindAddr, port)); } catch (SocketException ex) { _logger?.LogWarning(ex, "Could not bind OSC bridge to udp://{Addr}:{Port}.", bindAddr, port); _udp = null; return; } _cts = new CancellationTokenSource(); _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token)); IsRunning = true; _logger?.LogInformation("OSC bridge listening on udp://{Addr}:{Port}/", bindAddr, port); } public void Stop() { if (!IsRunning) return; try { _cts?.Cancel(); } catch { /* ignore */ } try { _udp?.Close(); } catch { /* ignore */ } try { _receiveTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ } _udp?.Dispose(); _udp = null; _cts?.Dispose(); _cts = null; _receiveTask = null; IsRunning = false; } public async ValueTask DisposeAsync() { Stop(); await Task.CompletedTask; } private async Task ReceiveLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested && _udp is not null) { UdpReceiveResult result; try { result = await _udp.ReceiveAsync(ct); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { break; } catch (SocketException ex) { _logger?.LogWarning(ex, "OSC receive failed; continuing."); continue; } try { var msg = OscMessage.TryParse(result.Buffer); if (msg is null) continue; await DispatchAsync(msg); } catch (Exception ex) { _logger?.LogWarning(ex, "OSC dispatch failed for packet from {Endpoint}.", result.RemoteEndPoint); } } } private async Task DispatchAsync(OscMessage msg) { var addr = msg.Address; switch (addr) { case "/teamsiso/teams/mute": InvokeTeams(TeamsControlBridge.ToggleMute); return; case "/teamsiso/teams/camera": InvokeTeams(TeamsControlBridge.ToggleCamera); return; case "/teamsiso/teams/leave": InvokeTeams(TeamsControlBridge.LeaveCall); return; case "/teamsiso/teams/share": InvokeTeams(TeamsControlBridge.OpenShareTray); return; case "/teamsiso/teams/raise-hand":InvokeTeams(TeamsControlBridge.ToggleRaiseHand); return; case "/teamsiso/refresh-discovery":_controller.RefreshDiscovery(); return; case "/teamsiso/stop-all": await StopAllAsync(); return; case "/teamsiso/recording": SetRecording(msg); return; case "/teamsiso/recording/marker": DropMarker(msg); return; case "/teamsiso/recording/roll": await RollRecordingAsync(); return; case "/teamsiso/notes": AppendNote(msg); return; case "/teamsiso/iso": await ToggleByNameAsync(msg); return; case "/teamsiso/iso/by-id": await ToggleByIdAsync(msg); return; case "/teamsiso/preset": await ApplyPresetAsync(msg); return; default: _logger?.LogDebug("Ignoring unknown OSC address {Addr}", addr); return; } } // ─── handler helpers ──────────────────────────────────────────────── private static void InvokeTeams(Func action) => _ = action(); private async Task StopAllAsync() { var vm = _viewModel(); if (vm is null) return; var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher is null) return; var enabled = await dispatcher.InvokeAsync(() => vm.Participants.Where(p => p.IsEnabled).ToArray()); foreach (var p in enabled) { try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); } catch { /* defensive */ } await dispatcher.InvokeAsync(() => p.IsEnabled = false); } } private void SetRecording(OscMessage msg) { var enabled = msg.GetBoolArg(0) ?? false; // OSC doesn't carry a directory string in this minimal protocol; let the // recording directory remain whatever the UI / REST surface set last. _controller.SetRecording(enabled, _controller.RecordingDirectory); } private void DropMarker(OscMessage msg) { var label = msg.GetStringArg(0) ?? "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss"); _controller.AddRecordingMarker(label); } private static void AppendNote(OscMessage msg) { var text = msg.GetStringArg(0); if (!string.IsNullOrWhiteSpace(text)) NotesService.Append(text); } /// Roll every active recording into a new chunk. Same path as REST /recording/roll. private async Task RollRecordingAsync() { var vm = _viewModel(); var dispatcher = System.Windows.Application.Current?.Dispatcher; if (vm is null || dispatcher is null) return; var enabled = await dispatcher.InvokeAsync(() => vm.Participants.Where(p => p.IsEnabled).ToArray()); foreach (var p in enabled) { try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); await Task.Delay(150); var nameToUse = string.IsNullOrWhiteSpace(p.CustomName) ? OutputNameTemplate.Render(OutputNameTemplate.Get(), p.Id, p.DisplayName) : p.CustomName; bool? recordOverride = p.RecordToDisk ? null : false; await _controller.EnableIsoAsync(p.Id, nameToUse, recordOverride, CancellationToken.None); } catch { /* per-pipeline best-effort */ } } } private async Task ToggleByNameAsync(OscMessage msg) { var name = msg.GetStringArg(0); if (string.IsNullOrEmpty(name)) return; var enabled = msg.GetBoolArg(1); var vm = _viewModel(); var dispatcher = System.Windows.Application.Current?.Dispatcher; if (vm is null || dispatcher is null) return; var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x => string.Equals(x.DisplayName, name, StringComparison.OrdinalIgnoreCase))); if (p is null) return; await ApplyToggleAsync(p, enabled, dispatcher); } private async Task ToggleByIdAsync(OscMessage msg) { var idStr = msg.GetStringArg(0); if (!Guid.TryParse(idStr, out var id)) return; var enabled = msg.GetBoolArg(1); var vm = _viewModel(); var dispatcher = System.Windows.Application.Current?.Dispatcher; if (vm is null || dispatcher is null) return; var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x => x.Id == id)); if (p is null) return; await ApplyToggleAsync(p, enabled, dispatcher); } private async Task ApplyToggleAsync(ParticipantViewModel p, bool? enabled, System.Windows.Threading.Dispatcher dispatcher) { var target = enabled ?? !p.IsEnabled; if (target == p.IsEnabled) return; try { if (target) { await _controller.EnableIsoAsync(p.Id, string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName, CancellationToken.None); await dispatcher.InvokeAsync(() => p.IsEnabled = true); } else { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); await dispatcher.InvokeAsync(() => p.IsEnabled = false); } } catch { /* defensive: OSC senders are typically fire-and-forget */ } } private async Task ApplyPresetAsync(OscMessage msg) { var name = msg.GetStringArg(0); if (string.IsNullOrEmpty(name)) return; var preset = OperatorPresetStore.Find(name); if (preset is null) return; var vm = _viewModel(); var dispatcher = System.Windows.Application.Current?.Dispatcher; if (vm is null || dispatcher is null) return; var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList()); await PresetApplier.ApplyAsync(preset, snapshot, _controller, dispatcher); } } // ─── OSC message parser ───────────────────────────────────────────────── /// /// Minimal OSC 1.0 message parser. Supports the subset we care about: /// integer (i), float (f), string (s) args. Bundles / time tags / blobs are /// not implemented — incoming packets that look like bundles return null /// and the caller logs + skips them. /// internal sealed class OscMessage { public string Address { get; init; } = ""; public string TypeTag { get; init; } = ""; public IReadOnlyList Args { get; init; } = Array.Empty(); /// Parse a single OSC packet. Returns null if malformed or a bundle. public static OscMessage? TryParse(byte[] bytes) { if (bytes.Length < 8) return null; // Bundle marker — we don't support bundles. Skip. if (bytes[0] == '#') return null; var idx = 0; var address = ReadOscString(bytes, ref idx); if (address is null || !address.StartsWith('/')) return null; if (idx >= bytes.Length) return new OscMessage { Address = address }; var typeTag = ReadOscString(bytes, ref idx); if (typeTag is null || !typeTag.StartsWith(',')) return null; var args = new List(); for (var i = 1; i < typeTag.Length; i++) { switch (typeTag[i]) { case 'i': if (idx + 4 > bytes.Length) return null; args.Add(ReadInt32BE(bytes, idx)); idx += 4; break; case 'f': if (idx + 4 > bytes.Length) return null; args.Add(ReadFloat32BE(bytes, idx)); idx += 4; break; case 's': var s = ReadOscString(bytes, ref idx); if (s is null) return null; args.Add(s); break; case 'T': args.Add(true); break; case 'F': args.Add(false); break; default: // Unknown type — bail rather than mis-aligning subsequent args. return null; } } return new OscMessage { Address = address, TypeTag = typeTag, Args = args }; } public string? GetStringArg(int idx) => idx < Args.Count && Args[idx] is string s ? s : null; public bool? GetBoolArg(int idx) { if (idx >= Args.Count) return null; return Args[idx] switch { bool b => b, int i => i != 0, float f => f != 0f, string s => s.Equals("true", StringComparison.OrdinalIgnoreCase) || s == "1", _ => null, }; } private static string? ReadOscString(byte[] bytes, ref int idx) { var start = idx; while (idx < bytes.Length && bytes[idx] != 0) idx++; if (idx >= bytes.Length) return null; var s = Encoding.ASCII.GetString(bytes, start, idx - start); // Advance past the trailing null and align to 4-byte boundary. idx++; var pad = (4 - (idx - start) % 4) % 4; idx += pad; return s; } private static int ReadInt32BE(byte[] bytes, int offset) => (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]; private static float ReadFloat32BE(byte[] bytes, int offset) { Span tmp = stackalloc byte[4]; tmp[0] = bytes[offset + 3]; tmp[1] = bytes[offset + 2]; tmp[2] = bytes[offset + 1]; tmp[3] = bytes[offset]; return BitConverter.ToSingle(tmp); } }