PlasmaCommands.GraphicsWrite/GraphicsRow port the display firmware graphics command (ESC P s y x w h, MSB-left); PlasmaDisplay.RowAsync writes a locked whole-row update; the line protocol gains `plasma row <y> <hex32>`. The router plasma slot becomes a bounded FIFO queue: rows stream in order (a frame must not tear), texts still coalesce to the newest, clear flushes, cap 128 with counted drops. Docs updated; 442 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
367 lines
12 KiB
C#
367 lines
12 KiB
C#
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|row)";
|
|
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);
|
|
|
|
case "row":
|
|
return TryParsePlasmaRow(s, pos, out command, out error);
|
|
|
|
default:
|
|
error = $"unknown plasma subcommand '{sub}'";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// plasma row <y> <32 hex digits>: one full 128-px bitmap row (16 bytes,
|
|
// MSB = leftmost pixel), matching the native game's whole-row streaming.
|
|
private static bool TryParsePlasmaRow(
|
|
string s, int pos, out FeedbackCommand? command, out string? error)
|
|
{
|
|
command = null;
|
|
error = null;
|
|
|
|
string? yToken = NextToken(s, ref pos);
|
|
if (yToken is null || !TryParseNumber(yToken, out int y))
|
|
{
|
|
error = "plasma row needs a row number and 32 hex digits";
|
|
return false;
|
|
}
|
|
if (y is < 0 or > 31)
|
|
{
|
|
error = $"plasma row {y} out of range (0-31)";
|
|
return false;
|
|
}
|
|
|
|
string? hex = NextToken(s, ref pos);
|
|
if (hex is null)
|
|
{
|
|
error = "plasma row needs 32 hex digits of row data";
|
|
return false;
|
|
}
|
|
if (NextToken(s, ref pos) is string extra)
|
|
{
|
|
error = $"unexpected token '{extra}'";
|
|
return false;
|
|
}
|
|
if (hex.Length != 32)
|
|
{
|
|
error = $"plasma row data must be exactly 32 hex digits (16 bytes), got {hex.Length}";
|
|
return false;
|
|
}
|
|
|
|
var data = new byte[16];
|
|
for (int i = 0; i < 16; i++)
|
|
{
|
|
int hi = HexNibble(hex[i * 2]);
|
|
int lo = HexNibble(hex[i * 2 + 1]);
|
|
if (hi < 0 || lo < 0)
|
|
{
|
|
error = $"plasma row data has a non-hex character ('{hex[hi < 0 ? i * 2 : i * 2 + 1]}')";
|
|
return false;
|
|
}
|
|
data[i] = (byte)((hi << 4) | lo);
|
|
}
|
|
|
|
command = new FeedbackCommand
|
|
{
|
|
Kind = FeedbackCommandKind.PlasmaRow,
|
|
Y = (byte)y,
|
|
Data = data,
|
|
};
|
|
return true;
|
|
}
|
|
|
|
private static int HexNibble(char c) => c switch
|
|
{
|
|
>= '0' and <= '9' => c - '0',
|
|
>= 'a' and <= 'f' => c - 'a' + 10,
|
|
>= 'A' and <= 'F' => c - 'A' + 10,
|
|
_ => -1,
|
|
};
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|