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