feat: WebSocket live-state push + OSC bridge

This commit is contained in:
Zac Gaetano 2026-05-10 09:41:30 -04:00
parent e93b8caae0
commit b8fe344c58

View file

@ -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;
/// <summary>
/// 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)
/// </summary>
public sealed class OscBridge : IAsyncDisposable
{
public const int DefaultPort = 9000;
private readonly IIsoController _controller;
private readonly Func<MainViewModel?> _viewModel;
private readonly ILogger<OscBridge>? _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<MainViewModel?> viewModel,
ILogger<OscBridge>? 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<TeamsControlBridge.InvokeResult> 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);
}
/// <summary>Roll every active recording into a new chunk. Same path as REST /recording/roll.</summary>
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 ─────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
internal sealed class OscMessage
{
public string Address { get; init; } = "";
public string TypeTag { get; init; } = "";
public IReadOnlyList<object> Args { get; init; } = Array.Empty<object>();
/// <summary>Parse a single OSC packet. Returns null if malformed or a bundle.</summary>
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<object>();
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<byte> 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);
}
}