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.
218 lines
8.9 KiB
218 lines
8.9 KiB
// Population classifier for the connected radio (initiative #860, F1 #862).
|
|
//
|
|
// Upstream MeshCore 1.18.0 moved FIRMWARE_VER_CODE into the range the Offband
|
|
// fork had already spent (stock 14 = "CLI frame 66 exists" vs fork 14..22 =
|
|
// 0xC0-era contract numbers), so a bare `firmwareVerCode >= N` comparison no
|
|
// longer means one thing. Every capability decision routes through this
|
|
// classifier instead of reading ver code or frame offsets directly.
|
|
//
|
|
// Interim signal (plan E1.1): the device-info reply's SHAPE. Stock ships
|
|
// exactly 82 bytes; the fork appends offband_caps at offset 82 and caps
|
|
// byte 2 at offset 84 [verified against companion-v1.18.0 and firmware-base,
|
|
// INVESTIGATION.md §A/§B3]. The firmware's durable marker (decision D1)
|
|
// replaces this at one seam -see classifyDevicePopulation's doc.
|
|
|
|
import 'dart:typed_data';
|
|
|
|
/// Which firmware family the connected radio belongs to, as far as wire
|
|
/// behavior is concerned.
|
|
enum DevicePopulation {
|
|
/// Stock MeshCore below ver 14, or anything this classifier cannot place:
|
|
/// the fail-closed default. No fork frames, no CLI frame.
|
|
stockLegacy,
|
|
|
|
/// Stock MeshCore ver 14+ (1.18.0 era): CLI frame 66/29 exists; fork
|
|
/// frames (0xC0..0xC6) and caps bytes do not.
|
|
stockModern,
|
|
|
|
/// Offband fork: caps byte(s) present; fork frames are gated by the
|
|
/// advertised cap bits as before. No CLI frame until the fork absorbs
|
|
/// upstream 1.18 (post-absorb devices flip [Classification.allowsCliFrame]
|
|
/// at the D1 seam, not here).
|
|
offband,
|
|
}
|
|
|
|
/// The classifier's verdict plus the coarse capability doors it opens.
|
|
/// Fine-grained gating (which 0xC command, which cap bit) stays where it is
|
|
/// today; these booleans only say which FAMILIES of frames may be emitted.
|
|
class Classification {
|
|
const Classification({
|
|
required this.population,
|
|
required this.conflict,
|
|
required this.evidence,
|
|
});
|
|
|
|
final DevicePopulation population;
|
|
|
|
/// True when the signals contradicted each other (for example a fork-length
|
|
/// frame carrying a stock version string). Conflicts classify as
|
|
/// [DevicePopulation.stockLegacy] -fail closed -and [evidence] says why.
|
|
final bool conflict;
|
|
|
|
/// Human-readable signal summary for logs; never parsed.
|
|
final String evidence;
|
|
|
|
/// Fork frames (0xC0..0xC6) may be emitted, subject to the existing
|
|
/// cap-bit gates.
|
|
bool get allowsOffbandFrames => population == DevicePopulation.offband;
|
|
|
|
/// CMD_RUN_CLI_COMMAND (66) may be emitted. Deliberately false for
|
|
/// [DevicePopulation.offband] on the current fork base; the absorb flips
|
|
/// this at the D1 seam together with the durable marker.
|
|
bool get allowsCliFrame => population == DevicePopulation.stockModern;
|
|
}
|
|
|
|
/// Stock device-info replies are exactly this long; the first fork byte
|
|
/// (offband_caps) sits at this offset. [verified: CMD_DEVICE_QUERY reply
|
|
/// construction at companion-v1.18.0 -82 bytes, unchanged from 1.17.x]
|
|
const int stockDeviceInfoLength = 82;
|
|
|
|
/// Upstream ver code that introduces the CLI frame (66/29).
|
|
const int stockCliMinVerCode = 14;
|
|
|
|
/// Highest FIRMWARE_VER_CODE this client knows to exist in the field on any
|
|
/// population (fork is at 22, moving to 23 on firmware#1448; stock is at 14).
|
|
/// A report far above this is treated as malformed rather than capable.
|
|
const int maxKnownVerCode = 32;
|
|
|
|
/// Classifies the connected radio from its device-info reply and the parsed
|
|
/// firmware-version string.
|
|
///
|
|
/// [deviceInfo] is the raw RESP_CODE_DEVICE_INFO frame. [firmwareVersion] is
|
|
/// the version string already extracted from it (null when absent/unreadable);
|
|
/// it is a SECONDARY signal -absence never blocks, only a positive
|
|
/// contradiction does. Task #878 replaces the contains-check below with the
|
|
/// full dual-format parser.
|
|
///
|
|
/// D1 SEAM: when the firmware ships its durable population marker, this
|
|
/// function is the single place that learns to read it; callers and the
|
|
/// [Classification] contract do not change.
|
|
Classification classifyDevicePopulation({
|
|
required Uint8List deviceInfo,
|
|
required String? firmwareVersion,
|
|
}) {
|
|
final length = deviceInfo.length;
|
|
|
|
// Malformed: too short to even carry a ver code, per the connector's own
|
|
// minimum (frame.length < 4 is discarded there too).
|
|
if (length < 4) {
|
|
return Classification(
|
|
population: DevicePopulation.stockLegacy,
|
|
conflict: false,
|
|
evidence: 'malformed device-info (len=$length)',
|
|
);
|
|
}
|
|
|
|
final verCode = deviceInfo[1];
|
|
final forkShape = length > stockDeviceInfoLength;
|
|
final versionLooksStock = _versionLooksStock(firmwareVersion);
|
|
|
|
if (verCode > maxKnownVerCode) {
|
|
return Classification(
|
|
population: DevicePopulation.stockLegacy,
|
|
conflict: true,
|
|
evidence: 'ver $verCode above any known population (len=$length)',
|
|
);
|
|
}
|
|
|
|
if (forkShape) {
|
|
if (versionLooksStock == false) {
|
|
// Positively Offband: fork-length frame AND the dual version form.
|
|
// Every verified fork build reports the dual form, including release
|
|
// images' X-X shape (firmware#691) and bench builds' git-describe
|
|
// suffixes, so requiring it costs nothing real and stops a corrupted
|
|
// stock frame from unlocking fork frames.
|
|
return Classification(
|
|
population: DevicePopulation.offband,
|
|
conflict: false,
|
|
evidence: 'caps byte present (len=$length, ver=$verCode)',
|
|
);
|
|
}
|
|
// Stock version string, or no readable version at all: the shape says
|
|
// fork but nothing corroborates it, so no fork frames get emitted on
|
|
// this connection (Gemini review, task #875 finding 1A).
|
|
return Classification(
|
|
population: DevicePopulation.stockLegacy,
|
|
conflict: true,
|
|
evidence:
|
|
'fork-shape frame (len=$length) but version string '
|
|
'"$firmwareVersion" does not corroborate',
|
|
);
|
|
}
|
|
|
|
// A stock-shape (82-byte) frame is definitively stock: the Offband fork
|
|
// always appends at least one caps byte (>=83 bytes), so a stock-shape frame
|
|
// can never be an Offband device. The version string is therefore NOT used to
|
|
// force a conflict in the stock direction. This replaces the old inverse
|
|
// conflict (#875 finding 2), which guarded an impossible case (an Offband
|
|
// device that failed to append caps) and misfired on real stock firmware:
|
|
// stock 1.18.0 reports "v1.18.0-81dbae5" (semver + git-describe hash), which
|
|
// reads as the dual form and hid the console on every real device (#946,
|
|
// found by E2.2 bench verification). The fork-shape branch above still
|
|
// requires dual-version corroboration, because emitting fork frames is the
|
|
// direction where being wrong is dangerous.
|
|
if (verCode >= stockCliMinVerCode) {
|
|
return Classification(
|
|
population: DevicePopulation.stockModern,
|
|
conflict: false,
|
|
evidence: 'stock-shape frame, ver=$verCode >= $stockCliMinVerCode',
|
|
);
|
|
}
|
|
|
|
return Classification(
|
|
population: DevicePopulation.stockLegacy,
|
|
conflict: false,
|
|
evidence: 'stock-shape frame, ver=$verCode',
|
|
);
|
|
}
|
|
|
|
/// What a firmware-version string positively claims about the population.
|
|
enum VersionStringShape {
|
|
/// Single `vX.Y.Z` (or `X.Y.Z`): the stock FIRMWARE_VERSION form.
|
|
stock,
|
|
|
|
/// The Offband dual form: a semver, a dash, then more -covering
|
|
/// `<ob>-<mc>` ("1.4.0-1.17.0"), the release-image `X-X` shape
|
|
/// ("1.4.0-1.4.0", firmware#691), and bench builds' git-describe tails
|
|
/// ("1.1.0-4-g9f2e1c3-dirty").
|
|
offbandDual,
|
|
|
|
/// Absent, empty, or nothing this parser recognizes. Never corroborates
|
|
/// anything: the classifier treats it as a missing signal, which on a
|
|
/// fork-shape frame means fail closed.
|
|
unknown,
|
|
}
|
|
|
|
// A stock version with a suffix (a git-describe hash like "v1.18.0-81dbae5",
|
|
// or a pre-release like "v1.19.0-rc1") parses as the dual form here. That no
|
|
// longer mis-classifies a stock device: a stock-shape (82-byte) frame is
|
|
// classified by its ver code regardless of the version string (#946), because
|
|
// the frame shape is definitive. This parser's dual verdict only matters on a
|
|
// FORK-shape frame, where it corroborates the caps bytes.
|
|
final RegExp _semver = RegExp(r'^v?\d+\.\d+\.\d+$');
|
|
final RegExp _dualPrefix = RegExp(r'^v?\d+\.\d+\.\d+-.+$');
|
|
|
|
/// Parses the device-info firmware-version field into its population claim
|
|
/// (#878). Pure and total: any input maps to exactly one shape.
|
|
VersionStringShape classifyVersionString(String? version) {
|
|
if (version == null) return VersionStringShape.unknown;
|
|
final v = version.trim();
|
|
if (v.isEmpty) return VersionStringShape.unknown;
|
|
if (_semver.hasMatch(v)) return VersionStringShape.stock;
|
|
if (_dualPrefix.hasMatch(v)) return VersionStringShape.offbandDual;
|
|
return VersionStringShape.unknown;
|
|
}
|
|
|
|
/// Adapter kept for the classifier's tri-state contract: true = positively
|
|
/// stock, false = positively Offband, null = no signal.
|
|
bool? _versionLooksStock(String? version) {
|
|
switch (classifyVersionString(version)) {
|
|
case VersionStringShape.stock:
|
|
return true;
|
|
case VersionStringShape.offbandDual:
|
|
return false;
|
|
case VersionStringShape.unknown:
|
|
return null;
|
|
}
|
|
}
|