feedback: game-to-cockpit endpoint (pipe/UDP lamps+plasma), rumble lamp flash
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>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user