using RioJoy.Core.Mapping; using RioJoy.Core.Plasma; namespace RioJoy.Core.Feedback; /// /// The inbound game-feedback endpoint, assembled: pipe + UDP listeners feed /// protocol lines here; lines parse into s and /// route to the active profile's outputs. App-lifetime by design — the /// coordinator creates one lazily and keeps it across profile switches, so /// external clients hold their connection through switches and dormancy; /// / only swap where commands land /// (detached = dropped). owns the per-activation /// (creates it, runs its pump, cancels it /// on detach) and returns it so the rumble adapter can share the one rate /// governor. /// public sealed class FeedbackService : IDisposable { // A misbehaving client can emit garbage at line rate; log the first few and // go quiet instead of flooding the tray status/log. private const int MaxMalformedLogs = 5; private readonly FeedbackEndpointConfig _config; private readonly FeedbackRouter _router = new(); private FeedbackPipeServer? _pipe; private FeedbackUdpListener? _udp; private CancellationTokenSource? _schedulerCts; private bool _started; private long _malformed; public FeedbackService(FeedbackEndpointConfig? config) { _config = config ?? new FeedbackEndpointConfig(); _router.Logged += message => Logged?.Invoke(message); } /// Diagnostics: listener lifecycle, malformed lines, dropped lamp writes. public event Action? Logged; /// Total lines that failed to parse (all clients). public long MalformedLines => Interlocked.Read(ref _malformed); /// Commands dropped (detached, disallowed, or profile-owned lamps). public long DroppedCommands => _router.DroppedCommands; /// Start the configured listeners (idempotent). public void Start() { if (_started) return; _started = true; if (_config.PipeEnabled) { _pipe = new FeedbackPipeServer(_config.PipeName, HandleLine, OnLog); _pipe.Start(); } if (_config.UdpPort is int port) { try { _udp = new FeedbackUdpListener(port, HandleLine, OnLog); _udp.Start(); } catch (System.Net.Sockets.SocketException ex) { // Port taken — feedback still works over the pipe; say so and go on. OnLog($"feedback: UDP port {port} unavailable ({ex.Message}) — pipe only"); } } } /// /// Point inbound feedback at a just-activated profile's outputs. Returns the /// live lamp scheduler (share it with the rumble adapter — one governor for /// all feedback lamp traffic). /// public CoalescingLampScheduler Attach( ILampSink lamps, RioInputMap map, PlasmaDisplay? plasma, ProfileFeedbackConfig config) { Detach(); var scheduler = new CoalescingLampScheduler(lamps); _schedulerCts = new CancellationTokenSource(); _ = scheduler.RunAsync(_schedulerCts.Token); // exits cleanly on cancel, never faults _router.Attach(scheduler, map, plasma, config); return scheduler; } /// Drop the profile target; subsequent commands are dropped (counted). public void Detach() { _router.Detach(); _schedulerCts?.Cancel(); _schedulerCts?.Dispose(); _schedulerCts = null; } private void HandleLine(string line) { if (FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error)) { _router.Dispatch(command!); } else if (error is not null) { long count = Interlocked.Increment(ref _malformed); if (count <= MaxMalformedLogs) OnLog($"feedback: bad line ({error})" + (count == MaxMalformedLogs ? " — further malformed lines suppressed" : string.Empty)); } } private void OnLog(string message) => Logged?.Invoke(message); public void Dispose() { Detach(); _pipe?.Dispose(); _pipe = null; _udp?.Dispose(); _udp = null; _started = false; } }