feat: WebSocket live-state push + OSC bridge
This commit is contained in:
parent
e93b8caae0
commit
b8fe344c58
1 changed files with 393 additions and 0 deletions
393
src/TeamsISO.App/Services/OscBridge.cs
Normal file
393
src/TeamsISO.App/Services/OscBridge.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue