Phase 9: FeedbackPipeServer (\\.\pipe\riojoy-feedback) + loopback UDP share a forgiving text line protocol into FeedbackRouter; CoalescingLampScheduler rate- governs the 9600-baud link; plasma finally wired into activation (greeting, teardown blank, PlasmaDisplay write lock); ViGEm FeedbackReceived drives RumbleLampAdapter. Per-profile Feedback config, docs/FEEDBACK.md, 425 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
128 lines
4.4 KiB
C#
128 lines
4.4 KiB
C#
using RioJoy.Core.Mapping;
|
|
using RioJoy.Core.Plasma;
|
|
|
|
namespace RioJoy.Core.Feedback;
|
|
|
|
/// <summary>
|
|
/// The inbound game-feedback endpoint, assembled: pipe + UDP listeners feed
|
|
/// protocol lines here; lines parse into <see cref="FeedbackCommand"/>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;
|
|
/// <see cref="Attach"/>/<see cref="Detach"/> only swap where commands land
|
|
/// (detached = dropped). <see cref="Attach"/> owns the per-activation
|
|
/// <see cref="CoalescingLampScheduler"/> (creates it, runs its pump, cancels it
|
|
/// on detach) and returns it so the rumble adapter can share the one rate
|
|
/// governor.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Diagnostics: listener lifecycle, malformed lines, dropped lamp writes.</summary>
|
|
public event Action<string>? Logged;
|
|
|
|
/// <summary>Total lines that failed to parse (all clients).</summary>
|
|
public long MalformedLines => Interlocked.Read(ref _malformed);
|
|
|
|
/// <summary>Commands dropped (detached, disallowed, or profile-owned lamps).</summary>
|
|
public long DroppedCommands => _router.DroppedCommands;
|
|
|
|
/// <summary>Start the configured listeners (idempotent).</summary>
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Drop the profile target; subsequent commands are dropped (counted).</summary>
|
|
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;
|
|
}
|
|
}
|