diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 21b5653..da62d3e 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -294,6 +294,7 @@ class MeshCoreConnector extends ChangeNotifier { String? _firmwareVersion; String? _deviceModel; int? _offbandCaps; + int? _offbandCaps2; bool? _femLnaEnabled; int _pathHashByteWidth = 1; CompanionRadioStats? _latestRadioStats; @@ -610,6 +611,11 @@ class MeshCoreConnector extends ChangeNotifier { /// null on older firmware that sends a shorter frame. int? get offbandCaps => _offbandCaps; + /// Second `offband_caps` bitfield (frame offset 84, firmware #508). Null when + /// the frame is too short, which means "no byte-2 capabilities" and is never + /// an error: every radio predating #508 reports null here. (#480) + int? get offbandCaps2 => _offbandCaps2; + /// Whether the connected firmware speaks the `0xC1` GPS extension, i.e. it /// advertised the Offband-fork `offband_caps` byte. Stock MeshCore omits it, /// so 0xC1 is suppressed there rather than pinged blindly. (#144) @@ -5022,6 +5028,21 @@ class MeshCoreConnector extends ChangeNotifier { static bool? parseFemLnaState(Uint8List frame) => frame.length >= 84 ? frame[83] != femLnaBypass : null; + /// Second `offband_caps` byte, at frame offset **84** (firmware #508). + /// + /// Deliberately NOT adjacent to byte 1 at offset 82: offset 83 is already the + /// FEM LNA state byte, and every field here is read at a FIXED ABSOLUTE + /// offset, so inserting byte 2 next to byte 1 would shift the FEM state and + /// make shipped clients misread a bitmask as the LNA toggle. Firmware appends + /// byte 2 at the end of the frame for exactly that reason + /// (`OffbandConfigProtocol.h`, firmware PR #515). + /// + /// Null on any firmware predating #508, which sends a shorter frame. Null + /// means "no byte-2 capabilities", never an error. Bounds-checked like its + /// siblings, so a truncated or hostile short frame never indexes OOB. + static int? parseOffbandCaps2(Uint8List frame) => + frame.length >= 85 ? frame[84] : null; + void _handleDeviceInfo(Uint8List frame) { if (frame.length < 4) return; if (_shouldGateInitialChannelSync) { @@ -5072,11 +5093,14 @@ class MeshCoreConnector extends ChangeNotifier { // FEM LNA state rides one byte past the caps byte on v16+ (#304). Primary // read on connect, a 0xC3 GET is only the fallback. _femLnaEnabled = parseFemLnaState(frame); + // Second caps byte rides at offset 84, past the FEM state byte (#480). + _offbandCaps2 = parseOffbandCaps2(frame); // Capability-gated features are invisible when a bit is clear, which looks // identical to a bug. Log the raw inputs so "the toggle didn't appear" can // be told apart from "this radio says it can't". (#304) _appDebugLogService?.info( 'Offband caps=0x${(_offbandCaps ?? 0).toRadixString(16).padLeft(2, '0')} ' + 'caps2=${_offbandCaps2 == null ? 'absent' : '0x${_offbandCaps2!.toRadixString(16).padLeft(2, '0')}'} ' 'verCode=${_firmwareVerCode ?? 0} frameLen=${frame.length} ' 'femLnaByte=${_femLnaEnabled == null ? 'absent' : (_femLnaEnabled! ? '1' : '0')} ' 'femCapable=$supportsOffbandFemLna blockCapable=$supportsOffbandBlock', diff --git a/test/connector/offband_caps2_test.dart b/test/connector/offband_caps2_test.dart new file mode 100644 index 0000000..5d56073 --- /dev/null +++ b/test/connector/offband_caps2_test.dart @@ -0,0 +1,104 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; + +void main() { + /// Device-info frame with the three additive tail bytes at their fixed + /// absolute offsets: caps byte 1 at 82, FEM LNA state at 83, caps byte 2 at + /// 84 (firmware #508 / PR #515). + Uint8List deviceInfo({ + required int length, + int caps1 = 0, + int femByte = 0, + int caps2 = 0, + }) { + final frame = Uint8List(length); + if (length >= 1) frame[0] = respCodeDeviceInfo; + if (length >= 83) frame[82] = caps1; + if (length >= 84) frame[83] = femByte; + if (length >= 85) frame[84] = caps2; + return frame; + } + + group('offband_caps byte 2, device-info offset 84 (#480)', () { + test('reads byte 84 when the frame is long enough', () { + expect( + MeshCoreConnector.parseOffbandCaps2( + deviceInfo(length: 85, caps2: 0x01), + ), + equals(0x01), + ); + expect( + MeshCoreConnector.parseOffbandCaps2( + deviceInfo(length: 85, caps2: 0xFF), + ), + equals(0xFF), + ); + }); + + test('a present byte 2 of zero is still present, not absent', () { + // "No byte-2 capabilities set" and "firmware predates byte 2" are + // different states; only the second is null. + expect( + MeshCoreConnector.parseOffbandCaps2(deviceInfo(length: 85, caps2: 0)), + equals(0), + ); + }); + + test('null on firmware predating #508 (frame stops at 84)', () { + expect( + MeshCoreConnector.parseOffbandCaps2(deviceInfo(length: 84)), + isNull, + ); + }); + + test('null on a short or truncated frame, never an OOB index', () { + for (final length in [0, 1, 4, 81, 82, 83, 84]) { + expect( + MeshCoreConnector.parseOffbandCaps2(deviceInfo(length: length)), + isNull, + reason: 'frame length $length must yield null, not throw', + ); + } + }); + }); + + group('byte 2 does not disturb the fields before it', () { + // This is the regression the offset-84 choice exists to prevent: byte 2 is + // appended at the END of the frame, not adjacent to byte 1, because the FEM + // state byte already occupies 83 and every field is read at a fixed + // absolute offset. + final frame = deviceInfo( + length: 85, + caps1: offbandCapFemLna | offbandCapBlock, + femByte: 1, + caps2: 0x03, + ); + + test('caps byte 1 still reads at offset 82', () { + expect( + MeshCoreConnector.parseOffbandCaps(frame), + equals(offbandCapFemLna | offbandCapBlock), + ); + }); + + test('FEM LNA state still reads at offset 83', () { + expect(MeshCoreConnector.parseFemLnaState(frame), isTrue); + }); + + test('byte-1 gate predicates are unaffected by byte 2', () { + final caps1 = MeshCoreConnector.parseOffbandCaps(frame); + expect(firmwareSupportsOffbandFemLna(caps1), isTrue); + expect(firmwareSupportsOffbandBlock(caps1, 15), isTrue); + }); + + test('all three tail fields are independent', () { + final onlyCaps2 = deviceInfo(length: 85, caps2: 0x02); + expect(MeshCoreConnector.parseOffbandCaps(onlyCaps2), equals(0)); + expect(MeshCoreConnector.parseFemLnaState(onlyCaps2), isFalse); + expect(MeshCoreConnector.parseOffbandCaps2(onlyCaps2), equals(0x02)); + }); + }); +}