Files
riojoy/src/RioJoy.Core/Feedback/FeedbackService.cs
T
CydandClaude Fable 5 ad7ac19ab2 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>
2026-07-31 19:31:03 -05:00

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