You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
111 lines
4.2 KiB
111 lines
4.2 KiB
// Codec for the upstream 1.18 companion CLI frames (interop plan F2, #868):
|
|
// CMD_RUN_CLI_COMMAND (66) out, RESP_CODE_CLI_REPLY (29) back. Pure codec,
|
|
// no transport - mirrors the firmware contract at companion-v1.18.0
|
|
// (INVESTIGATION.md §A1):
|
|
// request [66][ascii command], frame length >= 3
|
|
// reply [29][ascii reply], reply text <= 165 bytes (reply_buf[166])
|
|
// Correlation rides the optional 3-char prefix `cc|`: a command whose third
|
|
// character is `|` (and length > 4 on the device) has those 3 chars reflected
|
|
// at the start of the reply. Same grammar the repeater CLI already uses.
|
|
//
|
|
// Only emit frame 66 to a device the population classifier places at
|
|
// stockModern (or post-absorb Offband, via the D1 seam) - see
|
|
// device_population.dart. The codec itself does not gate.
|
|
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
/// `CMD_RUN_CLI_COMMAND`, stock v14+.
|
|
const int cmdRunCliCommand = 66;
|
|
|
|
/// `RESP_CODE_CLI_REPLY`, the reply to [cmdRunCliCommand].
|
|
const int respCodeCliReply = 29;
|
|
|
|
/// The device truncates replies at this many text bytes (`reply_buf[166]`,
|
|
/// one byte spent on the frame code).
|
|
const int maxCliReplyPayload = 165;
|
|
|
|
final RegExp _prefixShape = RegExp(r'^..\|$');
|
|
|
|
/// Builds a `[66][command]` frame, optionally prepending a 3-char
|
|
/// correlation [prefix] of the `cc|` shape.
|
|
///
|
|
/// Throws [ArgumentError] on a blank command, a command too short for the
|
|
/// firmware's `len >= 3` floor (two command bytes minimum), or a prefix that
|
|
/// is not exactly three characters ending in `|` - fail closed rather than
|
|
/// emit a frame the device will misparse.
|
|
Uint8List buildRunCliCommandFrame(String command, {String? prefix}) {
|
|
final trimmed = command.trim();
|
|
if (trimmed.isEmpty) {
|
|
throw ArgumentError.value(command, 'command', 'must not be blank');
|
|
}
|
|
if (prefix != null && !_prefixShape.hasMatch(prefix)) {
|
|
throw ArgumentError.value(prefix, 'prefix', 'must be 3 chars ending in |');
|
|
}
|
|
final payload = '${prefix ?? ''}$trimmed';
|
|
if (payload.length < 2) {
|
|
throw ArgumentError.value(
|
|
command,
|
|
'command',
|
|
'firmware requires at least 2 command bytes (frame len >= 3)',
|
|
);
|
|
}
|
|
// The device CLI is an ASCII surface; encode fail-closed so a non-ASCII
|
|
// command throws here instead of reaching the device malformed (Gemini
|
|
// review, #881 finding 1).
|
|
final List<int> bytes;
|
|
try {
|
|
bytes = ascii.encode(payload);
|
|
} on ArgumentError {
|
|
throw ArgumentError.value(command, 'command', 'must be ASCII');
|
|
}
|
|
return Uint8List.fromList([cmdRunCliCommand, ...bytes]);
|
|
}
|
|
|
|
/// One decoded CLI reply.
|
|
class CliReply {
|
|
const CliReply({
|
|
required this.prefix,
|
|
required this.text,
|
|
required this.payloadLength,
|
|
});
|
|
|
|
/// The reflected `cc|` correlation token, when the reply carries one.
|
|
final String? prefix;
|
|
|
|
/// Reply text with any prefix stripped.
|
|
final String text;
|
|
|
|
/// Raw payload byte count (prefix included) - truncation happens on the
|
|
/// device BEFORE the prefix is stripped, so the bound is judged here, not
|
|
/// on [text] (Gemini review, #881 finding 2).
|
|
final int payloadLength;
|
|
|
|
/// True when the payload sits at the device's truncation bound - the reply
|
|
/// may have been cut and the UI should say so rather than present it as
|
|
/// complete.
|
|
bool get atReplyBound => payloadLength >= maxCliReplyPayload;
|
|
}
|
|
|
|
/// Parses a `[29][reply]` frame; null when [frame] is not a CLI reply.
|
|
/// Decoding is lenient (malformed bytes become U+FFFD) - a garbled reply is
|
|
/// still a reply, and dropping it would look like a timeout.
|
|
///
|
|
/// Prefix detection is positional (third character `|`), the same rule the
|
|
/// device and the repeater path use. A prefixless reply whose text happens to
|
|
/// carry `|` at index 2 mis-splits - inherent to the wire grammar, mitigated
|
|
/// by callers always sending a prefix so real replies reflect one.
|
|
CliReply? parseCliReply(Uint8List frame) {
|
|
if (frame.isEmpty || frame[0] != respCodeCliReply) return null;
|
|
final payloadLength = frame.length - 1;
|
|
final raw = utf8.decode(frame.sublist(1), allowMalformed: true);
|
|
if (raw.length >= 3 && raw[2] == '|') {
|
|
return CliReply(
|
|
prefix: raw.substring(0, 3),
|
|
text: raw.substring(3),
|
|
payloadLength: payloadLength,
|
|
);
|
|
}
|
|
return CliReply(prefix: null, text: raw, payloadLength: payloadLength);
|
|
}
|