From ad7ac19ab29ebb1dd013a0c373fc7334f86a6d23 Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 31 Jul 2026 19:31:03 -0500 Subject: [PATCH] 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 --- README.md | 7 +- docs/FEEDBACK.md | 148 +++++++++ docs/PLAN.md | 59 +++- src/RioJoy.Core/Compat/TaskCompat.cs | 5 + .../Feedback/CoalescingLampScheduler.cs | 109 +++++++ src/RioJoy.Core/Feedback/FeedbackCommand.cs | 43 +++ src/RioJoy.Core/Feedback/FeedbackConfig.cs | 61 ++++ .../Feedback/FeedbackLineBuffer.cs | 76 +++++ .../Feedback/FeedbackLineParser.cs | 296 ++++++++++++++++++ .../Feedback/FeedbackPipeServer.cs | 220 +++++++++++++ src/RioJoy.Core/Feedback/FeedbackRouter.cs | 198 ++++++++++++ src/RioJoy.Core/Feedback/FeedbackService.cs | 127 ++++++++ .../Feedback/FeedbackUdpListener.cs | 110 +++++++ src/RioJoy.Core/Feedback/RumbleLampAdapter.cs | 66 ++++ src/RioJoy.Core/Mapping/RioAddress.cs | 9 + src/RioJoy.Core/Output/ViGEmJoystickSink.cs | 14 + src/RioJoy.Core/Plasma/PlasmaDisplay.cs | 45 ++- src/RioJoy.Core/Profiles/AppConfig.cs | 7 + src/RioJoy.Core/Profiles/RioProfile.cs | 6 + src/RioJoy.Core/RioRuntime.cs | 8 + src/RioJoy.Tray/RioCoordinator.cs | 142 ++++++++- .../Feedback/CoalescingLampSchedulerTests.cs | 141 +++++++++ .../Feedback/FeedbackLineParserTests.cs | 250 +++++++++++++++ .../Feedback/FeedbackPipeServerTests.cs | 156 +++++++++ .../Feedback/FeedbackRouterTests.cs | 154 +++++++++ .../Feedback/FeedbackServiceTests.cs | 96 ++++++ .../Feedback/FeedbackTestHelpers.cs | 17 + .../Feedback/FeedbackUdpListenerTests.cs | 100 ++++++ .../Feedback/RumbleLampAdapterTests.cs | 108 +++++++ .../Plasma/PlasmaDisplayTests.cs | 79 +++++ .../Profiles/ConfigStoreTests.cs | 64 ++++ tests/RioJoy.Core.Tests/RioRuntimeTests.cs | 24 ++ .../Serial/GatedTransport.cs | 50 +++ 33 files changed, 2977 insertions(+), 18 deletions(-) create mode 100644 docs/FEEDBACK.md create mode 100644 src/RioJoy.Core/Feedback/CoalescingLampScheduler.cs create mode 100644 src/RioJoy.Core/Feedback/FeedbackCommand.cs create mode 100644 src/RioJoy.Core/Feedback/FeedbackConfig.cs create mode 100644 src/RioJoy.Core/Feedback/FeedbackLineBuffer.cs create mode 100644 src/RioJoy.Core/Feedback/FeedbackLineParser.cs create mode 100644 src/RioJoy.Core/Feedback/FeedbackPipeServer.cs create mode 100644 src/RioJoy.Core/Feedback/FeedbackRouter.cs create mode 100644 src/RioJoy.Core/Feedback/FeedbackService.cs create mode 100644 src/RioJoy.Core/Feedback/FeedbackUdpListener.cs create mode 100644 src/RioJoy.Core/Feedback/RumbleLampAdapter.cs create mode 100644 tests/RioJoy.Core.Tests/Feedback/CoalescingLampSchedulerTests.cs create mode 100644 tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs create mode 100644 tests/RioJoy.Core.Tests/Feedback/FeedbackPipeServerTests.cs create mode 100644 tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs create mode 100644 tests/RioJoy.Core.Tests/Feedback/FeedbackServiceTests.cs create mode 100644 tests/RioJoy.Core.Tests/Feedback/FeedbackTestHelpers.cs create mode 100644 tests/RioJoy.Core.Tests/Feedback/FeedbackUdpListenerTests.cs create mode 100644 tests/RioJoy.Core.Tests/Feedback/RumbleLampAdapterTests.cs create mode 100644 tests/RioJoy.Core.Tests/Plasma/PlasmaDisplayTests.cs create mode 100644 tests/RioJoy.Core.Tests/Serial/GatedTransport.cs diff --git a/README.md b/README.md index 49bb238..5fb14e6 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ Red Planet — talk to the RIO directly and do not use this app.) | [`driver/`](driver/) | `RioGamepad` virtual HID driver (KMDF + VHF) — replaces vJoy | | [`tools/RioJoySmokeTest`](tools/RioJoySmokeTest/) | On-cabinet end-to-end check of the feeder → driver path | | [`tools/XcfRegionExtract`](tools/XcfRegionExtract/) | Extracts cockpit label regions from `riojoy.xcf` → `regions.json` | -| [`docs/PLAN.md`](docs/PLAN.md) | Full modernization plan (7 phases) | +| [`docs/PLAN.md`](docs/PLAN.md) | Full modernization plan | | [`docs/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference | +| [`docs/FEEDBACK.md`](docs/FEEDBACK.md) | Game→cockpit feedback endpoint (lamps + plasma over pipe/UDP, rumble) | | _RIO board hardware & firmware_ | Moved to the [TeslaRel410 `restoration/`](https://gitea.mysticmachines.com/VWE/TeslaRel410/src/branch/main/restoration) archive — board photos, schematics, GAL decode (`restoration/rio-hardware`) and the RIO 4.3 board firmware (`restoration/rio-firmware`) | | [`docs/reference/`](docs/reference/) | Cockpit overlay art & the legacy labeling pipeline | | [`legacy/`](legacy/) | Original C++/vJoy implementation, kept as reference | @@ -41,7 +42,9 @@ dotnet test RioJoy.sln ## Status -Phases 1–5 are implemented and tested (241 unit tests). The `RioGamepad` virtual +Phases 1–5 and 9 are implemented and tested (425 unit tests). Games (or sim +export scripts) can drive the cockpit lamps and plasma display back through the +running app — see [`docs/FEEDBACK.md`](docs/FEEDBACK.md). The `RioGamepad` virtual HID driver is built (KMDF + VHF), **test-signed, installed, and verified**: it enumerates in `joy.cpl`, and the C# HID feeder (`DeviceIoControl` → `RioGamepad.sys`) drives its axes, buttons, and hat end-to-end (see diff --git a/docs/FEEDBACK.md b/docs/FEEDBACK.md new file mode 100644 index 0000000..e3a4968 --- /dev/null +++ b/docs/FEEDBACK.md @@ -0,0 +1,148 @@ +# Game feedback endpoint (game → RIOJoy → cockpit) + +Phase 9 lets external programs drive the cockpit's **output** hardware through +the running RIOJoy tray app: the 96 lamps (with board-side flash) and the +plasma/VFD text display. Anything that can write a line of text to a named pipe +or a UDP socket can use it — a DCS `Export.lua`, a SimHub plugin, a game mod, a +PowerShell one-liner. XInput **rumble** is mapped separately (no client needed; +see [Rumble → lamps](#rumble--lamps)). + +## Endpoints + +| Transport | Address | Default | +|---|---|---| +| Named pipe | `\\.\pipe\riojoy-feedback` | **on** | +| UDP (loopback only) | `udp://127.0.0.1:` | off (`UdpPort` unset) | + +Both feed the same line protocol; the pipe accepts up to 4 concurrent clients. +Configured app-wide in `%APPDATA%\RIOJoy\config.json`: + +```json +{ "Feedback": { "PipeEnabled": true, "PipeName": "riojoy-feedback", "UdpPort": 19910 } } +``` + +Omitting the `Feedback` section entirely = pipe on under the default name, UDP +off. Endpoint config is read once at the first profile activation; changes take +effect on app restart. + +The endpoint is **app-lifetime**: clients keep their connection across profile +switches and dormancy. Whether commands *apply* is per-profile (see +[Gating](#gating)). + +## Line protocol + +One command per line. LF or CRLF line endings; on UDP, one datagram carries one +or more complete lines and the end of the datagram terminates the last line +(no trailing LF needed, no fragments across datagrams). Keywords are +case-insensitive. Lines over 256 bytes and UDP datagrams over 4 KB are dropped. + +``` +# comment (also ;) +lamp set one lamp +lamp-all set every lamp +plasma text [x y] write text to the plasma display +plasma clear clear the plasma display +``` + +- **``** — RIO lamp address, decimal or `0x` hex. Valid: `0x00–0x47` + (the 72 buttons), `0x50–0x5F` (keypad 0), `0x60–0x6F` (keypad 1). See + [PROTOCOL.md §5](PROTOCOL.md). +- **``** — either words: `[solid|slow|med|fast] off|dim|bright` (flash + defaults to `solid`), or a raw state byte `0x00–0x3F` (the `LampRequest` + state, [PROTOCOL.md §3](PROTOCOL.md)). `lamp 0x12 fast bright` = flash-fast + at full brightness. **The board sustains the blink** — one command starts a + flash, another (`solid dim`, `off`, …) ends it. +- **`plasma text`** — the rest of the line is the text, or quote it + (`"VIPER 1-1"`; quotes stripped, no escapes). Two leading *numeric* tokens + are a cursor position `x y`; omitted (or `0 0`) auto-fits and centers + (`PlasmaPosText`). To display something that starts with two numbers, quote + it. Encoding is **Latin-1** (one byte = one char, the plasma's wire + encoding) — do not send UTF-8 for accented characters. + +Malformed lines are dropped and counted (first few are logged); they **never** +cost a client its connection. The endpoint sends no replies. + +## Gating + +| RIOJoy state | Listeners | Commands | +|---|---|---| +| Profile active, profile has a `Feedback` section | up | applied | +| Profile active, no `Feedback` section | up | dropped | +| Dormant / native game owns the ports | up | dropped | +| Editor session | up | dropped | + +Per-profile, in the profile's JSON: + +```json +{ + "Feedback": { + "AllowLampCommands": true, + "AllowPlasmaText": true, + "Rumble": { "LargeMotorLamps": [18, 19], "SmallMotorLamps": [96], "Threshold": 24 } + } +} +``` + +A profile without `"Feedback"` never applies inbound commands. (Editor UI for +these settings is a Phase 9 remaining item — edit the JSON for now.) + +**Lamp ownership:** a `lamp` write to an address the profile maps as a *lighted +button* (`iRIO` bit `0x8000`, `HasLamp`) is dropped — the input router owns +those lamps (bright on press / dim on release) and feedback must not fight it. +Such drops are logged once per address. `lamp-all` silently skips owned lamps. + +**Rate:** lamp commands share the 9600-baud RIO link with the ~55 ms analog +poll, so RIOJoy coalesces per-lamp state (latest wins) and sends at most one +*changed* lamp per 25 ms. Spam freely — identical states cost nothing — but a +`lamp-all` sweep takes ~3 s to fully land. Plasma writes are single-flight, +latest-pending-wins: flood-updating a score means only the newest pending text +is written. + +## Rumble → lamps + +With a `Rumble` config (above) and the ViGEm pad active, XInput vibration set +by the game flashes the configured lamps — works with **unmodified games**: +below `Threshold` (0–255) the lamps are off; the rest of the range maps to +slow / med / fast flash at full brightness, per motor. Constant rumble costs +one lamp command (the board blinks on its own). net48 flavor only (the XP +flavor has no ViGEm). + +## Client snippets + +DCS-style `Export.lua` (a pipe opens as a file on Windows): + +```lua +local rio = io.open("\\\\.\\pipe\\riojoy-feedback", "w") +-- in your export tick: +if masterCaution then rio:write("lamp 0x12 fast bright\n") +else rio:write("lamp 0x12 off\n") end +rio:write('plasma text "' .. callsign .. '"\n') +rio:flush() +``` + +PowerShell, pipe (hand-testing on the cabinet): + +```powershell +$p = New-Object IO.Pipes.NamedPipeClientStream '.', 'riojoy-feedback', ([IO.Pipes.PipeDirection]::Out) +$p.Connect(2000) +$w = New-Object IO.StreamWriter $p, ([Text.Encoding]::GetEncoding(28591)) +$w.WriteLine('lamp 0x12 fast bright'); $w.WriteLine('plasma text "VIPER 1-1"'); $w.Flush() +``` + +PowerShell, UDP (with `"UdpPort": 19910` configured): + +```powershell +$u = New-Object Net.Sockets.UdpClient +$b = [Text.Encoding]::GetEncoding(28591).GetBytes("lamp 0x12 fast bright`nplasma text 42 kills") +$u.Send($b, $b.Length, '127.0.0.1', 19910) | Out-Null +``` + +## Implementation map + +`src/RioJoy.Core/Feedback/`: `FeedbackLineParser` (grammar → `FeedbackCommand`), +`FeedbackLineBuffer` (bytes → lines), `FeedbackPipeServer` / `FeedbackUdpListener` +(transports), `CoalescingLampScheduler` (the rate governor — all feedback lamp +traffic goes through it, never straight to `ILampSink`), `FeedbackRouter` +(gating + ownership + plasma single-flight), `RumbleLampAdapter`, and +`FeedbackService` (the façade `RioCoordinator` owns). Tests mirror the layout in +`tests/RioJoy.Core.Tests/Feedback/`. diff --git a/docs/PLAN.md b/docs/PLAN.md index 1a6ec4b..3ce8365 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -183,8 +183,11 @@ Implemented in `src/RioJoy.Core/Calibration` + `Plasma` (105 xUnit tests total): - `PlasmaCommands` ports the `CPlasma` ESC command set (clear/cursor/font/attr/box draw+fill/text) + `GetFontSize` + the `PlasmaPosText` auto-fit/centering; `PlasmaDisplay` writes them over the secondary COM transport. -- ⏳ **Remaining:** hardware verification of axis feel + plasma output; the - game-specific `PlasmaScoreDraw` layout is profile content (Phase 5/7). +- ⏳ **Remaining:** hardware verification of axis feel + plasma output. Runtime + plasma wiring (secondary port open, greeting, teardown) landed in **Phase 9**; + the legacy game-specific `PlasmaScoreDraw` layout is superseded by the Phase 9 + feedback endpoint (external clients draw score/status content — + [`docs/FEEDBACK.md`](FEEDBACK.md)). ### Phase 5 — Tray app + profiles — code-complete ✅ Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tray` @@ -431,6 +434,58 @@ XP consumes pre-rendered wallpapers. computers, adds shortcuts); the single dist zip carries everything needed for both XP and 10/11, including offline redistributables. +### Phase 9 — Game feedback (game → cockpit) — code-complete ✅ +Inbound feedback endpoint + plasma runtime wiring + rumble→lamp mapping, in +`src/RioJoy.Core/Feedback` (425 xUnit tests total across the suite); protocol +spec + client snippets in [`docs/FEEDBACK.md`](FEEDBACK.md). Delivers the +§Profiles promises "Lamp behavior" and "Plasma/VFD content (or 'off')". +- **Endpoint**: `FeedbackPipeServer` serves `\\.\pipe\riojoy-feedback` + (read-only — no replies ever, which sidesteps the 0-buffer pipe write + deadlock class; ≤4 concurrent clients; reconnect forever; vRIO's + `VRioPipeService` server pattern incl. the poke-connect stop) and + `FeedbackUdpListener` binds loopback-only UDP (off by default, + `AppConfig.Feedback.UdpPort` — the transport sim export scripts speak + natively). One shared text line protocol: `FeedbackLineParser` + + `FeedbackLineBuffer` (Latin-1, LF/CRLF, forgiving — malformed lines drop and + log, never the connection). `FeedbackService` façades the lot; it lives in + `RioCoordinator` for the **app lifetime**, so clients keep their connection + across profile switches and dormancy — only command *application* is gated. +- **Rate governor**: `CoalescingLampScheduler` — per-address desired/last-sent + shadow state, at most one *changed* lamp per 25 ms tick, round-robin. All + feedback lamp traffic (pipe/UDP and rumble) posts here; nothing feedback-side + calls `ILampSink` directly, because every lamp command crosses the link's + stop-and-wait command gate (~150 ms worst case) shared with the ~55 ms + analog poll. +- **Routing/precedence**: `FeedbackRouter` — per-profile gating + (`RioProfile.Feedback`, null = feedback off; `AllowLampCommands`/ + `AllowPlasmaText`), profile-owned lamps (`HasLamp`) protected from + press/release fights (dropped, logged once per address per attach), plasma + writes single-flight with a latest-pending-wins slot. +- **Plasma wired at last** (closes the Phase 4 ⏳ wiring): `RioCoordinator. + Activate` opens `PlasmaComPort ?? DefaultPlasmaComPort` via the transport + factory (`pipe:` endpoints work for benchless testing; `"off"`/empty skips; + failure becomes a status suffix and never breaks activation), shows + `PlasmaGreeting` auto-centered, blanks + releases the port on teardown (the + native games open this port too). `PlasmaDisplay` gained its missing write + lock — `PosTextAsync` is five transport writes, and concurrent callers used + to interleave ESC fragments (`PlasmaDisplayTests` pins both the sequence and + the no-interleave guarantee). +- **Rumble → lamps** (net48 only): `ViGEmJoystickSink.RumbleChanged` (plain + byte delegate over ViGEm's `FeedbackReceived`; fires on a ViGEm-owned + thread) → `RumbleLampAdapter`: off below `Threshold`, then slow/med/fast + thirds at full brightness per motor, posting only state **changes** so + XInput's identical-value spam costs nothing — the board sustains the blink + from the state byte. Works with unmodified games that set XInput vibration. +- Config: `FeedbackEndpointConfig` (app-wide) + `ProfileFeedbackConfig` / + `RumbleLampConfig` (per-profile); nullable sections = off/defaults, keeping + pre-Phase-9 JSON byte-compatible (round-trip, unset-stays-null, and + shipped-profile cases in `ConfigStoreTests`). +- ⏳ **Remaining:** on-cabinet verification (real lamps + plasma glass, link + feel under game load); timed flash-then-restore effects (the scheduler's + shadow state is the designed hook); editor UI for the per-profile feedback + settings (JSON-only today); shipped client examples (SimHub plugin / DCS + export script) beyond the FEEDBACK.md snippets. + --- ## Open items / risks diff --git a/src/RioJoy.Core/Compat/TaskCompat.cs b/src/RioJoy.Core/Compat/TaskCompat.cs index c4ce827..b5513ca 100644 --- a/src/RioJoy.Core/Compat/TaskCompat.cs +++ b/src/RioJoy.Core/Compat/TaskCompat.cs @@ -10,6 +10,9 @@ namespace RioJoy.Core.Compat; internal static class TaskCompat { #if NET40 + /// net40 has no Task.CompletedTask. + public static Task CompletedTask { get; } = TaskEx.FromResult(true); + public static Task Run(Action action) => TaskEx.Run(action); public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) => @@ -25,6 +28,8 @@ internal static class TaskCompat return TaskEx.FromResult(true); } #else + public static Task CompletedTask => Task.CompletedTask; + public static Task Run(Action action) => Task.Run(action); public static Task Delay(TimeSpan delay, CancellationToken cancellationToken) => diff --git a/src/RioJoy.Core/Feedback/CoalescingLampScheduler.cs b/src/RioJoy.Core/Feedback/CoalescingLampScheduler.cs new file mode 100644 index 0000000..410acbf --- /dev/null +++ b/src/RioJoy.Core/Feedback/CoalescingLampScheduler.cs @@ -0,0 +1,109 @@ +using RioJoy.Core.Compat; +using RioJoy.Core.Mapping; + +namespace RioJoy.Core.Feedback; + +/// +/// The rate governor between feedback lamp traffic and the 9600-baud RIO link. +/// Every lamp command crosses the link's stop-and-wait command gate (worst case +/// ~150 ms with retransmits) shared with the ~55 ms analog poll, and nothing +/// downstream coalesces — so feedback paths must post here, never call +/// directly. Keeps a desired/last-sent shadow of all +/// 112 addresses; the pump sends at most one changed lamp per tick +/// (round-robin for fairness), so bursts of identical states collapse to +/// nothing and a flooding client cannot starve the analog poll. One instance +/// per profile activation, so shadow state never leaks across profiles. +/// +public sealed class CoalescingLampScheduler +{ + /// Default pump tick: ≤40 lamp commands/s at 9600 baud stays polite. + public static readonly TimeSpan DefaultSendInterval = TimeSpan.FromMilliseconds(25); + + private readonly ILampSink _sink; + private readonly TimeSpan _sendInterval; + private readonly object _gate = new(); + private readonly byte?[] _desired = new byte?[RioAddress.TableSize]; + private readonly byte?[] _lastSent = new byte?[RioAddress.TableSize]; + private int _cursor; + + public CoalescingLampScheduler(ILampSink sink, TimeSpan? sendInterval = null) + { + _sink = sink ?? throw new ArgumentNullException(nameof(sink)); + TimeSpan interval = sendInterval ?? DefaultSendInterval; + // Floor at 1 ms: Task.Delay(0) completes synchronously, which would turn + // RunAsync into an infinite synchronous loop that never yields. + _sendInterval = interval > TimeSpan.Zero ? interval : TimeSpan.FromMilliseconds(1); + } + + /// + /// Set the desired state for one lamp. Thread-safe and non-blocking (safe + /// from the ViGEm callback thread and pipe reader threads). Invalid + /// addresses are ignored — rumble config addresses arrive here unvalidated. + /// + public void Post(int address, byte state) + { + if (!RioAddress.IsValid(address)) + return; + lock (_gate) + _desired[address] = state; + } + + /// Set the desired state for every valid lamp address. + public void PostAll(byte state) + { + lock (_gate) + { + for (int a = 0; a < RioAddress.TableSize; a++) + { + if (RioAddress.IsValid(a)) + _desired[a] = state; + } + } + } + + /// + /// The pump loop: send one changed lamp, sleep a tick, repeat until + /// cancelled. Exits cleanly on cancellation (started fire-and-forget, so it + /// must never fault). + /// + public async Task RunAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + SendNextChanged(); + await TaskCompat.Delay(_sendInterval, ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Normal shutdown. + } + } + + private void SendNextChanged() + { + int address = -1; + byte state = 0; + lock (_gate) + { + for (int i = 0; i < _desired.Length; i++) + { + int a = (_cursor + i) % _desired.Length; + if (_desired[a] is byte want && _lastSent[a] != want) + { + address = a; + state = want; + _lastSent[a] = want; + _cursor = a + 1; // resume after this one — round-robin fairness + break; + } + } + } + + // Outside the lock: SetLamp is fire-and-forget but no reason to hold it. + if (address >= 0) + _sink.SetLamp(address, state); + } +} diff --git a/src/RioJoy.Core/Feedback/FeedbackCommand.cs b/src/RioJoy.Core/Feedback/FeedbackCommand.cs new file mode 100644 index 0000000..9a02f6c --- /dev/null +++ b/src/RioJoy.Core/Feedback/FeedbackCommand.cs @@ -0,0 +1,43 @@ +namespace RioJoy.Core.Feedback; + +/// What a parsed feedback line asks the cockpit to do. +public enum FeedbackCommandKind +{ + /// Set one lamp to a state (lamp <addr> <state>). + Lamp, + + /// Set every valid lamp address to a state (lamp-all <state>). + LampAll, + + /// Write text to the plasma display (plasma text [x y] <text>). + PlasmaText, + + /// Clear the plasma display (plasma clear). + PlasmaClear, +} + +/// +/// One inbound cockpit-feedback command, produced by +/// from a protocol line (docs/FEEDBACK.md) and +/// consumed by the feedback router. Addresses are already validated against the +/// RIO address space; lamp states are complete state bytes +/// (). +/// +public sealed record FeedbackCommand +{ + public FeedbackCommandKind Kind { get; init; } + + /// RIO lamp address ( only). + public int Address { get; init; } + + /// Lamp state byte (/). + public byte LampState { get; init; } + + /// Display text ( only). + public string? Text { get; init; } + + /// Plasma cursor position; (0,0) = auto-fit/center (PlasmaPosText). + public byte X { get; init; } + + public byte Y { get; init; } +} diff --git a/src/RioJoy.Core/Feedback/FeedbackConfig.cs b/src/RioJoy.Core/Feedback/FeedbackConfig.cs new file mode 100644 index 0000000..f922cbc --- /dev/null +++ b/src/RioJoy.Core/Feedback/FeedbackConfig.cs @@ -0,0 +1,61 @@ +namespace RioJoy.Core.Feedback; + +/// +/// App-level inbound-feedback endpoint settings +/// (; null there = these defaults: +/// named pipe on under , UDP off). The endpoint is +/// app-lifetime — clients keep their connection across profile switches and +/// dormancy; per-profile settings only gate what gets applied +/// (). Serialized into config.json, so no +/// vendor types. +/// +public sealed record FeedbackEndpointConfig +{ + public const string DefaultPipeName = "riojoy-feedback"; + + /// Listen on \\.\pipe\<PipeName> for feedback lines. + public bool PipeEnabled { get; init; } = true; + + public string PipeName { get; init; } = DefaultPipeName; + + /// + /// UDP loopback port to also listen on; null = UDP off. Datagrams carry one + /// or more complete protocol lines (docs/FEEDBACK.md). + /// + public int? UdpPort { get; init; } +} + +/// +/// Per-profile feedback application settings +/// (; null there = inbound feedback +/// is not applied for this profile — commands are dropped). +/// +public sealed record ProfileFeedbackConfig +{ + /// Apply inbound lamp/lamp-all commands. + public bool AllowLampCommands { get; init; } = true; + + /// Apply inbound plasma commands. + public bool AllowPlasmaText { get; init; } = true; + + /// XInput rumble → lamp flash mapping; null = off. + public RumbleLampConfig? Rumble { get; init; } +} + +/// +/// Maps ViGEm pad vibration onto cockpit lamps: each motor drives its listed +/// RIO lamp addresses through flash states scaled by intensity (off below +/// , then slow/med/fast thirds — the board sustains the +/// blink, so constant rumble costs one lamp command). +/// +public sealed record RumbleLampConfig +{ + /// RIO lamp addresses driven by the large (low-frequency) motor. + public List LargeMotorLamps { get; init; } = new(); + + /// RIO lamp addresses driven by the small (high-frequency) motor. + public List SmallMotorLamps { get; init; } = new(); + + /// Motor value (0-255) below which the lamps turn off. + public byte Threshold { get; init; } = 24; +} diff --git a/src/RioJoy.Core/Feedback/FeedbackLineBuffer.cs b/src/RioJoy.Core/Feedback/FeedbackLineBuffer.cs new file mode 100644 index 0000000..3c85a30 --- /dev/null +++ b/src/RioJoy.Core/Feedback/FeedbackLineBuffer.cs @@ -0,0 +1,76 @@ +using System.Text; + +namespace RioJoy.Core.Feedback; + +/// +/// Assembles raw endpoint bytes into protocol lines for +/// : LF terminates a line, a preceding CR is +/// stripped (CRLF and LF both work), and bytes decode as Latin-1 (one byte = +/// one char — the plasma wire encoding, so every byte 0x20-0xFF round-trips). +/// A line longer than is discarded through its next +/// LF, which keeps a binary client that connected by mistake from ballooning +/// the buffer. Not thread-safe; each connection/datagram reader owns one. +/// +public sealed class FeedbackLineBuffer +{ + public const int MaxLineLength = 256; + + private readonly StringBuilder _line = new(); + private bool _discarding; + + /// Feed bytes; returns the completed lines. + public IEnumerable Feed(byte[] buffer, int count) + { + List? lines = null; + for (int i = 0; i < count; i++) + { + byte b = buffer[i]; + if (b == (byte)'\n') + { + if (!_discarding) + { + if (_line.Length > 0 && _line[_line.Length - 1] == '\r') + _line.Length--; + (lines ??= new List()).Add(_line.ToString()); + } + _line.Length = 0; + _discarding = false; + } + else if (!_discarding) + { + if (_line.Length >= MaxLineLength) + { + _line.Length = 0; + _discarding = true; + } + else + { + _line.Append((char)b); // Latin-1: byte == code point + } + } + } + return lines ?? Enumerable.Empty(); // net40: no Array.Empty + } + + /// + /// End-of-datagram flush (UDP): the remaining buffered content is one final + /// line even without a trailing LF. Returns when + /// there is nothing buffered. Pipe readers never flush — they wait for LF. + /// + public string? Flush() + { + if (_discarding) + { + _discarding = false; + _line.Length = 0; + return null; + } + if (_line.Length == 0) + return null; + if (_line[_line.Length - 1] == '\r') + _line.Length--; + string s = _line.ToString(); + _line.Length = 0; + return s.Length == 0 ? null : s; + } +} diff --git a/src/RioJoy.Core/Feedback/FeedbackLineParser.cs b/src/RioJoy.Core/Feedback/FeedbackLineParser.cs new file mode 100644 index 0000000..ae4247a --- /dev/null +++ b/src/RioJoy.Core/Feedback/FeedbackLineParser.cs @@ -0,0 +1,296 @@ +using System.Globalization; +using RioJoy.Core.Mapping; +using RioJoy.Core.Protocol; + +namespace RioJoy.Core.Feedback; + +/// +/// Parses one line of the inbound feedback protocol (docs/FEEDBACK.md) into a +/// . Pure and forgiving: keywords are +/// case-insensitive, malformed lines produce an error string (the caller logs +/// and drops them — a bad line must never cost a client its connection). +/// This is also the validation boundary for lamp addresses: +/// SerialLampSink casts to byte unchecked, so out-of-range +/// addresses are rejected here. +/// +public static class FeedbackLineParser +{ + /// + /// Parse one line. Returns with a command when the + /// line is actionable. Returns with + /// for blank/comment lines + /// (skip silently) or an error message for malformed ones (log + drop). + /// + public static bool TryParse(string line, out FeedbackCommand? command, out string? error) + { + command = null; + error = null; + if (string.IsNullOrEmpty(line)) + return false; + + string s = line.Trim(); + if (s.Length == 0 || s[0] == '#' || s[0] == ';') + return false; // blank or comment + + int pos = 0; + string keyword = NextToken(s, ref pos)!; + switch (keyword.ToLowerInvariant()) + { + case "lamp": + return TryParseLamp(s, pos, all: false, out command, out error); + case "lamp-all": + return TryParseLamp(s, pos, all: true, out command, out error); + case "plasma": + return TryParsePlasma(s, pos, out command, out error); + default: + error = $"unknown command '{keyword}'"; + return false; + } + } + + private static bool TryParseLamp( + string s, int pos, bool all, out FeedbackCommand? command, out string? error) + { + command = null; + error = null; + int address = 0; + + if (!all) + { + string? addrToken = NextToken(s, ref pos); + if (addrToken is null) + { + error = "lamp needs an address and a state"; + return false; + } + if (!TryParseNumber(addrToken, out address)) + { + error = $"bad lamp address '{addrToken}'"; + return false; + } + if (!RioAddress.IsValid(address)) + { + error = $"lamp address 0x{address:X2} out of range " + + "(valid: 0x00-0x47, 0x50-0x5F, 0x60-0x6F)"; + return false; + } + } + + string? first = NextToken(s, ref pos); + if (first is null) + { + error = "missing lamp state"; + return false; + } + string? second = NextToken(s, ref pos); + if (NextToken(s, ref pos) is string extra) + { + error = $"unexpected token '{extra}'"; + return false; + } + + byte state; + if (second is null) + { + // Single token: a raw state byte, or a brightness word (flash = solid). + if (TryParseNumber(first, out int raw)) + { + if (raw is < 0 or > 0x3F) + { + error = $"raw lamp state must be 0x00-0x3F, got '{first}'"; + return false; + } + state = (byte)raw; + } + else if (TryBrightness(first, out LampField1 f1, out LampField2 f2)) + { + state = RioLampState.Compose(LampFlash.Solid, f1, f2); + } + else + { + error = $"unrecognized lamp state '{first}'"; + return false; + } + } + else + { + if (!TryFlash(first, out LampFlash flash)) + { + error = $"unrecognized flash mode '{first}' (solid|slow|med|fast)"; + return false; + } + if (!TryBrightness(second, out LampField1 f1, out LampField2 f2)) + { + error = $"unrecognized brightness '{second}' (off|dim|bright)"; + return false; + } + state = RioLampState.Compose(flash, f1, f2); + } + + command = new FeedbackCommand + { + Kind = all ? FeedbackCommandKind.LampAll : FeedbackCommandKind.Lamp, + Address = address, + LampState = state, + }; + return true; + } + + private static bool TryParsePlasma( + string s, int pos, out FeedbackCommand? command, out string? error) + { + command = null; + error = null; + + string? sub = NextToken(s, ref pos); + if (sub is null) + { + error = "plasma needs a subcommand (text|clear)"; + return false; + } + + switch (sub.ToLowerInvariant()) + { + case "clear": + if (NextToken(s, ref pos) is string extra) + { + error = $"unexpected token '{extra}'"; + return false; + } + command = new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear }; + return true; + + case "text": + return TryParsePlasmaText(s, pos, out command, out error); + + default: + error = $"unknown plasma subcommand '{sub}'"; + return false; + } + } + + private static bool TryParsePlasmaText( + string s, int pos, out FeedbackCommand? command, out string? error) + { + command = null; + error = null; + + // Optional "x y" position: taken only when the first TWO tokens are both + // numeric (so `plasma text 42` displays "42"; use quotes to force text). + byte x = 0, y = 0; + int textStart = pos; + int peek = pos; + string? t1 = NextToken(s, ref peek); + if (t1 is not null && TryParseNumber(t1, out int xv)) + { + string? t2 = NextToken(s, ref peek); + if (t2 is not null && TryParseNumber(t2, out int yv)) + { + if (xv is < 0 or > 255 || yv is < 0 or > 255) + { + error = $"plasma position ({xv},{yv}) out of range (0-255)"; + return false; + } + x = (byte)xv; + y = (byte)yv; + textStart = peek; + } + // t1 numeric but t2 not: the whole remainder (from textStart) is text + } + + if (!TryTakeText(s, textStart, out string? text, out error)) + return false; + if (text is null) + { + error = "plasma text needs text to display"; + return false; + } + + command = new FeedbackCommand + { + Kind = FeedbackCommandKind.PlasmaText, + Text = text, + X = x, + Y = y, + }; + return true; + } + + // Rest-of-line text: quoted (quotes stripped, no escapes, nothing may follow + // the closing quote) or the trimmed remainder. Null = nothing there. + private static bool TryTakeText(string s, int pos, out string? text, out string? error) + { + text = null; + error = null; + + while (pos < s.Length && char.IsWhiteSpace(s[pos])) + pos++; + if (pos >= s.Length) + return true; + + if (s[pos] == '"') + { + int close = s.IndexOf('"', pos + 1); + if (close < 0) + { + error = "unterminated quote in plasma text"; + return false; + } + if (close + 1 < s.Length && s.Substring(close + 1).Trim().Length != 0) + { + error = "unexpected content after closing quote"; + return false; + } + text = s.Substring(pos + 1, close - pos - 1); + return true; + } + + text = s.Substring(pos).TrimEnd(); + return true; + } + + private static string? NextToken(string s, ref int pos) + { + while (pos < s.Length && char.IsWhiteSpace(s[pos])) + pos++; + if (pos >= s.Length) + return null; + int start = pos; + while (pos < s.Length && !char.IsWhiteSpace(s[pos])) + pos++; + return s[start..pos]; + } + + // Decimal, or hex with an 0x/0X prefix. + private static bool TryParseNumber(string token, out int value) + { + if (token.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return int.TryParse( + token.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value); + return int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out value); + } + + private static bool TryFlash(string token, out LampFlash flash) + { + switch (token.ToLowerInvariant()) + { + case "solid": flash = LampFlash.Solid; return true; + case "slow": flash = LampFlash.FlashSlow; return true; + case "med": flash = LampFlash.FlashMed; return true; + case "fast": flash = LampFlash.FlashFast; return true; + default: flash = LampFlash.Solid; return false; + } + } + + // Brightness words set both fields, matching SolidOff/SolidDim/SolidBright. + private static bool TryBrightness(string token, out LampField1 f1, out LampField2 f2) + { + switch (token.ToLowerInvariant()) + { + case "off": f1 = LampField1.Off; f2 = LampField2.Off; return true; + case "dim": f1 = LampField1.Dim; f2 = LampField2.Dim; return true; + case "bright": f1 = LampField1.Bright; f2 = LampField2.Bright; return true; + default: f1 = LampField1.Off; f2 = LampField2.Off; return false; + } + } +} diff --git a/src/RioJoy.Core/Feedback/FeedbackPipeServer.cs b/src/RioJoy.Core/Feedback/FeedbackPipeServer.cs new file mode 100644 index 0000000..8a32f8d --- /dev/null +++ b/src/RioJoy.Core/Feedback/FeedbackPipeServer.cs @@ -0,0 +1,220 @@ +using System.IO.Pipes; + +namespace RioJoy.Core.Feedback; + +/// +/// Named-pipe listener for the inbound feedback protocol: serves +/// \\.\pipe\<name>, reassembles lines +/// (), and hands each to the owner — parsing +/// and routing live in , so this class is pure +/// transport. Modeled on vRIO's VRioPipeService (dedicated background +/// threads — net40 has no WaitForConnectionAsync; throwaway poke-connect +/// on stop because a pending WaitForConnection can survive Dispose on +/// net48), with two deliberate differences: the pipe is +/// — the server never writes, so the 0-byte +/// pipe-buffer write deadlock class cannot occur and no reply path exists — and +/// up to clients may stay connected at once (a sim +/// export script and a SimHub plugin both live here). Clients reconnect +/// forever; a malformed or overlong line never costs a client its connection. +/// +public sealed class FeedbackPipeServer : IDisposable +{ + /// Concurrent client cap (pipe instances of the served name). + public const int MaxClients = 4; + + private readonly string _pipeName; + private readonly Action _onLine; + private readonly Action? _log; + private readonly SemaphoreSlim _slots = new(MaxClients, MaxClients); + private readonly object _stateGate = new(); + private readonly List _open = new(); + private readonly List _readers = new(); + private Thread? _accept; + private volatile bool _running; + + public FeedbackPipeServer(string pipeName, Action onLine, Action? log = null) + { + if (string.IsNullOrWhiteSpace(pipeName)) + throw new ArgumentException("Pipe name is required.", nameof(pipeName)); + _pipeName = pipeName; + _onLine = onLine ?? throw new ArgumentNullException(nameof(onLine)); + _log = log; + } + + /// The served pipe name (without the \\.\pipe\ prefix). + public string PipeName => _pipeName; + + /// Start listening (idempotent). Clients may come and go forever. + public void Start() + { + if (_running) + return; + _running = true; + + _accept = new Thread(AcceptLoop) + { + IsBackground = true, + Name = $"RIOJoy feedback pipe ({_pipeName})", + }; + _accept.Start(); + _log?.Invoke($@"feedback: listening on \\.\pipe\{_pipeName}"); + } + + public void Dispose() + { + if (!_running) + return; + _running = false; + + // A WaitForConnection pending on a disposed stream can survive the + // Dispose on net48; a throwaway client connect releases it either way. + try + { + using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out); + poke.Connect(100); + } + catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { } + + NamedPipeServerStream[] open; + Thread[] readers; + lock (_stateGate) + { + open = _open.ToArray(); + _open.Clear(); + readers = _readers.ToArray(); + _readers.Clear(); + } + foreach (NamedPipeServerStream pipe in open) + { + try { pipe.Dispose(); } + catch (IOException) { } + } + + _accept?.Join(1000); + _accept = null; + foreach (Thread reader in readers) + reader.Join(1000); + } + + private void AcceptLoop() + { + bool busyLogged = false; // log a name collision once, not per retry + + while (_running) + { + // At capacity, park until a reader frees its slot (timed, so + // shutdown can't wedge on a missed release). + if (!_slots.Wait(200)) + continue; + if (!_running) + { + _slots.Release(); + return; + } + + NamedPipeServerStream pipe; + try + { + pipe = new NamedPipeServerStream(_pipeName, PipeDirection.In, MaxClients, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Name already served — most likely a second RIOJoy instance. + _slots.Release(); + if (!busyLogged) + { + busyLogged = true; + _log?.Invoke($@"feedback: \\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying"); + } + for (int i = 0; i < 20 && _running; i++) + Thread.Sleep(100); + continue; + } + busyLogged = false; + lock (_stateGate) + _open.Add(pipe); + + try + { + pipe.WaitForConnection(); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException) + { + Drop(pipe); + continue; // disposed by Dispose(), or the client vanished mid-connect + } + + if (!_running) + { + Drop(pipe); + return; + } + + var reader = new Thread(() => ReadUntilDisconnect(pipe)) + { + IsBackground = true, + Name = $"RIOJoy feedback pipe reader ({_pipeName})", + }; + lock (_stateGate) + _readers.Add(reader); + reader.Start(); // the reader owns the slot + stream from here + } + } + + private void ReadUntilDisconnect(NamedPipeServerStream pipe) + { + var buffer = new byte[512]; + var lines = new FeedbackLineBuffer(); + try + { + while (_running) + { + int n; + try + { + n = pipe.Read(buffer, 0, buffer.Length); + } + catch (Exception ex) when ( + ex is IOException or ObjectDisposedException or InvalidOperationException) + { + return; // client gone or shutdown + } + + if (n == 0) + return; // client closed its end + + foreach (string line in lines.Feed(buffer, n)) + Handle(line); + } + } + finally + { + Drop(pipe); + lock (_stateGate) + _readers.Remove(Thread.CurrentThread); + } + } + + private void Handle(string line) + { + try + { + _onLine(line); + } + catch (Exception ex) + { + // The line sink must never kill a reader; log and keep serving. + _log?.Invoke($"feedback: line handler failed: {ex.Message}"); + } + } + + private void Drop(NamedPipeServerStream pipe) + { + lock (_stateGate) + _open.Remove(pipe); + try { pipe.Dispose(); } + catch (IOException) { } + _slots.Release(); + } +} diff --git a/src/RioJoy.Core/Feedback/FeedbackRouter.cs b/src/RioJoy.Core/Feedback/FeedbackRouter.cs new file mode 100644 index 0000000..4d7080f --- /dev/null +++ b/src/RioJoy.Core/Feedback/FeedbackRouter.cs @@ -0,0 +1,198 @@ +using RioJoy.Core.Mapping; +using RioJoy.Core.Plasma; + +namespace RioJoy.Core.Feedback; + +/// +/// Applies inbound s to the active profile's +/// outputs. The listeners dispatch here from their reader threads; the target +/// (scheduler + map + plasma + per-profile config) is attached on profile +/// activation and detached on teardown — detached, everything drops silently +/// (dormancy and native-game yield are normal, not errors). +/// +/// Precedence: a lamp write to an address whose map entry has +/// is dropped — the +/// owns those lamps (bright on press / dim on release) and feedback must not +/// fight it. Such drops are logged once per address per attach so a +/// misconfigured client is diagnosable. lamp-all silently skips +/// profile-owned lamps for the same reason. +/// +/// Plasma writes are single-flight with a latest-pending-wins slot, so a +/// flooding client cannot queue unbounded 9600-baud text writes. +/// +public sealed class FeedbackRouter +{ + private sealed class Target + { + public Target(CoalescingLampScheduler lamps, RioInputMap map, + PlasmaDisplay? plasma, ProfileFeedbackConfig config) + { + Lamps = lamps; + Map = map; + Plasma = plasma; + Config = config; + } + + public CoalescingLampScheduler Lamps { get; } + public RioInputMap Map { get; } + public PlasmaDisplay? Plasma { get; } + public ProfileFeedbackConfig Config { get; } + public HashSet LoggedOwnedDrops { get; } = new(); + } + + private readonly object _gate = new(); + private Target? _target; + private bool _plasmaBusy; + private FeedbackCommand? _plasmaPending; + private long _dropped; + + /// Diagnostics (dropped profile-owned lamp writes, plasma faults). + public event Action? Logged; + + /// Commands dropped for any reason (detached, disallowed, profile-owned). + public long DroppedCommands => Interlocked.Read(ref _dropped); + + /// Point feedback at the just-activated profile's outputs. + public void Attach(CoalescingLampScheduler lamps, RioInputMap map, + PlasmaDisplay? plasma, ProfileFeedbackConfig config) + { + if (lamps is null) throw new ArgumentNullException(nameof(lamps)); + if (map is null) throw new ArgumentNullException(nameof(map)); + if (config is null) throw new ArgumentNullException(nameof(config)); + + lock (_gate) + { + _target = new Target(lamps, map, plasma, config); + _plasmaPending = null; // pending text belonged to the previous profile + } + } + + /// Drop the target; subsequent commands are dropped (counted). + public void Detach() + { + lock (_gate) + { + _target = null; + _plasmaPending = null; + } + } + + /// Apply one command. Thread-safe, non-blocking. + public void Dispatch(FeedbackCommand command) + { + if (command is null) + return; + + Target? target; + lock (_gate) + target = _target; + if (target is null) + { + Interlocked.Increment(ref _dropped); + return; + } + + switch (command.Kind) + { + case FeedbackCommandKind.Lamp: + DispatchLamp(target, command); + break; + case FeedbackCommandKind.LampAll: + DispatchLampAll(target, command); + break; + case FeedbackCommandKind.PlasmaText: + case FeedbackCommandKind.PlasmaClear: + DispatchPlasma(target, command); + break; + } + } + + private void DispatchLamp(Target target, FeedbackCommand command) + { + if (!target.Config.AllowLampCommands) + { + Interlocked.Increment(ref _dropped); + return; + } + if (target.Map[command.Address].HasLamp) + { + Interlocked.Increment(ref _dropped); + bool firstTime; + lock (_gate) + firstTime = target.LoggedOwnedDrops.Add(command.Address); + if (firstTime) + Logged?.Invoke( + $"feedback: lamp 0x{command.Address:X2} is profile-mapped (HasLamp) — dropped"); + return; + } + target.Lamps.Post(command.Address, command.LampState); + } + + private void DispatchLampAll(Target target, FeedbackCommand command) + { + if (!target.Config.AllowLampCommands) + { + Interlocked.Increment(ref _dropped); + return; + } + for (int a = 0; a < RioAddress.TableSize; a++) + { + if (RioAddress.IsValid(a) && !target.Map[a].HasLamp) + target.Lamps.Post(a, command.LampState); + } + } + + private void DispatchPlasma(Target target, FeedbackCommand command) + { + if (target.Plasma is null || !target.Config.AllowPlasmaText) + { + Interlocked.Increment(ref _dropped); + return; + } + + lock (_gate) + { + if (_plasmaBusy) + { + if (_plasmaPending is not null) + Interlocked.Increment(ref _dropped); // superseded before it ran + _plasmaPending = command; // latest wins + return; + } + _plasmaBusy = true; + } + StartPlasmaWrite(target, command); + } + + private void StartPlasmaWrite(Target target, FeedbackCommand command) + { + Task write = command.Kind == FeedbackCommandKind.PlasmaClear + ? target.Plasma!.ClearAsync() + : target.Plasma!.PosTextAsync(command.Text ?? string.Empty, command.X, command.Y); + + write.ContinueWith(w => + { + if (w.Exception is not null) // observe: an unobserved fault kills net40 + Logged?.Invoke($"feedback: plasma write failed: {w.Exception.GetBaseException().Message}"); + + FeedbackCommand? next; + Target? current; + lock (_gate) + { + next = _plasmaPending; + _plasmaPending = null; + current = _target; // pending text applies to the *current* profile's display + if (next is null || current?.Plasma is null || !current.Config.AllowPlasmaText) + { + _plasmaBusy = false; // chain ends; a racing Dispatch starts a fresh one + if (next is not null) + Interlocked.Increment(ref _dropped); + return; + } + // Busy stays true across the chained write, so latest-wins ordering + // holds — concurrent dispatches keep landing in the pending slot. + } + StartPlasmaWrite(current, next); + }, TaskContinuationOptions.ExecuteSynchronously); + } +} diff --git a/src/RioJoy.Core/Feedback/FeedbackService.cs b/src/RioJoy.Core/Feedback/FeedbackService.cs new file mode 100644 index 0000000..bf8330f --- /dev/null +++ b/src/RioJoy.Core/Feedback/FeedbackService.cs @@ -0,0 +1,127 @@ +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; + } +} diff --git a/src/RioJoy.Core/Feedback/FeedbackUdpListener.cs b/src/RioJoy.Core/Feedback/FeedbackUdpListener.cs new file mode 100644 index 0000000..2abb46c --- /dev/null +++ b/src/RioJoy.Core/Feedback/FeedbackUdpListener.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Net.Sockets; + +namespace RioJoy.Core.Feedback; + +/// +/// UDP loopback listener for the inbound feedback protocol — the transport sim +/// export scripts speak natively (DCS Export.lua, SimHub, X-Plane). Binds +/// only, so nothing off-machine can inject +/// commands and no firewall prompt appears. Each datagram carries one or more +/// complete protocol lines; end-of-datagram terminates the final line even +/// without a trailing LF, and nothing fragments across datagrams. Blocking +/// Receive on a background thread (net40 has no ReceiveAsync — +/// one code path for both flavors); Close unblocks it on dispose. +/// +public sealed class FeedbackUdpListener : IDisposable +{ + /// Datagrams larger than this are dropped (guards a hostile/broken sender). + public const int MaxDatagramBytes = 4096; + + private readonly UdpClient _udp; + private readonly Action _onLine; + private readonly Action? _log; + private Thread? _thread; + private volatile bool _running; + + /// Binds immediately; throws if the port is taken. + public FeedbackUdpListener(int port, Action onLine, Action? log = null) + { + _onLine = onLine ?? throw new ArgumentNullException(nameof(onLine)); + _log = log; + _udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port)); + Port = ((IPEndPoint)_udp.Client.LocalEndPoint!).Port; + } + + /// The bound port (resolves a requested port of 0 to the ephemeral one). + public int Port { get; } + + /// Start receiving (idempotent). + public void Start() + { + if (_running) + return; + _running = true; + + _thread = new Thread(ReceiveLoop) + { + IsBackground = true, + Name = $"RIOJoy feedback UDP (:{Port})", + }; + _thread.Start(); + _log?.Invoke($"feedback: listening on udp://127.0.0.1:{Port}"); + } + + public void Dispose() + { + if (!_running) + { + _udp.Close(); + return; + } + _running = false; + _udp.Close(); // unblocks the pending Receive with a SocketException + _thread?.Join(1000); + _thread = null; + } + + private void ReceiveLoop() + { + var lines = new FeedbackLineBuffer(); // reset per datagram via Flush + while (_running) + { + IPEndPoint? remote = null; + byte[] datagram; + try + { + datagram = _udp.Receive(ref remote!); + } + catch (Exception ex) when (ex is SocketException or ObjectDisposedException) + { + if (!_running) + return; // closed by Dispose + continue; // e.g. ICMP port-unreachable reflected as SocketException + } + + if (datagram.Length > MaxDatagramBytes) + { + _log?.Invoke($"feedback: dropped oversize {datagram.Length}-byte datagram"); + continue; + } + + foreach (string line in lines.Feed(datagram, datagram.Length)) + Handle(line); + if (lines.Flush() is string tail) // datagram end terminates the last line + Handle(tail); + } + } + + private void Handle(string line) + { + try + { + _onLine(line); + } + catch (Exception ex) + { + _log?.Invoke($"feedback: line handler failed: {ex.Message}"); + } + } +} diff --git a/src/RioJoy.Core/Feedback/RumbleLampAdapter.cs b/src/RioJoy.Core/Feedback/RumbleLampAdapter.cs new file mode 100644 index 0000000..b31fc59 --- /dev/null +++ b/src/RioJoy.Core/Feedback/RumbleLampAdapter.cs @@ -0,0 +1,66 @@ +using RioJoy.Core.Protocol; + +namespace RioJoy.Core.Feedback; + +/// +/// Maps XInput vibration onto cockpit lamp flash: each motor's intensity +/// becomes off / slow / med / fast (bright) on that motor's configured lamp +/// addresses. Subscribed to ViGEmJoystickSink.RumbleChanged, which fires +/// on a ViGEm-owned thread at XInput rates — therefore +/// only computes a state byte and posts to the shared +/// when it changed. The board sustains +/// the blink from the state byte, so a game holding constant rumble costs one +/// lamp command, and XInput's stream of identical values costs nothing. +/// (TFM-neutral; only the ViGEm hookup is net48-only.) +/// +public sealed class RumbleLampAdapter +{ + private readonly RumbleLampConfig _config; + private readonly CoalescingLampScheduler _lamps; + private readonly object _gate = new(); + private int _lastLarge = -1; // last posted state byte; -1 = none yet + private int _lastSmall = -1; + + public RumbleLampAdapter(RumbleLampConfig config, CoalescingLampScheduler lamps) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _lamps = lamps ?? throw new ArgumentNullException(nameof(lamps)); + } + + /// Vibration update from the pad. Thread-safe, non-blocking. + public void OnRumble(byte large, byte small) + { + lock (_gate) + { + Apply(large, _config.LargeMotorLamps, ref _lastLarge); + Apply(small, _config.SmallMotorLamps, ref _lastSmall); + } + } + + private void Apply(byte value, List addresses, ref int lastState) + { + byte state = MapMotor(value, _config.Threshold); + if (state == lastState) + return; + lastState = state; + foreach (int address in addresses) + _lamps.Post(address, state); // invalid config addresses drop in Post + } + + /// + /// Motor byte → lamp state: below is off; the + /// remaining range splits into thirds of slow / med / fast flash, bright. + /// + public static byte MapMotor(byte value, byte threshold) + { + if (value < threshold) + return RioLampState.SolidOff; + + int span = 256 - threshold; + int offset = value - threshold; + LampFlash flash = offset < span / 3 ? LampFlash.FlashSlow + : offset < span * 2 / 3 ? LampFlash.FlashMed + : LampFlash.FlashFast; + return RioLampState.Compose(flash, LampField1.Bright, LampField2.Bright); + } +} diff --git a/src/RioJoy.Core/Mapping/RioAddress.cs b/src/RioJoy.Core/Mapping/RioAddress.cs index b7d4fdc..93f6e7e 100644 --- a/src/RioJoy.Core/Mapping/RioAddress.cs +++ b/src/RioJoy.Core/Mapping/RioAddress.cs @@ -23,6 +23,15 @@ public static class RioAddress /// Size of the iRIO table (addresses 0x00..0x6F inclusive). public const int TableSize = MaxAddress + 1; // 112 + /// + /// True when is a real input/lamp address: the 72 + /// buttons or one of the two keypads. The 0x48–0x4F gap is unused. + /// + public static bool IsValid(int address) => + (address >= 0 && address < ButtonCount) || + (address >= Keypad0Base && address <= Keypad0Base + 0x0F) || + (address >= Keypad1Base && address <= MaxAddress); + /// Address for a digital button event ( 0x00–0x47). public static int FromButton(byte index) { diff --git a/src/RioJoy.Core/Output/ViGEmJoystickSink.cs b/src/RioJoy.Core/Output/ViGEmJoystickSink.cs index 6f382d9..8b97e70 100644 --- a/src/RioJoy.Core/Output/ViGEmJoystickSink.cs +++ b/src/RioJoy.Core/Output/ViGEmJoystickSink.cs @@ -46,6 +46,18 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable _pad = pad; } + /// + /// XInput vibration set by the game, as (large, small) motor bytes — the + /// feedback channel that works with unmodified games (rumble → cockpit lamp + /// flash, Phase 9). Raised on a ViGEm-owned thread at XInput rates: + /// handlers must not block (compute + post to a rate-limited scheduler + /// only). Plain byte delegate so consumers stay free of ViGEm types. + /// + public event Action? RumbleChanged; + + private void OnFeedback(object sender, Xbox360FeedbackReceivedEventArgs e) => + RumbleChanged?.Invoke(e.LargeMotor, e.SmallMotor); + /// /// Apply a per-profile axis routing ( = the default /// legacy routing) and neutralize the pad's axis state — all four thumb axes @@ -83,6 +95,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable pad.AutoSubmitReport = false; // submit once per logical update pad.Connect(); sink = new ViGEmJoystickSink(client, pad); + pad.FeedbackReceived += sink.OnFeedback; // game rumble → RumbleChanged return true; } catch @@ -140,6 +153,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable public void Dispose() { + _pad.FeedbackReceived -= OnFeedback; try { _pad.Disconnect(); } catch { /* already disconnected / bus gone */ } _client.Dispose(); } diff --git a/src/RioJoy.Core/Plasma/PlasmaDisplay.cs b/src/RioJoy.Core/Plasma/PlasmaDisplay.cs index 99eed11..5e2205d 100644 --- a/src/RioJoy.Core/Plasma/PlasmaDisplay.cs +++ b/src/RioJoy.Core/Plasma/PlasmaDisplay.cs @@ -1,3 +1,4 @@ +using RioJoy.Core.Compat; using RioJoy.Core.Serial; namespace RioJoy.Core.Plasma; @@ -6,11 +7,15 @@ namespace RioJoy.Core.Plasma; /// Drives the plasma / VFD text display over its (secondary) serial transport, /// writing the ESC sequences built by . Thin async /// wrapper around an ; the display is write-only. The -/// content shown is per-profile (Phase 5+). +/// content shown is per-profile (Phase 5+). A write lock keeps each command's +/// ESC sequence contiguous on the wire — is five +/// separate writes, and concurrent callers (greeting vs. feedback text) would +/// otherwise interleave fragments and corrupt the display. /// public sealed class PlasmaDisplay { private readonly IRioTransport _transport; + private readonly SemaphoreSlim _writeLock = new(1, 1); public PlasmaDisplay(IRioTransport transport) { @@ -18,35 +23,49 @@ public sealed class PlasmaDisplay } public Task ClearAsync(CancellationToken ct = default) => - WriteAsync(PlasmaCommands.Clear(), ct); + WriteLockedAsync(new[] { PlasmaCommands.Clear() }, ct); public Task CursorHomeAsync(CancellationToken ct = default) => - WriteAsync(PlasmaCommands.CursorHome(), ct); + WriteLockedAsync(new[] { PlasmaCommands.CursorHome() }, ct); public Task TextAsync(string text, CancellationToken ct = default) => - WriteAsync(PlasmaCommands.Text(text), ct); + WriteLockedAsync(new[] { PlasmaCommands.Text(text) }, ct); /// /// Position the cursor, set attribute + font, and write text — the /// PlasmaPosText sequence (auto-fit via /// ). Pass (0,0) to auto-center. /// - public async Task PosTextAsync( + public Task PosTextAsync( string text, byte x = 0, byte y = 0, byte attr = 0, byte font = 0, CancellationToken ct = default) { if (string.IsNullOrEmpty(text)) - return; + return TaskCompat.CompletedTask; (byte rx, byte ry, byte rfont, int len) = PlasmaCommands.ResolvePosText(text, x, y, font); - await WriteAsync(PlasmaCommands.CursorX(rx), ct).ConfigureAwait(false); - await WriteAsync(PlasmaCommands.CursorY(ry), ct).ConfigureAwait(false); - await WriteAsync(PlasmaCommands.FontAttr(attr), ct).ConfigureAwait(false); - await WriteAsync(PlasmaCommands.Font(rfont), ct).ConfigureAwait(false); - await WriteAsync(PlasmaCommands.Text(text[..len]), ct).ConfigureAwait(false); + return WriteLockedAsync(new[] + { + PlasmaCommands.CursorX(rx), + PlasmaCommands.CursorY(ry), + PlasmaCommands.FontAttr(attr), + PlasmaCommands.Font(rfont), + PlasmaCommands.Text(text[..len]), + }, ct); } - private Task WriteAsync(byte[] data, CancellationToken ct) => - _transport.WriteAsync(data, ct); + private async Task WriteLockedAsync(byte[][] chunks, CancellationToken ct) + { + await TaskCompat.WaitAsync(_writeLock, ct).ConfigureAwait(false); + try + { + foreach (byte[] chunk in chunks) + await _transport.WriteAsync(chunk, ct).ConfigureAwait(false); + } + finally + { + _writeLock.Release(); + } + } } diff --git a/src/RioJoy.Core/Profiles/AppConfig.cs b/src/RioJoy.Core/Profiles/AppConfig.cs index 6b71e2a..533db61 100644 --- a/src/RioJoy.Core/Profiles/AppConfig.cs +++ b/src/RioJoy.Core/Profiles/AppConfig.cs @@ -55,6 +55,13 @@ public sealed class AppConfig /// public string? OverlayTemplatePath { get; set; } + /// + /// Inbound game-feedback endpoint settings (Phase 9); null = the + /// defaults (named pipe on, + /// UDP off). + /// + public Feedback.FeedbackEndpointConfig? Feedback { get; set; } + /// Find a profile by name (case-insensitive), or null. public RioProfile? FindProfile(string? name) => name is null diff --git a/src/RioJoy.Core/Profiles/RioProfile.cs b/src/RioJoy.Core/Profiles/RioProfile.cs index 5304a41..e38825e 100644 --- a/src/RioJoy.Core/Profiles/RioProfile.cs +++ b/src/RioJoy.Core/Profiles/RioProfile.cs @@ -41,6 +41,12 @@ public sealed class RioProfile /// Plasma greeting text shown on load (null = leave display as-is). public string? PlasmaGreeting { get; set; } + /// + /// How inbound game feedback (lamp/plasma commands, rumble) is applied while + /// this profile is active; null = feedback is not applied (commands dropped). + /// + public Feedback.ProfileFeedbackConfig? Feedback { get; set; } + /// Cockpit wallpaper image path (generated in Phase 7). public string? WallpaperPath { get; set; } diff --git a/src/RioJoy.Core/RioRuntime.cs b/src/RioJoy.Core/RioRuntime.cs index 9323fc8..916c30d 100644 --- a/src/RioJoy.Core/RioRuntime.cs +++ b/src/RioJoy.Core/RioRuntime.cs @@ -46,6 +46,14 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable /// public bool EchoAllLamps { get; set; } + /// + /// This runtime's serial lamp sink — the target the inbound feedback + /// endpoint's scheduler drives (Phase 9). Feedback paths must rate-limit + /// through a , never call + /// directly (see its remarks). + /// + public ILampSink Lamps => _lamp; + /// Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate). public event Action? DiagnosticToggle; diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs index c17cc39..4aa13c1 100644 --- a/src/RioJoy.Tray/RioCoordinator.cs +++ b/src/RioJoy.Tray/RioCoordinator.cs @@ -1,8 +1,10 @@ using RioJoy.Core; using RioJoy.Core.Calibration; +using RioJoy.Core.Feedback; using RioJoy.Core.Mapping; using RioJoy.Core.Output; using RioJoy.Core.Overlay; +using RioJoy.Core.Plasma; using RioJoy.Core.Profiles; using RioJoy.Core.Serial; #if !NET40 @@ -44,6 +46,21 @@ public sealed class RioCoordinator : IDisposable private IDisposable? _joystick; private string? _activeProfileName; + // The plasma display's own transport (null when the profile runs without one). + // Released on every teardown — the native games open this port too. + private IRioTransport? _plasmaTransport; + private PlasmaDisplay? _plasma; + + // The inbound feedback endpoint (Phase 9). Created lazily on first + // activation and kept for the app's lifetime, so external clients hold + // their pipe/UDP connection across profile switches and dormancy — only + // Attach/Detach swings where (whether) commands land. + private FeedbackService? _feedback; + private CoalescingLampScheduler? _feedbackScheduler; // per-activation (rumble shares it) +#if !NET40 + private Action? _rumbleUnhook; // detaches the rumble adapter from the pad +#endif + // The user's own desktop wallpaper, captured the first time we override it with // a cockpit wallpaper. null = we are not currently overriding (nothing to // restore). "" is a valid captured value (the user had no wallpaper). @@ -59,6 +76,12 @@ public sealed class RioCoordinator : IDisposable /// Raised (with a short status string) whenever the active state changes. public event Action? StatusChanged; + /// + /// Feedback-endpoint diagnostics (malformed lines, dropped profile-owned + /// lamp writes, listener lifecycle). Also mirrored to the debugger output. + /// + public event Action? FeedbackLog; + /// Current status line for the tray. public string Status { get; private set; } = "Dormant"; @@ -229,9 +252,10 @@ public sealed class RioCoordinator : IDisposable AnalogPollInterval = TimeSpan.FromMilliseconds( Math.Max(10, config.AnalogPollMs)), // floor guards a typo'd config }); + RioInputMap map = profile.ToInputMap(); // shared: runtime routing + feedback precedence _runtime = new RioRuntime( _link, - profile.ToInputMap(), + map, input, joystick, new AxisCalibrator(profile.Calibration)); @@ -241,6 +265,28 @@ public sealed class RioCoordinator : IDisposable _cts = new CancellationTokenSource(); _ = _link.RunAsync(_cts.Token); _runtime.Start(); + + if (routeInput) + { + // Plasma + inbound feedback are live-profile concerns; editor + // sessions run without them (commands drop at the endpoint). + note += OpenPlasma(profile, config); + AttachFeedback(profile, map, config); + +#if !NET40 // rumble arrives via ViGEm, so the XP flavor has no source for it + if (realJoystick is ViGEmJoystickSink rumblePad && + profile.Feedback?.Rumble is RumbleLampConfig rumbleConfig && + _feedbackScheduler is not null) + { + // Game rumble → lamp flash, through the same rate governor + // as the pipe/UDP lamp commands. + var adapter = new RumbleLampAdapter(rumbleConfig, _feedbackScheduler); + rumblePad.RumbleChanged += adapter.OnRumble; + _rumbleUnhook = () => rumblePad.RumbleChanged -= adapter.OnRumble; + } +#endif + } + _activeProfileName = profile.Name; SetStatus($"{(routeInput ? "Active" : "Editing")}: {profile.Name} ({_link.Description}){note}"); } @@ -255,6 +301,77 @@ public sealed class RioCoordinator : IDisposable ApplyWallpaper(profile); } + /// + /// Open the plasma display for and show its + /// greeting (Phase 9 — closes Phase 4's dangling wiring). The port is the + /// profile's , falling back to + /// ; null/empty/"off" + /// disables (the default is "COM2", so plasma-less machines want "off"). + /// Best-effort: returns a status suffix on failure — a missing display must + /// never break activation. + /// + private string OpenPlasma(RioProfile profile, AppConfig config) + { + string? plasmaPort = profile.PlasmaComPort ?? config.DefaultPlasmaComPort; + if (string.IsNullOrWhiteSpace(plasmaPort) || + string.Equals(plasmaPort!.Trim(), "off", StringComparison.OrdinalIgnoreCase)) + return string.Empty; + + try + { + _plasmaTransport = _transportFactory(plasmaPort); + _plasma = new PlasmaDisplay(_plasmaTransport); + FireAndForget(ShowGreetingAsync(_plasma, profile.PlasmaGreeting)); + return string.Empty; + } + catch (Exception ex) + { + _plasmaTransport?.Dispose(); + _plasmaTransport = null; + _plasma = null; + return $" [plasma: {ex.Message}]"; + } + } + + private static async Task ShowGreetingAsync(PlasmaDisplay plasma, string? greeting) + { + try + { + await plasma.ClearAsync().ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(greeting)) + await plasma.PosTextAsync(greeting!).ConfigureAwait(false); // (0,0) = auto-center + } + catch + { + // Best-effort: a wedged display port must not surface anywhere. + } + } + + /// + /// Point the (app-lifetime) feedback endpoint at the new runtime's outputs. + /// A profile with no section leaves the + /// endpoint detached — clients stay connected, commands drop. + /// + private void AttachFeedback(RioProfile profile, RioInputMap map, AppConfig config) + { + if (_feedback is null) + { + _feedback = new FeedbackService(config.Feedback); + _feedback.Logged += message => + { + System.Diagnostics.Debug.WriteLine(message); + FeedbackLog?.Invoke(message); + }; + _feedback.Start(); + } + + if (profile.Feedback is ProfileFeedbackConfig fb && _runtime is not null) + _feedbackScheduler = _feedback.Attach(_runtime.Lamps, map, _plasma, fb); + } + + private static void FireAndForget(Task task) => + task.ContinueWith(static t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted); + /// /// Generate the profile's cockpit wallpaper from the configured overlay template /// and apply it. Best-effort and opt-in (only when @@ -345,6 +462,16 @@ public sealed class RioCoordinator : IDisposable private void Teardown() { + // Feedback first: detach the router and stop the lamp-scheduler pump so + // nothing races the disposal below (the endpoint itself stays up — + // clients keep their connections; their commands now drop). + _feedback?.Detach(); + _feedbackScheduler = null; +#if !NET40 + _rumbleUnhook?.Invoke(); + _rumbleUnhook = null; +#endif + _runtime?.Dispose(); _runtime = null; @@ -359,6 +486,17 @@ public sealed class RioCoordinator : IDisposable _editorInput = null; _editorJoystick = null; + if (_plasma is not null) + { + // Best-effort blank before releasing the display (2 bytes at 9600 + // baud ≈ 2 ms; the bound only bites on a wedged port). + try { _plasma.ClearAsync().Wait(200); } + catch { /* best-effort */ } + _plasma = null; + } + _plasmaTransport?.Dispose(); // releases the plasma COM port (native games open it too) + _plasmaTransport = null; + _transport?.Dispose(); // releases the COM port _transport = null; @@ -374,6 +512,8 @@ public sealed class RioCoordinator : IDisposable public void Dispose() { Teardown(); + _feedback?.Dispose(); // now the endpoint itself: drop clients, stop listening + _feedback = null; RestoreWallpaper(); // clean exit shouldn't leave a cockpit wallpaper behind } } diff --git a/tests/RioJoy.Core.Tests/Feedback/CoalescingLampSchedulerTests.cs b/tests/RioJoy.Core.Tests/Feedback/CoalescingLampSchedulerTests.cs new file mode 100644 index 0000000..24d0228 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Feedback/CoalescingLampSchedulerTests.cs @@ -0,0 +1,141 @@ +using System.Globalization; +using RioJoy.Core.Feedback; +using RioJoy.Core.Mapping; +using RioJoy.Core.Protocol; +using RioJoy.Core.Tests.Mapping; +using RioJoy.Core.Tests.Serial; +using Xunit; + +namespace RioJoy.Core.Tests.Feedback; + +public class CoalescingLampSchedulerTests +{ + private static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(1); // pump as fast as timers allow + + private static Task WaitFor(Func condition, int timeoutMs = 5000) => + FeedbackWait.For(condition, timeoutMs); + + // "Lamp(0x12,0x3C)" → (0x12, 0x3C) + private static (int Address, byte State) ParseLamp(string entry) + { + string[] parts = entry["Lamp(0x".Length..^1].Split(new[] { ",0x" }, StringSplitOptions.None); + return (int.Parse(parts[0], NumberStyles.HexNumber, CultureInfo.InvariantCulture), + byte.Parse(parts[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task Post_SameAddressRepeatedly_SendsOnlyTheLatestState() + { + var sink = new RecordingSink(); + var scheduler = new CoalescingLampScheduler(sink, Fast); + for (byte s = 0; s <= 0x30; s++) + scheduler.Post(0x12, s); // a burst of updates while nothing pumps + + using var cts = new CancellationTokenSource(); + Task pump = scheduler.RunAsync(cts.Token); + await WaitFor(() => sink.Snapshot().Length >= 1); + await Task.Delay(50); // give a buggy scheduler time to send the rest + cts.Cancel(); + await pump.WithTimeout(); + + string entry = Assert.Single(sink.Snapshot()); + Assert.Equal((0x12, (byte)0x30), ParseLamp(entry)); // burst collapsed to the last state + } + + [Fact] + public async Task Post_UnchangedState_IsNotResent() + { + var sink = new RecordingSink(); + var scheduler = new CoalescingLampScheduler(sink, Fast); + using var cts = new CancellationTokenSource(); + Task pump = scheduler.RunAsync(cts.Token); + + scheduler.Post(0x05, RioLampState.SolidBright); + await WaitFor(() => sink.Snapshot().Length >= 1); + + scheduler.Post(0x05, RioLampState.SolidBright); // same state again + await Task.Delay(50); + cts.Cancel(); + await pump.WithTimeout(); + + Assert.Single(sink.Snapshot()); // no resend for an unchanged lamp + } + + [Fact] + public async Task Pump_SendsAtMostOneLampPerTick() + { + var sink = new RecordingSink(); + var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(200)); + scheduler.Post(0x01, RioLampState.SolidBright); + scheduler.Post(0x02, RioLampState.SolidBright); + scheduler.Post(0x03, RioLampState.SolidBright); + + using var cts = new CancellationTokenSource(); + Task pump = scheduler.RunAsync(cts.Token); + await WaitFor(() => sink.Snapshot().Length >= 1); + + // The next tick is ~200 ms out; three pending lamps must not burst. + Assert.Single(sink.Snapshot()); + cts.Cancel(); + await pump.WithTimeout(); + } + + [Fact] + public async Task PostAll_CoversExactlyTheValidAddressSet() + { + var sink = new RecordingSink(); + var scheduler = new CoalescingLampScheduler(sink, Fast); + scheduler.PostAll(RioLampState.SolidOff); + + int validCount = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid); + Assert.Equal(104, validCount); // 72 buttons + 2×16 keypad keys; 0x48-0x4F is a gap + + using var cts = new CancellationTokenSource(); + Task pump = scheduler.RunAsync(cts.Token); + await WaitFor(() => sink.Snapshot().Length >= validCount); + await Task.Delay(50); + cts.Cancel(); + await pump.WithTimeout(); + + var sent = sink.Snapshot().Select(ParseLamp).ToArray(); + Assert.Equal(validCount, sent.Length); // nothing sent twice, no gap addresses + Assert.All(sent, s => Assert.Equal(RioLampState.SolidOff, s.State)); + Assert.Equal( + Enumerable.Range(0, RioAddress.TableSize).Where(RioAddress.IsValid), + sent.Select(s => s.Address).OrderBy(a => a)); + } + + [Fact] + public async Task Post_InvalidAddress_Ignored() + { + var sink = new RecordingSink(); + var scheduler = new CoalescingLampScheduler(sink, Fast); + scheduler.Post(0x48, RioLampState.SolidBright); // gap address (rumble config is unvalidated) + scheduler.Post(0x70, RioLampState.SolidBright); + scheduler.Post(-1, RioLampState.SolidBright); + + using var cts = new CancellationTokenSource(); + Task pump = scheduler.RunAsync(cts.Token); + await Task.Delay(100); + cts.Cancel(); + await pump.WithTimeout(); + + Assert.Empty(sink.Snapshot()); + } + + [Fact] + public async Task Cancel_StopsThePump() + { + var sink = new RecordingSink(); + var scheduler = new CoalescingLampScheduler(sink, Fast); + using var cts = new CancellationTokenSource(); + Task pump = scheduler.RunAsync(cts.Token); + + cts.Cancel(); + await pump.WithTimeout(); // exits cleanly, no OperationCanceledException + + scheduler.Post(0x01, RioLampState.SolidBright); + await Task.Delay(50); + Assert.Empty(sink.Snapshot()); // a stopped pump sends nothing + } +} diff --git a/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs b/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs new file mode 100644 index 0000000..04f0a64 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs @@ -0,0 +1,250 @@ +using System.Text; +using RioJoy.Core.Feedback; +using RioJoy.Core.Protocol; +using Xunit; + +namespace RioJoy.Core.Tests.Feedback; + +public class FeedbackLineParserTests +{ + private static FeedbackCommand Parse(string line) + { + bool ok = FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error); + Assert.True(ok, $"expected '{line}' to parse, got error: {error}"); + return command!; + } + + private static string ParseError(string line) + { + bool ok = FeedbackLineParser.TryParse(line, out _, out string? error); + Assert.False(ok); + Assert.NotNull(error); + return error!; + } + + [Theory] + [InlineData("lamp 0x12 bright", 0x12, 0x3C)] // solid implied; = SolidBright + [InlineData("lamp 18 bright", 18, 0x3C)] // decimal address + [InlineData("lamp 0x12 dim", 0x12, 0x14)] // = SolidDim + [InlineData("lamp 0x12 off", 0x12, 0x00)] // = SolidOff + [InlineData("lamp 0x12 fast bright", 0x12, 0x3F)] + [InlineData("lamp 0x12 slow dim", 0x12, 0x15)] + [InlineData("lamp 0x12 med bright", 0x12, 0x3E)] + [InlineData("lamp 0x12 solid bright", 0x12, 0x3C)] + [InlineData("LAMP 0x12 FAST BRIGHT", 0x12, 0x3F)] // keywords case-insensitive + [InlineData("lamp 0x12 0x36", 0x12, 0x36)] // raw state byte + [InlineData("lamp 0x12 20", 0x12, 20)] // raw state, decimal + public void Lamp_Forms_ComposeTheDocumentedStateByte(string line, int address, int state) + { + FeedbackCommand cmd = Parse(line); + Assert.Equal(FeedbackCommandKind.Lamp, cmd.Kind); + Assert.Equal(address, cmd.Address); + Assert.Equal((byte)state, cmd.LampState); + } + + [Fact] + public void Lamp_WordStates_MatchRioLampStateCompose() + { + Assert.Equal(RioLampState.SolidBright, Parse("lamp 0 bright").LampState); + Assert.Equal(RioLampState.SolidDim, Parse("lamp 0 dim").LampState); + Assert.Equal(RioLampState.SolidOff, Parse("lamp 0 off").LampState); + Assert.Equal( + RioLampState.Compose(LampFlash.FlashFast, LampField1.Bright, LampField2.Bright), + Parse("lamp 0 fast bright").LampState); + } + + [Theory] + [InlineData(0x00)] + [InlineData(0x47)] + [InlineData(0x50)] + [InlineData(0x5F)] + [InlineData(0x60)] + [InlineData(0x6F)] + public void Lamp_AddressRangeEdges_Accepted(int address) + { + Assert.Equal(address, Parse($"lamp 0x{address:X2} dim").Address); + } + + [Theory] + [InlineData("lamp 0x48 dim")] // gap between buttons and keypad 0 + [InlineData("lamp 0x4F dim")] + [InlineData("lamp 0x70 dim")] // beyond MaxAddress + [InlineData("lamp 200 dim")] + public void Lamp_AddressOutOfRange_Rejected(string line) + { + Assert.Contains("out of range", ParseError(line)); + } + + [Theory] + [InlineData("lamp 0x12 0x40")] // raw state above 6 lamp-state bits + [InlineData("lamp 0x12 64")] + public void Lamp_RawStateAboveSixBits_Rejected(string line) + { + Assert.Contains("0x00-0x3F", ParseError(line)); + } + + [Theory] + [InlineData("lamp")] + [InlineData("lamp 0x12")] + [InlineData("lamp 0x12 blinky")] + [InlineData("lamp 0x12 fast blinky")] + [InlineData("lamp 0x12 fast bright extra")] + [InlineData("lamp banana dim")] + [InlineData("bogus 1 2")] + public void Lamp_Malformed_ReturnsErrorText(string line) + { + Assert.NotEmpty(ParseError(line)); + } + + [Fact] + public void LampAll_ParsesStateWithoutAddress() + { + FeedbackCommand cmd = Parse("lamp-all off"); + Assert.Equal(FeedbackCommandKind.LampAll, cmd.Kind); + Assert.Equal(RioLampState.SolidOff, cmd.LampState); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("# a comment")] + [InlineData("; also a comment")] + [InlineData(" # indented comment")] + public void BlankAndComment_SkippedWithoutError(string line) + { + bool ok = FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error); + Assert.False(ok); + Assert.Null(command); + Assert.Null(error); + } + + [Fact] + public void PlasmaClear_Parses() + { + Assert.Equal(FeedbackCommandKind.PlasmaClear, Parse("plasma clear").Kind); + } + + [Fact] + public void PlasmaText_Quoted_StripsQuotes() + { + FeedbackCommand cmd = Parse("plasma text \"VIPER 1-1\""); + Assert.Equal(FeedbackCommandKind.PlasmaText, cmd.Kind); + Assert.Equal("VIPER 1-1", cmd.Text); + Assert.Equal(0, cmd.X); // (0,0) = auto-center + Assert.Equal(0, cmd.Y); + } + + [Fact] + public void PlasmaText_Unquoted_TakesRestOfLine() + { + Assert.Equal("VIPER 1-1", Parse("plasma text VIPER 1-1").Text); + } + + [Fact] + public void PlasmaText_TwoLeadingNumbers_ArePosition() + { + FeedbackCommand cmd = Parse("plasma text 12 3 \"FUEL LOW\""); + Assert.Equal(12, cmd.X); + Assert.Equal(3, cmd.Y); + Assert.Equal("FUEL LOW", cmd.Text); + } + + [Fact] + public void PlasmaText_OneLeadingNumber_IsText() + { + // Only two consecutive numeric tokens form a position; a single one is text. + FeedbackCommand cmd = Parse("plasma text 42 kills"); + Assert.Equal(0, cmd.X); + Assert.Equal(0, cmd.Y); + Assert.Equal("42 kills", cmd.Text); + } + + [Fact] + public void PlasmaText_QuotedNumber_IsText() + { + Assert.Equal("42", Parse("plasma text \"42\"").Text); + } + + [Fact] + public void PlasmaText_Latin1Chars_Preserved() + { + // Latin-1 range survives the parser untouched (plasma wire encoding). + Assert.Equal("CAFÉ ÜBER", Parse("plasma text \"CAFÉ ÜBER\"").Text); + } + + [Theory] + [InlineData("plasma")] + [InlineData("plasma bogus")] + [InlineData("plasma clear extra")] + [InlineData("plasma text")] + [InlineData("plasma text \"unterminated")] + [InlineData("plasma text \"done\" trailing")] + [InlineData("plasma text 300 1 \"X\"")] // position out of byte range + public void Plasma_Malformed_ReturnsErrorText(string line) + { + Assert.NotEmpty(ParseError(line)); + } +} + +public class FeedbackLineBufferTests +{ + private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s); + + [Fact] + public void Feed_SplitsOnLf_AndStripsCr() + { + var buffer = new FeedbackLineBuffer(); + byte[] data = Latin1("lamp 1 dim\r\nplasma clear\n"); + Assert.Equal( + new[] { "lamp 1 dim", "plasma clear" }, + buffer.Feed(data, data.Length)); + } + + [Fact] + public void Feed_ReassemblesLinesSplitAcrossChunks() + { + var buffer = new FeedbackLineBuffer(); + byte[] a = Latin1("lamp 0x12 fa"); + byte[] b = Latin1("st bright\n"); + Assert.Empty(buffer.Feed(a, a.Length)); + Assert.Equal(new[] { "lamp 0x12 fast bright" }, buffer.Feed(b, b.Length)); + } + + [Fact] + public void Feed_DiscardsOverlongLine_ThenRecovers() + { + var buffer = new FeedbackLineBuffer(); + byte[] junk = Latin1(new string('x', FeedbackLineBuffer.MaxLineLength + 50) + "\nlamp 1 dim\n"); + Assert.Equal(new[] { "lamp 1 dim" }, buffer.Feed(junk, junk.Length)); + } + + [Fact] + public void Feed_DecodesLatin1Bytes() + { + var buffer = new FeedbackLineBuffer(); + byte[] data = Latin1("plasma text \"CAFÉ\"\n"); + Assert.Equal(new[] { "plasma text \"CAFÉ\"" }, buffer.Feed(data, data.Length)); + } + + [Fact] + public void Flush_ReturnsTrailingLineWithoutLf() + { + // UDP datagrams may omit the final LF; end-of-datagram is a terminator. + var buffer = new FeedbackLineBuffer(); + byte[] data = Latin1("lamp 1 dim\nlamp 2 off"); + Assert.Equal(new[] { "lamp 1 dim" }, buffer.Feed(data, data.Length)); + Assert.Equal("lamp 2 off", buffer.Flush()); + Assert.Null(buffer.Flush()); // flushed state is consumed + } + + [Fact] + public void Flush_EmptyOrDiscarding_ReturnsNull() + { + var buffer = new FeedbackLineBuffer(); + Assert.Null(buffer.Flush()); + + byte[] junk = Latin1(new string('x', FeedbackLineBuffer.MaxLineLength + 50)); + Assert.Empty(buffer.Feed(junk, junk.Length)); + Assert.Null(buffer.Flush()); // overlong tail is discarded, not returned + } +} diff --git a/tests/RioJoy.Core.Tests/Feedback/FeedbackPipeServerTests.cs b/tests/RioJoy.Core.Tests/Feedback/FeedbackPipeServerTests.cs new file mode 100644 index 0000000..130b02d --- /dev/null +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackPipeServerTests.cs @@ -0,0 +1,156 @@ +using System.IO.Pipes; +using System.Text; +using RioJoy.Core.Feedback; +using Xunit; + +namespace RioJoy.Core.Tests.Feedback; + +public class FeedbackPipeServerTests +{ + private static string UniqueName() => $"riojoy-fb-test-{Guid.NewGuid():N}"; + + private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s); + + /// Collector whose Count/Snapshot are safe against the reader threads. + private sealed class Lines + { + private readonly List _lines = new(); + + public void Add(string line) + { + lock (_lines) _lines.Add(line); + } + + public int Count + { + get { lock (_lines) return _lines.Count; } + } + + public string[] Snapshot() + { + lock (_lines) return _lines.ToArray(); + } + } + + // The accept loop arms asynchronously after Start; retry until it listens. + private static NamedPipeClientStream Connect(string name, int timeoutMs = 5000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (true) + { + var client = new NamedPipeClientStream(".", name, PipeDirection.Out); + try + { + client.Connect(200); + return client; + } + catch (Exception ex) when (ex is IOException or TimeoutException) + { + client.Dispose(); + Assert.True(DateTime.UtcNow < deadline, $"could not connect to {name}: {ex.Message}"); + Thread.Sleep(20); + } + } + } + + private static void Send(NamedPipeClientStream client, string text) + { + byte[] data = Latin1(text); + client.Write(data, 0, data.Length); + client.Flush(); + } + + [Fact] + public async Task Lines_AreDeliveredInOrder() + { + string name = UniqueName(); + var lines = new Lines(); + using var server = new FeedbackPipeServer(name, lines.Add); + server.Start(); + + using NamedPipeClientStream client = Connect(name); + Send(client, "lamp 1 dim\r\nplasma clear\n"); + + await FeedbackWait.For(() => lines.Count >= 2); + Assert.Equal(new[] { "lamp 1 dim", "plasma clear" }, lines.Snapshot()); + } + + [Fact] + public async Task Line_SplitAcrossWrites_Reassembles() + { + string name = UniqueName(); + var lines = new Lines(); + using var server = new FeedbackPipeServer(name, lines.Add); + server.Start(); + + using NamedPipeClientStream client = Connect(name); + Send(client, "lamp 0x12 fa"); + Send(client, "st bright\n"); + + await FeedbackWait.For(() => lines.Count >= 1); + Assert.Equal("lamp 0x12 fast bright", Assert.Single(lines.Snapshot())); + } + + [Fact] + public async Task TwoConcurrentClients_BothDeliver() + { + string name = UniqueName(); + var lines = new Lines(); + using var server = new FeedbackPipeServer(name, lines.Add); + server.Start(); + + using NamedPipeClientStream a = Connect(name); + using NamedPipeClientStream b = Connect(name); // second instance while A stays connected + Send(a, "lamp 1 dim\n"); + Send(b, "lamp 2 off\n"); + + await FeedbackWait.For(() => lines.Count >= 2); + Assert.Equal(new[] { "lamp 1 dim", "lamp 2 off" }, lines.Snapshot().OrderBy(l => l)); + } + + [Fact] + public async Task ClientDisconnect_ThenReconnect_Works() + { + string name = UniqueName(); + var lines = new Lines(); + using var server = new FeedbackPipeServer(name, lines.Add); + server.Start(); + + using (NamedPipeClientStream first = Connect(name)) + Send(first, "lamp 1 dim\n"); + await FeedbackWait.For(() => lines.Count >= 1); + + using NamedPipeClientStream second = Connect(name); // server re-arms after the EOF + Send(second, "lamp 2 off\n"); + await FeedbackWait.For(() => lines.Count >= 2); + } + + [Fact] + public async Task ThrowingLineHandler_DoesNotKillTheConnection() + { + string name = UniqueName(); + var lines = new Lines(); + using var server = new FeedbackPipeServer(name, line => + { + if (line.Contains("boom")) + throw new InvalidOperationException("handler bug"); + lines.Add(line); + }); + server.Start(); + + using NamedPipeClientStream client = Connect(name); + Send(client, "boom\nlamp 1 dim\n"); + + await FeedbackWait.For(() => lines.Count >= 1); // the good line still lands + Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot())); + } + + [Fact] + public void Dispose_UnblocksThePendingAccept() + { + var server = new FeedbackPipeServer(UniqueName(), _ => { }); + server.Start(); + Thread.Sleep(100); // let the accept loop park in WaitForConnection + server.Dispose(); // must not hang on the pending accept (poke-connect) + } +} diff --git a/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs b/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs new file mode 100644 index 0000000..9f73be0 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs @@ -0,0 +1,154 @@ +using RioJoy.Core.Feedback; +using RioJoy.Core.Mapping; +using RioJoy.Core.Plasma; +using RioJoy.Core.Protocol; +using RioJoy.Core.Tests.Mapping; +using RioJoy.Core.Tests.Serial; +using Xunit; + +namespace RioJoy.Core.Tests.Feedback; + +public class FeedbackRouterTests : IDisposable +{ + private readonly RecordingSink _sink = new(); + private readonly CoalescingLampScheduler _scheduler; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _pump; + + public FeedbackRouterTests() + { + _scheduler = new CoalescingLampScheduler(_sink, TimeSpan.FromMilliseconds(1)); + _pump = _scheduler.RunAsync(_cts.Token); + } + + public void Dispose() + { + _cts.Cancel(); + _pump.Wait(TimeSpan.FromSeconds(5)); + _cts.Dispose(); + } + + private static FeedbackCommand Lamp(int address, byte state) => + new() { Kind = FeedbackCommandKind.Lamp, Address = address, LampState = state }; + + private static FeedbackCommand Text(string text) => + new() { Kind = FeedbackCommandKind.PlasmaText, Text = text }; + + [Fact] + public async Task Lamp_ProfileOwnedAddressDropped_UnownedApplied() + { + var map = new RioInputMap(); + map[0x10] = RioMapEntry.Create(RioRouteKind.Keyboard, 0x41, lit: true); // InputRouter owns this lamp + var router = new FeedbackRouter(); + var logged = new List(); + router.Logged += logged.Add; + router.Attach(_scheduler, map, plasma: null, new ProfileFeedbackConfig()); + + router.Dispatch(Lamp(0x11, RioLampState.SolidBright)); // unowned → applied + await FeedbackWait.For(() => _sink.Snapshot().Length >= 1); + Assert.Equal("Lamp(0x11,0x3C)", Assert.Single(_sink.Snapshot())); + + router.Dispatch(Lamp(0x10, RioLampState.SolidBright)); // owned → dropped + router.Dispatch(Lamp(0x10, RioLampState.SolidOff)); + await Task.Delay(50); + Assert.Single(_sink.Snapshot()); + Assert.Equal(2, router.DroppedCommands); + Assert.Single(logged); // logged once per address per attach, not per drop + } + + [Fact] + public async Task Detached_CommandsDroppedAndCounted() + { + var router = new FeedbackRouter(); + router.Dispatch(Lamp(0x01, RioLampState.SolidBright)); // never attached + Assert.Equal(1, router.DroppedCommands); + + router.Attach(_scheduler, new RioInputMap(), null, new ProfileFeedbackConfig()); + router.Detach(); + router.Dispatch(Lamp(0x01, RioLampState.SolidBright)); + Assert.Equal(2, router.DroppedCommands); + + await Task.Delay(50); + Assert.Empty(_sink.Snapshot()); + } + + [Fact] + public async Task AllowFlags_GateLampAndPlasma() + { + var transport = new FakeTransport(); + var router = new FeedbackRouter(); + router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), + new ProfileFeedbackConfig { AllowLampCommands = false, AllowPlasmaText = false }); + + router.Dispatch(Lamp(0x01, RioLampState.SolidBright)); + router.Dispatch(Text("NOPE")); + await Task.Delay(50); + + Assert.Empty(_sink.Snapshot()); + Assert.False(transport.Writes.TryRead(out _)); + Assert.Equal(2, router.DroppedCommands); + } + + [Fact] + public async Task LampAll_SkipsProfileOwnedLamps() + { + var map = new RioInputMap(); + map[0x00] = RioMapEntry.Create(RioRouteKind.Joystick, 1, lit: true); + var router = new FeedbackRouter(); + router.Attach(_scheduler, map, null, new ProfileFeedbackConfig()); + + router.Dispatch(new FeedbackCommand + { + Kind = FeedbackCommandKind.LampAll, + LampState = RioLampState.SolidDim, + }); + + int expected = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid) - 1; + await FeedbackWait.For(() => _sink.Snapshot().Length >= expected); + await Task.Delay(50); + + string[] sent = _sink.Snapshot(); + Assert.Equal(expected, sent.Length); + Assert.DoesNotContain("Lamp(0x00,0x14)", sent); // the profile-owned lamp is untouched + } + + [Fact] + public async Task Plasma_FloodCoalesces_FirstAndLatestOnly() + { + // Park the first text mid-write; everything dispatched meanwhile collapses + // to the single latest pending command. + var transport = new GatedTransport(); + var router = new FeedbackRouter(); + router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), + new ProfileFeedbackConfig()); + + router.Dispatch(Text("FIRST")); // goes busy, parked on the gate + router.Dispatch(Text("MID-1")); // pending + router.Dispatch(Text("MID-2")); // supersedes MID-1 + router.Dispatch(Text("LAST")); // supersedes MID-2 + transport.Open(); + + var writes = new List(); + for (int i = 0; i < 10; i++) + writes.Add(await transport.NextWriteAsync()); + await Task.Delay(50); + + Assert.Equal(PlasmaCommands.Text("FIRST"), writes[4]); // FIRST's text chunk + Assert.Equal(PlasmaCommands.Text("LAST"), writes[9]); // then only LAST's + Assert.True(transport.NoMoreWrites); + Assert.Equal(2, router.DroppedCommands); // the two superseded middles + } + + [Fact] + public async Task PlasmaClear_WritesTheClearCommand() + { + var transport = new FakeTransport(); + var router = new FeedbackRouter(); + router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), + new ProfileFeedbackConfig()); + + router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear }); + + Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync()); + } +} diff --git a/tests/RioJoy.Core.Tests/Feedback/FeedbackServiceTests.cs b/tests/RioJoy.Core.Tests/Feedback/FeedbackServiceTests.cs new file mode 100644 index 0000000..fc64d09 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackServiceTests.cs @@ -0,0 +1,96 @@ +using System.IO.Pipes; +using System.Text; +using RioJoy.Core.Feedback; +using RioJoy.Core.Mapping; +using RioJoy.Core.Plasma; +using RioJoy.Core.Tests.Mapping; +using RioJoy.Core.Tests.Serial; +using Xunit; + +namespace RioJoy.Core.Tests.Feedback; + +/// +/// End-to-end: pipe client → line assembly → parse → router → lamp scheduler / +/// plasma, the exact path a sim export script exercises. +/// +public class FeedbackServiceTests +{ + private static string UniqueName() => $"riojoy-fb-svc-{Guid.NewGuid():N}"; + + private static NamedPipeClientStream Connect(string name, int timeoutMs = 5000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (true) + { + var client = new NamedPipeClientStream(".", name, PipeDirection.Out); + try + { + client.Connect(200); + return client; + } + catch (Exception ex) when (ex is IOException or TimeoutException) + { + client.Dispose(); + Assert.True(DateTime.UtcNow < deadline, $"could not connect: {ex.Message}"); + Thread.Sleep(20); + } + } + } + + private static void Send(NamedPipeClientStream client, string text) + { + byte[] data = Encoding.GetEncoding(28591).GetBytes(text); + client.Write(data, 0, data.Length); + client.Flush(); + } + + [Fact] + public async Task PipeClient_DrivesLampsAndPlasma_MalformedLinesSurvive() + { + string name = UniqueName(); + var lamps = new RecordingSink(); + var plasmaTransport = new FakeTransport(); + using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name }); + service.Start(); + service.Attach(lamps, new RioInputMap(), new PlasmaDisplay(plasmaTransport), + new ProfileFeedbackConfig()); + + using NamedPipeClientStream client = Connect(name); + Send(client, "# cockpit warmup\nlamp 0x11 fast bright\nbogus nonsense\nplasma clear\n"); + + await FeedbackWait.For(() => lamps.Snapshot().Length >= 1); + Assert.Equal("Lamp(0x11,0x3F)", Assert.Single(lamps.Snapshot())); + Assert.Equal(PlasmaCommands.Clear(), await plasmaTransport.NextWriteAsync()); + Assert.Equal(1, service.MalformedLines); // the bogus line, not the comment + + Send(client, "lamp 0x11 off\n"); // the connection survived the bad line + await FeedbackWait.For(() => lamps.Snapshot().Length >= 2); + Assert.Equal("Lamp(0x11,0x00)", lamps.Snapshot()[1]); + } + + [Fact] + public async Task Detach_DropsCommands_ReattachAppliesAgain() + { + string name = UniqueName(); + var lamps = new RecordingSink(); + using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name }); + service.Start(); + using NamedPipeClientStream client = Connect(name); + + // Dormant (never attached): commands drop, the client stays connected. + Send(client, "lamp 0x01 bright\n"); + await FeedbackWait.For(() => service.DroppedCommands >= 1); + Assert.Empty(lamps.Snapshot()); + + // Profile activates: the same client now drives lamps. + service.Attach(lamps, new RioInputMap(), null, new ProfileFeedbackConfig()); + Send(client, "lamp 0x01 bright\n"); + await FeedbackWait.For(() => lamps.Snapshot().Length >= 1); + + // Yield to a native game: back to dropping, still connected. + service.Detach(); + Send(client, "lamp 0x01 off\n"); + await FeedbackWait.For(() => service.DroppedCommands >= 2); + Assert.Single(lamps.Snapshot()); + } +} diff --git a/tests/RioJoy.Core.Tests/Feedback/FeedbackTestHelpers.cs b/tests/RioJoy.Core.Tests/Feedback/FeedbackTestHelpers.cs new file mode 100644 index 0000000..58f8155 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackTestHelpers.cs @@ -0,0 +1,17 @@ +using Xunit; + +namespace RioJoy.Core.Tests.Feedback; + +internal static class FeedbackWait +{ + /// Poll until holds, failing at the deadline. + public static async Task For(Func condition, int timeoutMs = 5000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (!condition()) + { + Assert.True(DateTime.UtcNow < deadline, "condition not reached in time"); + await Task.Delay(10); + } + } +} diff --git a/tests/RioJoy.Core.Tests/Feedback/FeedbackUdpListenerTests.cs b/tests/RioJoy.Core.Tests/Feedback/FeedbackUdpListenerTests.cs new file mode 100644 index 0000000..dc50c61 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackUdpListenerTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using RioJoy.Core.Feedback; +using Xunit; + +namespace RioJoy.Core.Tests.Feedback; + +public class FeedbackUdpListenerTests +{ + private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s); + + private sealed class Lines + { + private readonly List _lines = new(); + + public void Add(string line) + { + lock (_lines) _lines.Add(line); + } + + public int Count + { + get { lock (_lines) return _lines.Count; } + } + + public string[] Snapshot() + { + lock (_lines) return _lines.ToArray(); + } + } + + private static void Send(int port, byte[] datagram) + { + using var udp = new UdpClient(); + udp.Send(datagram, datagram.Length, new IPEndPoint(IPAddress.Loopback, port)); + } + + [Fact] + public async Task Datagram_WithoutTrailingLf_IsOneLine() + { + var lines = new Lines(); + using var listener = new FeedbackUdpListener(0, lines.Add); // 0 → ephemeral + Assert.NotEqual(0, listener.Port); + listener.Start(); + + Send(listener.Port, Latin1("lamp 1 dim")); // datagram end terminates the line + + await FeedbackWait.For(() => lines.Count >= 1); + Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot())); + } + + [Fact] + public async Task Datagram_WithMultipleLines_DeliversEach() + { + var lines = new Lines(); + using var listener = new FeedbackUdpListener(0, lines.Add); + listener.Start(); + + Send(listener.Port, Latin1("lamp 1 dim\nlamp 2 off\nplasma clear")); + + await FeedbackWait.For(() => lines.Count >= 3); + Assert.Equal(new[] { "lamp 1 dim", "lamp 2 off", "plasma clear" }, lines.Snapshot()); + } + + [Fact] + public async Task ThrowingLineHandler_DoesNotKillTheListener() + { + var lines = new Lines(); + using var listener = new FeedbackUdpListener(0, line => + { + if (line.Contains("boom")) + throw new InvalidOperationException("handler bug"); + lines.Add(line); + }); + listener.Start(); + + Send(listener.Port, Latin1("boom\n")); + Send(listener.Port, Latin1("lamp 1 dim\n")); + + await FeedbackWait.For(() => lines.Count >= 1); + Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot())); + } + + [Fact] + public void Dispose_UnblocksThePendingReceive() + { + var listener = new FeedbackUdpListener(0, _ => { }); + listener.Start(); + Thread.Sleep(50); // let the loop park in Receive + listener.Dispose(); // Close must unblock it without hanging + } + + [Fact] + public void PortInUse_ThrowsSocketException() + { + using var first = new FeedbackUdpListener(0, _ => { }); + Assert.Throws(() => new FeedbackUdpListener(first.Port, _ => { })); + } +} diff --git a/tests/RioJoy.Core.Tests/Feedback/RumbleLampAdapterTests.cs b/tests/RioJoy.Core.Tests/Feedback/RumbleLampAdapterTests.cs new file mode 100644 index 0000000..51b6a9a --- /dev/null +++ b/tests/RioJoy.Core.Tests/Feedback/RumbleLampAdapterTests.cs @@ -0,0 +1,108 @@ +using RioJoy.Core.Feedback; +using RioJoy.Core.Protocol; +using RioJoy.Core.Tests.Mapping; +using Xunit; + +namespace RioJoy.Core.Tests.Feedback; + +public class RumbleLampAdapterTests +{ + // Flash + Bright/Bright state bytes the bands resolve to. + private const byte SlowBright = 0x3D; + private const byte MedBright = 0x3E; + private const byte FastBright = 0x3F; + + [Theory] + [InlineData(0, 24, 0x00)] // below threshold → off + [InlineData(23, 24, 0x00)] + [InlineData(24, 24, SlowBright)] // first third of the remaining range + [InlineData(100, 24, SlowBright)] + [InlineData(101, 24, MedBright)] // second third + [InlineData(177, 24, MedBright)] + [InlineData(178, 24, FastBright)] // top third + [InlineData(255, 24, FastBright)] + [InlineData(0, 0, SlowBright)] // threshold 0 = never off + public void MapMotor_BandsResolveToDocumentedStates(int value, int threshold, byte expected) + { + Assert.Equal(expected, RumbleLampAdapter.MapMotor((byte)value, (byte)threshold)); + } + + [Fact] + public void MapMotor_MatchesRioLampStateCompose() + { + Assert.Equal( + RioLampState.Compose(LampFlash.FlashFast, LampField1.Bright, LampField2.Bright), + RumbleLampAdapter.MapMotor(255, 24)); + Assert.Equal(RioLampState.SolidOff, RumbleLampAdapter.MapMotor(0, 24)); + } + + [Fact] + public async Task OnRumble_DrivesEachMotorsConfiguredAddresses() + { + var sink = new RecordingSink(); + var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1)); + using var cts = new CancellationTokenSource(); + Task pump = scheduler.RunAsync(cts.Token); + + var adapter = new RumbleLampAdapter(new RumbleLampConfig + { + LargeMotorLamps = { 0x20, 0x21 }, + SmallMotorLamps = { 0x30 }, + }, scheduler); + + adapter.OnRumble(255, 0); // big hit, no small motor + + await FeedbackWait.For(() => sink.Snapshot().Length >= 3); + cts.Cancel(); + await pump; + + string[] sent = sink.Snapshot(); + Assert.Contains("Lamp(0x20,0x3F)", sent); // large motor lamps flash fast + Assert.Contains("Lamp(0x21,0x3F)", sent); + Assert.Contains("Lamp(0x30,0x00)", sent); // small motor lamps confirmed off + } + + [Fact] + public async Task OnRumble_RepeatedIdenticalValues_PostNothingNew() + { + var sink = new RecordingSink(); + var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1)); + using var cts = new CancellationTokenSource(); + Task pump = scheduler.RunAsync(cts.Token); + + var adapter = new RumbleLampAdapter( + new RumbleLampConfig { LargeMotorLamps = { 0x20 }, SmallMotorLamps = { 0x30 } }, + scheduler); + + // XInput-style spam: same vibration reported over and over. + for (int i = 0; i < 200; i++) + adapter.OnRumble(200, 0); + + await FeedbackWait.For(() => sink.Snapshot().Length >= 2); + await Task.Delay(50); + cts.Cancel(); + await pump; + + Assert.Equal(2, sink.Snapshot().Length); // one state per motor, ever + } + + [Fact] + public async Task OnRumble_ZeroAfterRumble_TurnsTheLampsOff() + { + var sink = new RecordingSink(); + var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1)); + using var cts = new CancellationTokenSource(); + Task pump = scheduler.RunAsync(cts.Token); + + var adapter = new RumbleLampAdapter( + new RumbleLampConfig { LargeMotorLamps = { 0x20 } }, scheduler); + + adapter.OnRumble(255, 0); + await FeedbackWait.For(() => sink.Snapshot().Contains("Lamp(0x20,0x3F)")); + adapter.OnRumble(0, 0); + await FeedbackWait.For(() => sink.Snapshot().Contains("Lamp(0x20,0x00)")); + + cts.Cancel(); + await pump; + } +} diff --git a/tests/RioJoy.Core.Tests/Plasma/PlasmaDisplayTests.cs b/tests/RioJoy.Core.Tests/Plasma/PlasmaDisplayTests.cs new file mode 100644 index 0000000..2221720 --- /dev/null +++ b/tests/RioJoy.Core.Tests/Plasma/PlasmaDisplayTests.cs @@ -0,0 +1,79 @@ +using RioJoy.Core.Plasma; +using RioJoy.Core.Tests.Serial; +using Xunit; + +namespace RioJoy.Core.Tests.Plasma; + +public class PlasmaDisplayTests +{ + private static byte[][] PosTextChunks(string text, byte x = 0, byte y = 0, byte attr = 0, byte font = 0) + { + (byte rx, byte ry, byte rfont, int len) = PlasmaCommands.ResolvePosText(text, x, y, font); + return new[] + { + PlasmaCommands.CursorX(rx), + PlasmaCommands.CursorY(ry), + PlasmaCommands.FontAttr(attr), + PlasmaCommands.Font(rfont), + PlasmaCommands.Text(text[..len]), + }; + } + + [Fact] + public async Task PosTextAsync_EmitsThePosTextSequenceInOrder() + { + var transport = new FakeTransport(); + var display = new PlasmaDisplay(transport); + + await display.PosTextAsync("VIPER 1-1").WithTimeout(); + + foreach (byte[] expected in PosTextChunks("VIPER 1-1")) + Assert.Equal(expected, await transport.NextWriteAsync()); + } + + [Fact] + public async Task PosTextAsync_EmptyText_WritesNothing() + { + var transport = new FakeTransport(); + var display = new PlasmaDisplay(transport); + + await display.PosTextAsync("").WithTimeout(); + + Assert.False(transport.Writes.TryRead(out _)); + } + + [Fact] + public async Task ClearAsync_WritesTheClearCommand() + { + var transport = new FakeTransport(); + var display = new PlasmaDisplay(transport); + + await display.ClearAsync().WithTimeout(); + + Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync()); + } + + [Fact] + public async Task PosTextAsync_ConcurrentCalls_DoNotInterleave() + { + // Without the write lock, B's cursor/font fragments land between A's five + // writes and corrupt the ESC stream. Gate A's first write so B has every + // chance to sneak in, then assert the ten writes arrive as A's five + // followed by B's five. + var transport = new GatedTransport(); + var display = new PlasmaDisplay(transport); + + Task a = display.PosTextAsync("AAAA"); + Task b = display.PosTextAsync("BBBB"); + transport.Open(); + await Task.WhenAll(a, b).WithTimeout(); + + var writes = new List(); + for (int i = 0; i < 10; i++) + writes.Add(await transport.NextWriteAsync()); + + byte[][] expected = PosTextChunks("AAAA").Concat(PosTextChunks("BBBB")).ToArray(); + for (int i = 0; i < expected.Length; i++) + Assert.Equal(expected[i], writes[i]); + } +} diff --git a/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs b/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs index bd9104e..f64bc2d 100644 --- a/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs +++ b/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs @@ -1,4 +1,5 @@ using RioJoy.Core.Calibration; +using RioJoy.Core.Feedback; using RioJoy.Core.Output; using RioJoy.Core.Profiles; using Xunit; @@ -86,6 +87,69 @@ public class ConfigStoreTests Assert.Null(Assert.Single(ConfigStore.Deserialize(json).Profiles).AxisRouting); } + [Fact] + public void RoundTrips_FeedbackSections() + { + var config = new AppConfig + { + Feedback = new FeedbackEndpointConfig { PipeName = "riojoy-fb-test", UdpPort = 19900 }, + Profiles = + { + new RioProfile + { + Name = "DCS", + Feedback = new ProfileFeedbackConfig + { + AllowPlasmaText = false, + Rumble = new RumbleLampConfig + { + LargeMotorLamps = { 0x12, 0x13 }, + SmallMotorLamps = { 0x60 }, + Threshold = 32, + }, + }, + }, + }, + }; + + AppConfig back = ConfigStore.Deserialize(ConfigStore.Serialize(config)); + + Assert.NotNull(back.Feedback); + Assert.True(back.Feedback!.PipeEnabled); + Assert.Equal("riojoy-fb-test", back.Feedback.PipeName); + Assert.Equal(19900, back.Feedback.UdpPort); + + // These records hold List, so no record value equality — per-property. + ProfileFeedbackConfig fb = Assert.Single(back.Profiles).Feedback!; + Assert.True(fb.AllowLampCommands); + Assert.False(fb.AllowPlasmaText); + Assert.NotNull(fb.Rumble); + Assert.Equal(new[] { 0x12, 0x13 }, fb.Rumble!.LargeMotorLamps); + Assert.Equal(new[] { 0x60 }, fb.Rumble.SmallMotorLamps); + Assert.Equal(32, fb.Rumble.Threshold); + } + + [Fact] + public void Feedback_Unset_StaysNull_AndOffJson() + { + // null = feedback off / endpoint defaults; NullValueHandling.Ignore keeps + // both sections out of the JSON, so pre-Phase-9 files stay byte-compatible. + string json = ConfigStore.Serialize(new AppConfig { Profiles = { new RioProfile { Name = "P" } } }); + Assert.DoesNotContain("Feedback", json); + + AppConfig back = ConfigStore.Deserialize(json); + Assert.Null(back.Feedback); + Assert.Null(Assert.Single(back.Profiles).Feedback); + } + + [Fact] + public void ShippedDescentProfile_ParsesWithFeedbackOff() + { + string json = File.ReadAllText(Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json")); + RioProfile p = Assert.Single(ConfigStore.Deserialize($"{{\"Profiles\":[{json}]}}").Profiles); + Assert.Null(p.Feedback); // pre-Phase-9 profile documents deserialize with feedback off + } + [Fact] public void ShippedDescentProfile_ParsesWithDescentRouting_NoTriggerTargets() { diff --git a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs index f606c03..87db2fc 100644 --- a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs +++ b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs @@ -48,6 +48,30 @@ public class RioRuntimeTests await run; } + [Fact] + public async Task Lamps_SetLamp_SendsALampRequestOverTheLink() + { + var fake = new FakeTransport(); + var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false }); + var recorder = new RecordingSink(); + + using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder); + runtime.Start(); + + using var cts = new CancellationTokenSource(); + Task run = link.RunAsync(cts.Token); + + // The accessor the feedback endpoint's lamp scheduler drives (Phase 9). + runtime.Lamps.SetLamp(0x12, RioLampState.SolidBright); + + Assert.Equal( + PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x12, RioLampState.SolidBright }), + await fake.NextWriteAsync()); + + cts.Cancel(); + await run; + } + [Fact] public async Task AnalogReply_DrivesAllSixAxes() { diff --git a/tests/RioJoy.Core.Tests/Serial/GatedTransport.cs b/tests/RioJoy.Core.Tests/Serial/GatedTransport.cs new file mode 100644 index 0000000..759f96f --- /dev/null +++ b/tests/RioJoy.Core.Tests/Serial/GatedTransport.cs @@ -0,0 +1,50 @@ +using System.Threading.Channels; +using RioJoy.Core.Serial; + +namespace RioJoy.Core.Tests.Serial; + +/// +/// whose first write blocks until +/// — lets a test park one writer mid-sequence while another tries to cut in +/// (write-lock and latest-wins assertions). +/// +internal sealed class GatedTransport : IRioTransport +{ + private readonly Channel _writes = Channel.CreateUnbounded(); + private readonly SemaphoreSlim _gate = new(0, 1); + private readonly object _armLock = new(); + private bool _gateArmed = true; + + public string Description => "gated"; + + /// Release the parked first write. + public void Open() => _gate.Release(); + + public Task ReadAsync(byte[] buffer, CancellationToken cancellationToken) => + Task.FromResult(0); + + public async Task WriteAsync(byte[] data, CancellationToken cancellationToken) + { + bool wait; + lock (_armLock) + { + wait = _gateArmed; + _gateArmed = false; + } + if (wait) + await _gate.WaitAsync(cancellationToken); + _writes.Writer.TryWrite((byte[])data.Clone()); + } + + /// Read the next write, failing if none arrives in time. + public async Task NextWriteAsync(TimeSpan? timeout = null) + { + using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(5)); + return await _writes.Reader.ReadAsync(cts.Token); + } + + /// True when no further write has arrived. + public bool NoMoreWrites => !_writes.Reader.TryPeek(out _); + + public void Dispose() { } +}