feedback: game-to-cockpit endpoint (pipe/UDP lamps+plasma), rumble lamp flash
Phase 9: FeedbackPipeServer (\\.\pipe\riojoy-feedback) + loopback UDP share a forgiving text line protocol into FeedbackRouter; CoalescingLampScheduler rate- governs the 9600-baud link; plasma finally wired into activation (greeting, teardown blank, PlasmaDisplay write lock); ViGEm FeedbackReceived drives RumbleLampAdapter. Per-profile Feedback config, docs/FEEDBACK.md, 425 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:<port>` | 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 <addr> <state> set one lamp
|
||||
lamp-all <state> set every lamp
|
||||
plasma text [x y] <text> write text to the plasma display
|
||||
plasma clear clear the plasma display
|
||||
```
|
||||
|
||||
- **`<addr>`** — 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).
|
||||
- **`<state>`** — 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/`.
|
||||
+57
-2
@@ -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
|
||||
|
||||
@@ -10,6 +10,9 @@ namespace RioJoy.Core.Compat;
|
||||
internal static class TaskCompat
|
||||
{
|
||||
#if NET40
|
||||
/// <summary>net40 has no <c>Task.CompletedTask</c>.</summary>
|
||||
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) =>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using RioJoy.Core.Compat;
|
||||
using RioJoy.Core.Mapping;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="ILampSink"/> directly. Keeps a desired/last-sent shadow of all
|
||||
/// 112 addresses; the pump sends at most one <i>changed</i> 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.
|
||||
/// </summary>
|
||||
public sealed class CoalescingLampScheduler
|
||||
{
|
||||
/// <summary>Default pump tick: ≤40 lamp commands/s at 9600 baud stays polite.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Post(int address, byte state)
|
||||
{
|
||||
if (!RioAddress.IsValid(address))
|
||||
return;
|
||||
lock (_gate)
|
||||
_desired[address] = state;
|
||||
}
|
||||
|
||||
/// <summary>Set the desired state for every valid lamp address.</summary>
|
||||
public void PostAll(byte state)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
for (int a = 0; a < RioAddress.TableSize; a++)
|
||||
{
|
||||
if (RioAddress.IsValid(a))
|
||||
_desired[a] = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>What a parsed feedback line asks the cockpit to do.</summary>
|
||||
public enum FeedbackCommandKind
|
||||
{
|
||||
/// <summary>Set one lamp to a state (<c>lamp <addr> <state></c>).</summary>
|
||||
Lamp,
|
||||
|
||||
/// <summary>Set every valid lamp address to a state (<c>lamp-all <state></c>).</summary>
|
||||
LampAll,
|
||||
|
||||
/// <summary>Write text to the plasma display (<c>plasma text [x y] <text></c>).</summary>
|
||||
PlasmaText,
|
||||
|
||||
/// <summary>Clear the plasma display (<c>plasma clear</c>).</summary>
|
||||
PlasmaClear,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One inbound cockpit-feedback command, produced by
|
||||
/// <see cref="FeedbackLineParser"/> 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
|
||||
/// (<see cref="Protocol.RioLampState"/>).
|
||||
/// </summary>
|
||||
public sealed record FeedbackCommand
|
||||
{
|
||||
public FeedbackCommandKind Kind { get; init; }
|
||||
|
||||
/// <summary>RIO lamp address (<see cref="FeedbackCommandKind.Lamp"/> only).</summary>
|
||||
public int Address { get; init; }
|
||||
|
||||
/// <summary>Lamp state byte (<see cref="FeedbackCommandKind.Lamp"/>/<see cref="FeedbackCommandKind.LampAll"/>).</summary>
|
||||
public byte LampState { get; init; }
|
||||
|
||||
/// <summary>Display text (<see cref="FeedbackCommandKind.PlasmaText"/> only).</summary>
|
||||
public string? Text { get; init; }
|
||||
|
||||
/// <summary>Plasma cursor position; (0,0) = auto-fit/center (<c>PlasmaPosText</c>).</summary>
|
||||
public byte X { get; init; }
|
||||
|
||||
public byte Y { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// App-level inbound-feedback endpoint settings
|
||||
/// (<see cref="Profiles.AppConfig.Feedback"/>; null there = these defaults:
|
||||
/// named pipe on under <see cref="DefaultPipeName"/>, UDP off). The endpoint is
|
||||
/// app-lifetime — clients keep their connection across profile switches and
|
||||
/// dormancy; per-profile settings only gate what gets applied
|
||||
/// (<see cref="ProfileFeedbackConfig"/>). Serialized into config.json, so no
|
||||
/// vendor types.
|
||||
/// </summary>
|
||||
public sealed record FeedbackEndpointConfig
|
||||
{
|
||||
public const string DefaultPipeName = "riojoy-feedback";
|
||||
|
||||
/// <summary>Listen on <c>\\.\pipe\<PipeName></c> for feedback lines.</summary>
|
||||
public bool PipeEnabled { get; init; } = true;
|
||||
|
||||
public string PipeName { get; init; } = DefaultPipeName;
|
||||
|
||||
/// <summary>
|
||||
/// UDP loopback port to also listen on; null = UDP off. Datagrams carry one
|
||||
/// or more complete protocol lines (docs/FEEDBACK.md).
|
||||
/// </summary>
|
||||
public int? UdpPort { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-profile feedback application settings
|
||||
/// (<see cref="Profiles.RioProfile.Feedback"/>; null there = inbound feedback
|
||||
/// is not applied for this profile — commands are dropped).
|
||||
/// </summary>
|
||||
public sealed record ProfileFeedbackConfig
|
||||
{
|
||||
/// <summary>Apply inbound <c>lamp</c>/<c>lamp-all</c> commands.</summary>
|
||||
public bool AllowLampCommands { get; init; } = true;
|
||||
|
||||
/// <summary>Apply inbound <c>plasma</c> commands.</summary>
|
||||
public bool AllowPlasmaText { get; init; } = true;
|
||||
|
||||
/// <summary>XInput rumble → lamp flash mapping; null = off.</summary>
|
||||
public RumbleLampConfig? Rumble { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps ViGEm pad vibration onto cockpit lamps: each motor drives its listed
|
||||
/// RIO lamp addresses through flash states scaled by intensity (off below
|
||||
/// <see cref="Threshold"/>, then slow/med/fast thirds — the board sustains the
|
||||
/// blink, so constant rumble costs one lamp command).
|
||||
/// </summary>
|
||||
public sealed record RumbleLampConfig
|
||||
{
|
||||
/// <summary>RIO lamp addresses driven by the large (low-frequency) motor.</summary>
|
||||
public List<int> LargeMotorLamps { get; init; } = new();
|
||||
|
||||
/// <summary>RIO lamp addresses driven by the small (high-frequency) motor.</summary>
|
||||
public List<int> SmallMotorLamps { get; init; } = new();
|
||||
|
||||
/// <summary>Motor value (0-255) below which the lamps turn off.</summary>
|
||||
public byte Threshold { get; init; } = 24;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles raw endpoint bytes into protocol lines for
|
||||
/// <see cref="FeedbackLineParser"/>: 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 <see cref="MaxLineLength"/> 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.
|
||||
/// </summary>
|
||||
public sealed class FeedbackLineBuffer
|
||||
{
|
||||
public const int MaxLineLength = 256;
|
||||
|
||||
private readonly StringBuilder _line = new();
|
||||
private bool _discarding;
|
||||
|
||||
/// <summary>Feed <paramref name="count"/> bytes; returns the completed lines.</summary>
|
||||
public IEnumerable<string> Feed(byte[] buffer, int count)
|
||||
{
|
||||
List<string>? 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<string>()).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<string>(); // net40: no Array.Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-of-datagram flush (UDP): the remaining buffered content is one final
|
||||
/// line even without a trailing LF. Returns <see langword="null"/> when
|
||||
/// there is nothing buffered. Pipe readers never flush — they wait for LF.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Globalization;
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Protocol;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Parses one line of the inbound feedback protocol (docs/FEEDBACK.md) into a
|
||||
/// <see cref="FeedbackCommand"/>. 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:
|
||||
/// <c>SerialLampSink</c> casts to <c>byte</c> unchecked, so out-of-range
|
||||
/// addresses are rejected here.
|
||||
/// </summary>
|
||||
public static class FeedbackLineParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse one line. Returns <see langword="true"/> with a command when the
|
||||
/// line is actionable. Returns <see langword="false"/> with
|
||||
/// <paramref name="error"/> <see langword="null"/> for blank/comment lines
|
||||
/// (skip silently) or an error message for malformed ones (log + drop).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.IO.Pipes;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Named-pipe listener for the inbound feedback protocol: serves
|
||||
/// <c>\\.\pipe\<name></c>, reassembles lines
|
||||
/// (<see cref="FeedbackLineBuffer"/>), and hands each to the owner — parsing
|
||||
/// and routing live in <see cref="FeedbackService"/>, so this class is pure
|
||||
/// transport. Modeled on vRIO's <c>VRioPipeService</c> (dedicated background
|
||||
/// threads — net40 has no <c>WaitForConnectionAsync</c>; throwaway poke-connect
|
||||
/// on stop because a pending <c>WaitForConnection</c> can survive Dispose on
|
||||
/// net48), with two deliberate differences: the pipe is
|
||||
/// <see cref="PipeDirection.In"/> — the server never writes, so the 0-byte
|
||||
/// pipe-buffer write deadlock class cannot occur and no reply path exists — and
|
||||
/// up to <see cref="MaxClients"/> 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.
|
||||
/// </summary>
|
||||
public sealed class FeedbackPipeServer : IDisposable
|
||||
{
|
||||
/// <summary>Concurrent client cap (pipe instances of the served name).</summary>
|
||||
public const int MaxClients = 4;
|
||||
|
||||
private readonly string _pipeName;
|
||||
private readonly Action<string> _onLine;
|
||||
private readonly Action<string>? _log;
|
||||
private readonly SemaphoreSlim _slots = new(MaxClients, MaxClients);
|
||||
private readonly object _stateGate = new();
|
||||
private readonly List<NamedPipeServerStream> _open = new();
|
||||
private readonly List<Thread> _readers = new();
|
||||
private Thread? _accept;
|
||||
private volatile bool _running;
|
||||
|
||||
public FeedbackPipeServer(string pipeName, Action<string> onLine, Action<string>? 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;
|
||||
}
|
||||
|
||||
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
|
||||
public string PipeName => _pipeName;
|
||||
|
||||
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Plasma;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Applies inbound <see cref="FeedbackCommand"/>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 <c>lamp</c> write to an address whose map entry has
|
||||
/// <see cref="RioMapEntry.HasLamp"/> is dropped — the <see cref="InputRouter"/>
|
||||
/// 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. <c>lamp-all</c> 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.
|
||||
/// </summary>
|
||||
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<int> LoggedOwnedDrops { get; } = new();
|
||||
}
|
||||
|
||||
private readonly object _gate = new();
|
||||
private Target? _target;
|
||||
private bool _plasmaBusy;
|
||||
private FeedbackCommand? _plasmaPending;
|
||||
private long _dropped;
|
||||
|
||||
/// <summary>Diagnostics (dropped profile-owned lamp writes, plasma faults).</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>Commands dropped for any reason (detached, disallowed, profile-owned).</summary>
|
||||
public long DroppedCommands => Interlocked.Read(ref _dropped);
|
||||
|
||||
/// <summary>Point feedback at the just-activated profile's outputs.</summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drop the target; subsequent commands are dropped (counted).</summary>
|
||||
public void Detach()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_target = null;
|
||||
_plasmaPending = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Apply one command. Thread-safe, non-blocking.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Plasma;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// The inbound game-feedback endpoint, assembled: pipe + UDP listeners feed
|
||||
/// protocol lines here; lines parse into <see cref="FeedbackCommand"/>s and
|
||||
/// route to the active profile's outputs. App-lifetime by design — the
|
||||
/// coordinator creates one lazily and keeps it across profile switches, so
|
||||
/// external clients hold their connection through switches and dormancy;
|
||||
/// <see cref="Attach"/>/<see cref="Detach"/> only swap where commands land
|
||||
/// (detached = dropped). <see cref="Attach"/> owns the per-activation
|
||||
/// <see cref="CoalescingLampScheduler"/> (creates it, runs its pump, cancels it
|
||||
/// on detach) and returns it so the rumble adapter can share the one rate
|
||||
/// governor.
|
||||
/// </summary>
|
||||
public sealed class FeedbackService : IDisposable
|
||||
{
|
||||
// A misbehaving client can emit garbage at line rate; log the first few and
|
||||
// go quiet instead of flooding the tray status/log.
|
||||
private const int MaxMalformedLogs = 5;
|
||||
|
||||
private readonly FeedbackEndpointConfig _config;
|
||||
private readonly FeedbackRouter _router = new();
|
||||
private FeedbackPipeServer? _pipe;
|
||||
private FeedbackUdpListener? _udp;
|
||||
private CancellationTokenSource? _schedulerCts;
|
||||
private bool _started;
|
||||
private long _malformed;
|
||||
|
||||
public FeedbackService(FeedbackEndpointConfig? config)
|
||||
{
|
||||
_config = config ?? new FeedbackEndpointConfig();
|
||||
_router.Logged += message => Logged?.Invoke(message);
|
||||
}
|
||||
|
||||
/// <summary>Diagnostics: listener lifecycle, malformed lines, dropped lamp writes.</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>Total lines that failed to parse (all clients).</summary>
|
||||
public long MalformedLines => Interlocked.Read(ref _malformed);
|
||||
|
||||
/// <summary>Commands dropped (detached, disallowed, or profile-owned lamps).</summary>
|
||||
public long DroppedCommands => _router.DroppedCommands;
|
||||
|
||||
/// <summary>Start the configured listeners (idempotent).</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_started)
|
||||
return;
|
||||
_started = true;
|
||||
|
||||
if (_config.PipeEnabled)
|
||||
{
|
||||
_pipe = new FeedbackPipeServer(_config.PipeName, HandleLine, OnLog);
|
||||
_pipe.Start();
|
||||
}
|
||||
|
||||
if (_config.UdpPort is int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
_udp = new FeedbackUdpListener(port, HandleLine, OnLog);
|
||||
_udp.Start();
|
||||
}
|
||||
catch (System.Net.Sockets.SocketException ex)
|
||||
{
|
||||
// Port taken — feedback still works over the pipe; say so and go on.
|
||||
OnLog($"feedback: UDP port {port} unavailable ({ex.Message}) — pipe only");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point inbound feedback at a just-activated profile's outputs. Returns the
|
||||
/// live lamp scheduler (share it with the rumble adapter — one governor for
|
||||
/// all feedback lamp traffic).
|
||||
/// </summary>
|
||||
public CoalescingLampScheduler Attach(
|
||||
ILampSink lamps, RioInputMap map, PlasmaDisplay? plasma, ProfileFeedbackConfig config)
|
||||
{
|
||||
Detach();
|
||||
|
||||
var scheduler = new CoalescingLampScheduler(lamps);
|
||||
_schedulerCts = new CancellationTokenSource();
|
||||
_ = scheduler.RunAsync(_schedulerCts.Token); // exits cleanly on cancel, never faults
|
||||
_router.Attach(scheduler, map, plasma, config);
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
/// <summary>Drop the profile target; subsequent commands are dropped (counted).</summary>
|
||||
public void Detach()
|
||||
{
|
||||
_router.Detach();
|
||||
_schedulerCts?.Cancel();
|
||||
_schedulerCts?.Dispose();
|
||||
_schedulerCts = null;
|
||||
}
|
||||
|
||||
private void HandleLine(string line)
|
||||
{
|
||||
if (FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error))
|
||||
{
|
||||
_router.Dispatch(command!);
|
||||
}
|
||||
else if (error is not null)
|
||||
{
|
||||
long count = Interlocked.Increment(ref _malformed);
|
||||
if (count <= MaxMalformedLogs)
|
||||
OnLog($"feedback: bad line ({error})" +
|
||||
(count == MaxMalformedLogs ? " — further malformed lines suppressed" : string.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLog(string message) => Logged?.Invoke(message);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Detach();
|
||||
_pipe?.Dispose();
|
||||
_pipe = null;
|
||||
_udp?.Dispose();
|
||||
_udp = null;
|
||||
_started = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// UDP loopback listener for the inbound feedback protocol — the transport sim
|
||||
/// export scripts speak natively (DCS Export.lua, SimHub, X-Plane). Binds
|
||||
/// <see cref="IPAddress.Loopback"/> 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
|
||||
/// <c>Receive</c> on a background thread (net40 has no <c>ReceiveAsync</c> —
|
||||
/// one code path for both flavors); <c>Close</c> unblocks it on dispose.
|
||||
/// </summary>
|
||||
public sealed class FeedbackUdpListener : IDisposable
|
||||
{
|
||||
/// <summary>Datagrams larger than this are dropped (guards a hostile/broken sender).</summary>
|
||||
public const int MaxDatagramBytes = 4096;
|
||||
|
||||
private readonly UdpClient _udp;
|
||||
private readonly Action<string> _onLine;
|
||||
private readonly Action<string>? _log;
|
||||
private Thread? _thread;
|
||||
private volatile bool _running;
|
||||
|
||||
/// <summary>Binds immediately; throws <see cref="SocketException"/> if the port is taken.</summary>
|
||||
public FeedbackUdpListener(int port, Action<string> onLine, Action<string>? 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;
|
||||
}
|
||||
|
||||
/// <summary>The bound port (resolves a requested port of 0 to the ephemeral one).</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>Start receiving (idempotent).</summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>ViGEmJoystickSink.RumbleChanged</c>, which fires
|
||||
/// on a ViGEm-owned thread at XInput rates — <see cref="OnRumble"/> therefore
|
||||
/// only computes a state byte and posts to the shared
|
||||
/// <see cref="CoalescingLampScheduler"/> 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.)
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Vibration update from the pad. Thread-safe, non-blocking.</summary>
|
||||
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<int> 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Motor byte → lamp state: below <paramref name="threshold"/> is off; the
|
||||
/// remaining range splits into thirds of slow / med / fast flash, bright.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,15 @@ public static class RioAddress
|
||||
/// <summary>Size of the <c>iRIO</c> table (addresses 0x00..0x6F inclusive).</summary>
|
||||
public const int TableSize = MaxAddress + 1; // 112
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="address"/> is a real input/lamp address: the 72
|
||||
/// buttons or one of the two keypads. The 0x48–0x4F gap is unused.
|
||||
/// </summary>
|
||||
public static bool IsValid(int address) =>
|
||||
(address >= 0 && address < ButtonCount) ||
|
||||
(address >= Keypad0Base && address <= Keypad0Base + 0x0F) ||
|
||||
(address >= Keypad1Base && address <= MaxAddress);
|
||||
|
||||
/// <summary>Address for a digital button event (<paramref name="index"/> 0x00–0x47).</summary>
|
||||
public static int FromButton(byte index)
|
||||
{
|
||||
|
||||
@@ -46,6 +46,18 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
|
||||
_pad = pad;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public event Action<byte, byte>? RumbleChanged;
|
||||
|
||||
private void OnFeedback(object sender, Xbox360FeedbackReceivedEventArgs e) =>
|
||||
RumbleChanged?.Invoke(e.LargeMotor, e.SmallMotor);
|
||||
|
||||
/// <summary>
|
||||
/// Apply a per-profile axis routing (<see langword="null"/> = 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();
|
||||
}
|
||||
|
||||
@@ -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 <see cref="PlasmaCommands"/>. Thin async
|
||||
/// wrapper around an <see cref="IRioTransport"/>; 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 — <see cref="PosTextAsync"/> is five
|
||||
/// separate writes, and concurrent callers (greeting vs. feedback text) would
|
||||
/// otherwise interleave fragments and corrupt the display.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Position the cursor, set attribute + font, and write text — the
|
||||
/// <c>PlasmaPosText</c> sequence (auto-fit via
|
||||
/// <see cref="PlasmaCommands.ResolvePosText"/>). Pass (0,0) to auto-center.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ public sealed class AppConfig
|
||||
/// </summary>
|
||||
public string? OverlayTemplatePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inbound game-feedback endpoint settings (Phase 9); null = the
|
||||
/// <see cref="Feedback.FeedbackEndpointConfig"/> defaults (named pipe on,
|
||||
/// UDP off).
|
||||
/// </summary>
|
||||
public Feedback.FeedbackEndpointConfig? Feedback { get; set; }
|
||||
|
||||
/// <summary>Find a profile by name (case-insensitive), or null.</summary>
|
||||
public RioProfile? FindProfile(string? name) =>
|
||||
name is null
|
||||
|
||||
@@ -41,6 +41,12 @@ public sealed class RioProfile
|
||||
/// <summary>Plasma greeting text shown on load (null = leave display as-is).</summary>
|
||||
public string? PlasmaGreeting { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How inbound game feedback (lamp/plasma commands, rumble) is applied while
|
||||
/// this profile is active; null = feedback is not applied (commands dropped).
|
||||
/// </summary>
|
||||
public Feedback.ProfileFeedbackConfig? Feedback { get; set; }
|
||||
|
||||
/// <summary>Cockpit wallpaper image path (generated in Phase 7).</summary>
|
||||
public string? WallpaperPath { get; set; }
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
|
||||
/// </summary>
|
||||
public bool EchoAllLamps { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// This runtime's serial lamp sink — the target the inbound feedback
|
||||
/// endpoint's scheduler drives (Phase 9). Feedback paths must rate-limit
|
||||
/// through a <see cref="Feedback.CoalescingLampScheduler"/>, never call
|
||||
/// <see cref="ILampSink.SetLamp"/> directly (see its remarks).
|
||||
/// </summary>
|
||||
public ILampSink Lamps => _lamp;
|
||||
|
||||
/// <summary>Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).</summary>
|
||||
public event Action<RioCommandCode>? DiagnosticToggle;
|
||||
|
||||
|
||||
@@ -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
|
||||
/// <summary>Raised (with a short status string) whenever the active state changes.</summary>
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Feedback-endpoint diagnostics (malformed lines, dropped profile-owned
|
||||
/// lamp writes, listener lifecycle). Also mirrored to the debugger output.
|
||||
/// </summary>
|
||||
public event Action<string>? FeedbackLog;
|
||||
|
||||
/// <summary>Current status line for the tray.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open the plasma display for <paramref name="profile"/> and show its
|
||||
/// greeting (Phase 9 — closes Phase 4's dangling wiring). The port is the
|
||||
/// profile's <see cref="RioProfile.PlasmaComPort"/>, falling back to
|
||||
/// <see cref="AppConfig.DefaultPlasmaComPort"/>; null/empty/<c>"off"</c>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point the (app-lifetime) feedback endpoint at the new runtime's outputs.
|
||||
/// A profile with no <see cref="RioProfile.Feedback"/> section leaves the
|
||||
/// endpoint detached — clients stay connected, commands drop.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Generate the profile's cockpit wallpaper from the configured overlay template
|
||||
/// and apply it. Best-effort and opt-in (only when <see cref="AppConfig.OverlayTemplatePath"/>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Collector whose Count/Snapshot are safe against the reader threads.</summary>
|
||||
private sealed class Lines
|
||||
{
|
||||
private readonly List<string> _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)
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<byte[]>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end: pipe client → line assembly → parse → router → lamp scheduler /
|
||||
/// plasma, the exact path a sim export script exercises.
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Xunit;
|
||||
|
||||
namespace RioJoy.Core.Tests.Feedback;
|
||||
|
||||
internal static class FeedbackWait
|
||||
{
|
||||
/// <summary>Poll until <paramref name="condition"/> holds, failing at the deadline.</summary>
|
||||
public static async Task For(Func<bool> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> _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<SocketException>(() => new FeedbackUdpListener(first.Port, _ => { }));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<byte[]>();
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<int>, 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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Threading.Channels;
|
||||
using RioJoy.Core.Serial;
|
||||
|
||||
namespace RioJoy.Core.Tests.Serial;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IRioTransport"/> whose first write blocks until <see cref="Open"/>
|
||||
/// — lets a test park one writer mid-sequence while another tries to cut in
|
||||
/// (write-lock and latest-wins assertions).
|
||||
/// </summary>
|
||||
internal sealed class GatedTransport : IRioTransport
|
||||
{
|
||||
private readonly Channel<byte[]> _writes = Channel.CreateUnbounded<byte[]>();
|
||||
private readonly SemaphoreSlim _gate = new(0, 1);
|
||||
private readonly object _armLock = new();
|
||||
private bool _gateArmed = true;
|
||||
|
||||
public string Description => "gated";
|
||||
|
||||
/// <summary>Release the parked first write.</summary>
|
||||
public void Open() => _gate.Release();
|
||||
|
||||
public Task<int> 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());
|
||||
}
|
||||
|
||||
/// <summary>Read the next write, failing if none arrives in time.</summary>
|
||||
public async Task<byte[]> NextWriteAsync(TimeSpan? timeout = null)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(5));
|
||||
return await _writes.Reader.ReadAsync(cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>True when no further write has arrived.</summary>
|
||||
public bool NoMoreWrites => !_writes.Reader.TryPeek(out _);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
Reference in New Issue
Block a user