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.
meshcore-client/lib/connector/device_population.dart

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;
}
}

Powered by TurnKey Linux.