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;
}
}