diff --git a/src/TeamsISO.App/Services/OscBridge.cs b/src/TeamsISO.App/Services/OscBridge.cs new file mode 100644 index 0000000..818abb1 --- /dev/null +++ b/src/TeamsISO.App/Services/OscBridge.cs @@ -0,0 +1,393 @@ +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; + + public OscBridge( + IIsoController controller, + Func viewModel, + ILogger? logger = null) + { + _controller = controller; + _viewModel = viewModel; + _logger = logger; + } + + public void Start(int port) + { + if (IsRunning && Port == port) return; + Stop(); + + Port = port; + try + { + // Bind to loopback only — same threat model as the REST surface. + _udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port)); + } + catch (SocketException ex) + { + _logger?.LogWarning(ex, "Could not bind OSC bridge to udp://127.0.0.1:{Port}.", port); + _udp = null; + return; + } + _cts = new CancellationTokenSource(); + _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token)); + IsRunning = true; + _logger?.LogInformation("OSC bridge listening on udp://127.0.0.1:{Port}/", 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); + } +}