diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index eafb650..93a926c 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -4031,6 +4031,17 @@ class MeshCoreConnector extends ChangeNotifier { _maybeStartInitialChannelSync(); } + /// Extract the additive `offband_caps` byte from a device-info reply. + /// + /// Offset verified against firmware MyMesh.cpp: the reply tail is three + /// consecutive `out_frame[i++]` writes — client_repeat (byte 80, v9+), + /// path_hash_mode (byte 81, v10+), offband_caps (byte 82, v14+). Bytes 80/81 + /// are shipping/working (path-hash feature), so byte 82 is the adjacent next + /// byte by construction. Pre-v14 firmware sends a shorter frame -> null. + /// Bounds-checked: a truncated or hostile short frame never indexes OOB. + static int? parseOffbandCaps(Uint8List frame) => + frame.length >= 83 ? frame[82] : null; + void _handleDeviceInfo(Uint8List frame) { if (frame.length < 4) return; if (_shouldGateInitialChannelSync) { @@ -4049,9 +4060,9 @@ class MeshCoreConnector extends ChangeNotifier { } else { _pathHashByteWidth = 1; } - // Offband config capability v14+ (byte 82): offband_caps bitfield, appended - // after path_hash_mode (additive — older firmware sends a shorter frame). - _offbandCaps = frame.length >= 83 ? frame[82] : null; + // Offband config capability v14+ (byte 82). Extracted + bounds-checked in a + // testable helper; offset verified against firmware (see parseOffbandCaps). + _offbandCaps = parseOffbandCaps(frame); // Firmware reports MAX_CONTACTS / 2 for v3+ device info. final reportedContacts = frame[2]; diff --git a/test/connector/observer_caps_test.dart b/test/connector/observer_caps_test.dart new file mode 100644 index 0000000..cba0801 --- /dev/null +++ b/test/connector/observer_caps_test.dart @@ -0,0 +1,60 @@ +// byte-82 offset + short-frame robustness for the device-info offband_caps +// read (#64 N1, Gemini MAJOR #4). +// +// The offset itself is verified against firmware source (MyMesh.cpp writes +// client_repeat@80, path_hash_mode@81, offband_caps@82 as consecutive +// out_frame[i++]; bytes 80/81 are shipping). These tests pin the bounds guard +// so a truncated or hostile short device-info frame can never index OOB, and +// that byte 82 is read verbatim when present. A live observer node confirms +// end-to-end at G2. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/observer_config_client.dart'; + +void main() { + // A device-info frame of [len] bytes, byte 82 = [caps] when the frame is long + // enough to carry it. + Uint8List frame(int len, {int caps = 0}) { + final f = Uint8List(len); + if (len > 82) f[82] = caps; + return f; + } + + group('parseOffbandCaps — offset 82 + short-frame robustness', () { + test('pre-v14 frame without the caps byte (len 82) -> null', () { + expect(MeshCoreConnector.parseOffbandCaps(frame(82)), isNull); + }); + + test('len 83 reads byte 82 verbatim', () { + expect(MeshCoreConnector.parseOffbandCaps(frame(83, caps: 0x01)), 0x01); + expect(MeshCoreConnector.parseOffbandCaps(frame(83, caps: 0x00)), 0x00); + expect(MeshCoreConnector.parseOffbandCaps(frame(90, caps: 0xFF)), 0xFF); + }); + + test('observer capability bit round-trips through supportsConfig', () { + final caps = MeshCoreConnector.parseOffbandCaps( + frame(83, caps: ObserverConfigClient.capWifiObserver), + )!; + expect( + ObserverConfigClient.supportsConfig( + firmwareVerCode: ObserverConfigClient.minVerCode, + offbandCaps: caps, + ), + isTrue, + ); + }); + + test('hostile/truncated short frames never throw, always null', () { + for (final n in [0, 1, 4, 80, 81, 82]) { + expect( + MeshCoreConnector.parseOffbandCaps(frame(n)), + isNull, + reason: 'frame length $n must not index byte 82', + ); + } + }); + }); +}