diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b46f7240..2ea031f6 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -974,8 +974,7 @@ class MeshCoreConnector extends ChangeNotifier { DeviceUiSurface? get deviceUiErrorSurface => _deviceUiErrorSurface; /// Whether this radio answers the 0xC6 packet-hash query (#524/#611). - bool get supportsPktHash => - firmwareSupportsPktHash(_offbandCaps2, _firmwareVerCode); + bool get supportsPktHash => firmwareSupportsPktHash(_offbandCaps2); /// Whether to fetch CoreScope observer counts for outgoing channel messages: /// firmware supports the hash query AND the owner enabled the feature. @@ -1771,8 +1770,7 @@ class MeshCoreConnector extends ChangeNotifier { /// Whether the connected radio persists a block list + drops blocked DMs /// (Offband firmware, capability-gated). Absent → app-local block only. - bool get supportsOffbandBlock => - firmwareSupportsOffbandBlock(_offbandCaps, _firmwareVerCode); + bool get supportsOffbandBlock => firmwareSupportsOffbandBlock(_offbandCaps); /// True when the radio's block store hit `MAX_BLOCKED_KEYS` (32) and rejected /// an ADD (ok=0). Local block still applies; those keys just aren't portable. @@ -1787,8 +1785,7 @@ class MeshCoreConnector extends ChangeNotifier { /// Never shortcut this to a model or version check. bool get supportsOffbandFemLna => firmwareSupportsOffbandFemLna(_offbandCaps); - bool get supportsOffbandCaplog => - firmwareSupportsOffbandCaplog(_offbandCaps, _firmwareVerCode); + bool get supportsOffbandCaplog => firmwareSupportsOffbandCaplog(_offbandCaps); /// Current FEM LNA state as last reported by the radio, or null if unknown /// (pre-v16 firmware). Always reflects hardware truth, never a local guess. diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 5b3c9ab8..37bfef11 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -539,9 +539,9 @@ bool firmwareSupportsOffbandGps(int? offbandCaps) => offbandCaps != null; /// `OFFBAND_CAP_BLOCK` bit (bit 1) in the `offband_caps` byte of the device-info /// reply: an Offband radio that persists a block list and drops blocked DMs at -/// receive (firmware PR #247, `FIRMWARE_VER_CODE 15`). Unlike the loose GPS -/// presence-gate above, block requires the **explicit bit** set AND -/// `FIRMWARE_VER_CODE >= 15`; absent → app-only mode (no sync, no firmware drop). +/// receive (firmware PR #247). Unlike the loose GPS presence-gate above, block +/// requires the **explicit bit** set; absent → app-only mode (no sync, no +/// firmware drop). The bit alone, never the version code (#1065). const int offbandCapBlock = 0x02; /// `OFFBAND_CAP_FEM_LNA` bit (bit 2) in the `offband_caps` byte: this radio can @@ -559,25 +559,33 @@ const int offbandCapFemLna = 0x04; // POPULATION INVARIANT (#875-#877): every `offbandCaps`-anchored gate below is // safe against stock 1.18+ devices ONLY because the connector nulls the caps // bytes unless the population classifier corroborates the fork layout -// (device_population.dart). The ver-code floors in these gates are FORK -// contract numbers; stock reuses 14+ with different meanings, so a bare -// `firmwareVerCode >=` comparison may only be added for codes BELOW 14 -// (pre-split, same meaning everywhere: see supportsCompanionRadioStats >= 8 -// and firmwareSupportsRegionScope >= 13) or behind a caps-anchored gate here. +// (device_population.dart). +// +// NO CAPS-ANCHORED GATE MAY READ FIRMWARE_VER_CODE (#1065, owner ruling). The +// code rolled back to 14 to resolve the 1.18.0 collision, so a fork contract +// number is not a capability signal: a floor of >= 15, >= 17 or >= 22 goes +// false on firmware that genuinely has the feature. Those three floors were +// removed here, and they were never what protected stock radios -- the +// classifier is, by nulling the caps bytes a stock reply never carries. +// Capability is the BIT, which is also self-dating, since firmware predating a +// feature cannot set that feature's bit. +// +// The only surviving `firmwareVerCode >=` comparisons are the BITLESS ones for +// pre-split codes, which mean the same thing upstream and in the fork and have +// no bit to fall back on: supportsCompanionRadioStats >= 8 and +// firmwareSupportsRegionScope >= 13. Do not add another. bool firmwareSupportsOffbandFemLna(int? offbandCaps) => offbandCaps != null && (offbandCaps & offbandCapFemLna) != 0; -bool firmwareSupportsOffbandBlock(int? offbandCaps, int? firmwareVerCode) => - offbandCaps != null && - (offbandCaps & offbandCapBlock) != 0 && - (firmwareVerCode ?? 0) >= 15; +bool firmwareSupportsOffbandBlock(int? offbandCaps) => + offbandCaps != null && (offbandCaps & offbandCapBlock) != 0; /// Caplog serial-capture capability (firmware #427). The bit is compile-time /// static in device-info, so it's reliable across reboots, the client re-reads /// it on reconnect and never has to race a STATUS probe. Bit 5 (0x08/0x10 are -/// reserved for WiFi-companion #365 / display-config); requires FIRMWARE_VER_CODE -/// >= 17, the version that introduced it. +/// reserved for WiFi-companion #365 / display-config). Gated on the bit alone, +/// never the version code (#1065). const int offbandCapCaplog = 0x20; /// `offband_caps` BYTE 2 bits (device-info frame offset 84, firmware #508). @@ -637,10 +645,8 @@ bool firmwareSupportsScreenSettings(int? offbandCaps2) => bool firmwareSupportsGpsMode(int? offbandCaps2) => offbandCaps2 != null && (offbandCaps2 & offbandCap2GpsMode) != 0; -bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) => - offbandCaps != null && - (offbandCaps & offbandCapCaplog) != 0 && - (firmwareVerCode ?? 0) >= 17; +bool firmwareSupportsOffbandCaplog(int? offbandCaps) => + offbandCaps != null && (offbandCaps & offbandCapCaplog) != 0; /// Packet-hash query capability (firmware #611, cap byte 2 bit 3). Lets the /// client ask firmware for the authoritative on-air hash of a channel message @@ -649,13 +655,12 @@ bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) => /// PR merges; confirm against it before release. const int offbandCap2PktHash = 0x08; -/// True iff this radio supports the 0xC6 packet-hash query. Requires both the -/// cap-byte-2 bit AND FIRMWARE_VER_CODE >= 22. The client must never emit 0xC6 -/// unless this is true. -bool firmwareSupportsPktHash(int? offbandCaps2, int? firmwareVerCode) => - offbandCaps2 != null && - (offbandCaps2 & offbandCap2PktHash) != 0 && - (firmwareVerCode ?? 0) >= 22; +/// True iff this radio supports the 0xC6 packet-hash query: the cap-byte-2 bit +/// and nothing else (#1065). The client must never emit 0xC6 unless this is +/// true. Caps byte 2 only exists from the version that added device-info offset +/// 84, so the bit's presence already dates the firmware. +bool firmwareSupportsPktHash(int? offbandCaps2) => + offbandCaps2 != null && (offbandCaps2 & offbandCap2PktHash) != 0; /// 0xC6 CMD_OFFBAND_PKT_HASH (firmware #611). Client-issued query, never a push; /// the stock channel-send OK reply is untouched. diff --git a/test/connector/caplog_cap_test.dart b/test/connector/caplog_cap_test.dart index b3faf6ee..eaad6c31 100644 --- a/test/connector/caplog_cap_test.dart +++ b/test/connector/caplog_cap_test.dart @@ -3,30 +3,24 @@ import 'package:meshcore_open/connector/meshcore_protocol.dart'; void main() { group('firmwareSupportsOffbandCaplog', () { - test('true when 0x20 bit set AND version >= 17', () { - expect(firmwareSupportsOffbandCaplog(0x20, 17), isTrue); - expect( - firmwareSupportsOffbandCaplog(0x26, 17), - isTrue, - ); // block|fem|caplog - expect(firmwareSupportsOffbandCaplog(0x20, 18), isTrue); + test('true when the 0x20 bit is set', () { + expect(firmwareSupportsOffbandCaplog(0x20), isTrue); + expect(firmwareSupportsOffbandCaplog(0x26), isTrue); // block|fem|caplog }); test('false when the caplog bit is missing', () { - expect( - firmwareSupportsOffbandCaplog(0x06, 17), - isFalse, - ); // block|fem only - expect(firmwareSupportsOffbandCaplog(0x00, 17), isFalse); - }); - - test('false when version is below 17', () { - expect(firmwareSupportsOffbandCaplog(0x20, 16), isFalse); - expect(firmwareSupportsOffbandCaplog(0x20, null), isFalse); + expect(firmwareSupportsOffbandCaplog(0x06), isFalse); // block|fem only + expect(firmwareSupportsOffbandCaplog(0x00), isFalse); }); test('false when caps are unknown (null)', () { - expect(firmwareSupportsOffbandCaplog(null, 17), isFalse); + expect(firmwareSupportsOffbandCaplog(null), isFalse); }); + + // #1065: the gate used to AND `FIRMWARE_VER_CODE >= 17` on top of the bit, + // which went false on the rollback to 14. The version code is no longer a + // capability signal; the bit is the whole gate and nothing else is read. + // Stock radios are kept out by the population classifier nulling the caps + // bytes, not by a floor. See vercode_floor_regression_test.dart. }); } diff --git a/test/connector/offband_caps2_test.dart b/test/connector/offband_caps2_test.dart index 5d56073a..95562612 100644 --- a/test/connector/offband_caps2_test.dart +++ b/test/connector/offband_caps2_test.dart @@ -91,7 +91,7 @@ void main() { test('byte-1 gate predicates are unaffected by byte 2', () { final caps1 = MeshCoreConnector.parseOffbandCaps(frame); expect(firmwareSupportsOffbandFemLna(caps1), isTrue); - expect(firmwareSupportsOffbandBlock(caps1, 15), isTrue); + expect(firmwareSupportsOffbandBlock(caps1), isTrue); }); test('all three tail fields are independent', () { diff --git a/test/connector/pkt_hash_protocol_test.dart b/test/connector/pkt_hash_protocol_test.dart index ce6b234a..76e28d30 100644 --- a/test/connector/pkt_hash_protocol_test.dart +++ b/test/connector/pkt_hash_protocol_test.dart @@ -59,20 +59,22 @@ void main() { }); group('firmwareSupportsPktHash gate', () { - test('requires both the cap bit and ver >= 22', () { - expect(firmwareSupportsPktHash(0x08, 22), isTrue); - expect(firmwareSupportsPktHash(0x08, 23), isTrue); - expect(firmwareSupportsPktHash(0x08, 21), isFalse); // version too low - expect(firmwareSupportsPktHash(0x00, 22), isFalse); // bit clear - expect(firmwareSupportsPktHash(null, 22), isFalse); // no byte 2 - expect(firmwareSupportsPktHash(0x08, null), isFalse); // no version + // #1065: this used to also require `FIRMWARE_VER_CODE >= 22`, which went + // false on the rollback to 14. The cap bit is the whole gate now; caps + // byte 2 only exists from the version that added offset 84, so the bit + // already dates the firmware, and stock radios are excluded by the + // population classifier rather than by a version floor. + test('requires the cap bit, and nothing else', () { + expect(firmwareSupportsPktHash(0x08), isTrue); + expect(firmwareSupportsPktHash(0x00), isFalse); // bit clear + expect(firmwareSupportsPktHash(null), isFalse); // no byte 2 }); test('ignores unrelated bits', () { // 0x0A = button-matrix (0x02) + pkt-hash (0x08) both set. - expect(firmwareSupportsPktHash(0x0A, 22), isTrue); + expect(firmwareSupportsPktHash(0x0A), isTrue); // Only notify-scope (0x01) set: pkt-hash bit clear. - expect(firmwareSupportsPktHash(0x01, 22), isFalse); + expect(firmwareSupportsPktHash(0x01), isFalse); }); }); } diff --git a/test/connector/vercode_floor_regression_test.dart b/test/connector/vercode_floor_regression_test.dart new file mode 100644 index 00000000..0d08e3b8 --- /dev/null +++ b/test/connector/vercode_floor_regression_test.dart @@ -0,0 +1,126 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/device_population.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; + +/// #1065: FIRMWARE_VER_CODE is rolling back to 14 and no capability decision +/// may ride on it (owner ruling, Agent Mail 1401). Three shipped gates ANDed a +/// fork contract number on top of an already-correct caps-bit check, so they +/// went false on firmware that genuinely supports the feature. +/// +/// The stock protection these floors appeared to provide is actually carried by +/// the population classifier, which nulls both caps bytes unless the device-info +/// frame's SHAPE corroborates the fork layout. The last group here proves that, +/// so removing the floors cannot leak a fork frame to a stock radio. +void main() { + /// Stock replies are exactly 82 bytes. The fork appends caps byte 1 at 82, + /// the FEM LNA state at 83 and caps byte 2 at 84. + Uint8List deviceInfo({ + required int length, + int verCode = 14, + int caps1 = 0, + int femByte = 0, + int caps2 = 0, + }) { + final frame = Uint8List(length); + if (length >= 1) frame[0] = respCodeDeviceInfo; + if (length >= 2) frame[1] = verCode; + if (length >= 83) frame[82] = caps1; + if (length >= 84) frame[83] = femByte; + if (length >= 85) frame[84] = caps2; + return frame; + } + + group('#1065 the three gates must not read FIRMWARE_VER_CODE', () { + // The signatures take the caps byte and nothing else, so a version floor + // cannot be reintroduced by accident: there is no parameter to read. These + // assertions would have failed at ver 14 before the fix. + test('block list is capable with its bit set', () { + expect(firmwareSupportsOffbandBlock(offbandCapBlock), isTrue); + }); + + test('caplog is capable with its bit set', () { + expect(firmwareSupportsOffbandCaplog(offbandCapCaplog), isTrue); + }); + + test('packet hash is capable with its bit set', () { + expect(firmwareSupportsPktHash(offbandCap2PktHash), isTrue); + }); + + // A structural `isA()` assertion was tried here and + // REMOVED: an optional positional `[int? firmwareVerCode]` still satisfies + // that type, so the check passed while the floor was back in place. The + // behavioural assertions above are the real guard -- they were verified to + // fail against a deliberately reintroduced `>= 22` floor. + }); + + group('#1065 the bit is still the whole gate', () { + test('a clear bit is false', () { + expect(firmwareSupportsOffbandBlock(0x00), isFalse); + expect(firmwareSupportsOffbandCaplog(0x00), isFalse); + expect(firmwareSupportsPktHash(0x00), isFalse); + }); + + test('another feature bit does not open the gate', () { + expect(firmwareSupportsOffbandBlock(offbandCapFemLna), isFalse); + expect(firmwareSupportsOffbandCaplog(offbandCapBlock), isFalse); + expect(firmwareSupportsPktHash(offbandCap2NotifyScope), isFalse); + }); + + test('absent caps bytes are false, never an error', () { + expect(firmwareSupportsOffbandBlock(null), isFalse); + expect(firmwareSupportsOffbandCaplog(null), isFalse); + expect(firmwareSupportsPktHash(null), isFalse); + }); + }); + + group('#1065 the population classifier carries the stock protection', () { + test('a stock-shape frame at ver 14 is stockModern, not offband', () { + final c = classifyDevicePopulation( + deviceInfo: deviceInfo(length: stockDeviceInfoLength, verCode: 14), + firmwareVersion: 'v1.18.0-81dbae5', + ); + expect(c.population, DevicePopulation.stockModern); + expect(c.allowsOffbandFrames, isFalse); + }); + + test('a fork-shape frame at ver 14 is still offband', () { + // The case the floors broke: the fork reporting the rolled-back code. + final c = classifyDevicePopulation( + deviceInfo: deviceInfo( + length: 85, + verCode: 14, + caps1: offbandCapBlock | offbandCapCaplog, + caps2: offbandCap2PktHash, + ), + firmwareVersion: '1.4.0-1.17.0', + ); + expect(c.population, DevicePopulation.offband); + expect(c.allowsOffbandFrames, isTrue); + }); + + test('a stock radio yields no caps, so every gate is false', () { + // What the connector does at meshcore_connector.dart:7924-7938: the caps + // bytes are only parsed when the classifier allows fork frames. + final frame = deviceInfo(length: stockDeviceInfoLength, verCode: 14); + final c = classifyDevicePopulation( + deviceInfo: frame, + firmwareVersion: 'v1.18.0-81dbae5', + ); + final caps1 = c.allowsOffbandFrames + ? MeshCoreConnector.parseOffbandCaps(frame) + : null; + final caps2 = c.allowsOffbandFrames + ? MeshCoreConnector.parseOffbandCaps2(frame) + : null; + + expect(caps1, isNull); + expect(caps2, isNull); + expect(firmwareSupportsOffbandBlock(caps1), isFalse); + expect(firmwareSupportsOffbandCaplog(caps1), isFalse); + expect(firmwareSupportsPktHash(caps2), isFalse); + }); + }); +} diff --git a/test/device_population_test.dart b/test/device_population_test.dart index 1767b6c1..139e9716 100644 --- a/test/device_population_test.dart +++ b/test/device_population_test.dart @@ -314,5 +314,23 @@ void main() { // Pre-split floors (same meaning on every population) stay available. expect(connector.supportsCompanionRadioStats, isTrue); }); + + test('stock at ver 14 lights nothing either (#1065): the real stock code,' + ' now that the fork rolls back onto the same number', () { + // The case above uses 22, which no stock device reports. 14 is what real + // stock 1.18 sends AND what the fork reports after the rollback, so this + // is the collision in its actual form. The three gates whose version + // floors were removed in #1065 must still be false here, and they are, + // because the 82-byte shape classifies stockModern and the connector + // nulls both caps bytes. + connector.handleFrameForTest( + deviceInfo(verCode: 14, version: 'v1.18.0-81dbae5'), + ); + expect(connector.offbandCaps, isNull); + expect(connector.offbandCaps2, isNull); + expect(connector.supportsOffbandBlock, isFalse); + expect(connector.supportsOffbandCaplog, isFalse); + expect(connector.supportsPktHash, isFalse); + }); }); }