From 45bfa33eede0352c495493c617b2a9f3b7999040 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 23:25:20 -0400 Subject: [PATCH] feat(#304): 0xC3 FEM LNA protocol layer + capability gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the client half of the Heltec V4 FEM LNA control surface at the protocol level: 0xC3 CMD_OFFBAND_FEM_LNA (0x01 SET [value], 0x02 GET), a 3-byte [0xC3][sub][value] reply parser, and the OFFBAND_CAP_FEM_LNA = 0x04 capability gate. Deliberately a fork-private command rather than a sixth byte on the stock CMD_SET_OTHER_PARAMS (38): that frame is shared with upstream MeshCore and is sent to every radio regardless of fork, so widening it would perturb stock firmware. Nothing is emitted unless the capability bit is set. The gate checks the bit only, never model or version — firmware derives it at runtime from the auto-detected FEM chip, so it is a per-unit answer and two Heltec V4s can legitimately disagree. PROVISIONAL: firmware owns the caps byte and has not yet confirmed 0x04 is free, and the 0xC3 setter is not built yet. Spec sent to OffbandMesh/meshcore-firmware#298. Do not merge before firmware confirms the shape as built. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_protocol.dart | 55 ++++++++++++++++ test/connector/offband_fem_lna_test.dart | 84 ++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 test/connector/offband_fem_lna_test.dart diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 516af02..e5f8821 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -263,6 +263,46 @@ const int respCodeOffbandGps = 0xC1; /// Request frame for [cmdOffbandGps] — a bare 1-byte command, no payload. (#135) Uint8List buildOffbandGpsRequestFrame() => Uint8List.fromList([cmdOffbandGps]); +// --- Offband FEM LNA command (0xC3) — capability-gated. Heltec V4 external +// FEM LNA control; firmware counterpart OffbandMesh/meshcore-firmware#298. +// +// Deliberately a fork-private command rather than an extra byte on the stock +// CMD_SET_OTHER_PARAMS (38): that frame is shared with upstream MeshCore and is +// sent to every radio regardless of fork, so widening it would perturb stock +// firmware. Nothing here is emitted unless the capability bit is set. (#304) +const int cmdOffbandFemLna = 0xC3; +const int offbandFemLnaSet = 0x01; +const int offbandFemLnaGet = 0x02; + +/// `0` = FEM LNA bypassed, `1` = enabled. Firmware default is enabled. +const int femLnaBypass = 0x00; +const int femLnaEnabled = 0x01; + +Uint8List buildOffbandFemLnaSetFrame(bool enabled) => Uint8List.fromList([ + cmdOffbandFemLna, + offbandFemLnaSet, + enabled ? femLnaEnabled : femLnaBypass, +]); + +Uint8List buildOffbandFemLnaGetFrame() => + Uint8List.fromList([cmdOffbandFemLna, offbandFemLnaGet]); + +/// Reply to a `0xC3` request: `[0xC3][sub][value]`. A malformed request draws +/// the generic `[respCodeErr][errCodeIllegalArg]` instead, which is NOT +/// 0xC3-prefixed and so never reaches this parser. +class OffbandFemLnaReply { + const OffbandFemLnaReply(this.subType, this.value); + final int subType; + final int value; + + bool get enabled => value != femLnaBypass; +} + +OffbandFemLnaReply? parseOffbandFemLnaReply(Uint8List frame) { + if (frame.length < 3 || frame[0] != cmdOffbandFemLna) return null; + return OffbandFemLnaReply(frame[1], frame[2]); +} + // --- Offband block command (0xC2) — capability-gated; see // docs/architecture/block-contract-as-built.md §8. Firmware as-built PR #247. --- const int cmdOffbandBlock = 0xC2; @@ -319,6 +359,21 @@ bool firmwareSupportsOffbandGps(int? offbandCaps) => offbandCaps != null; /// `FIRMWARE_VER_CODE >= 15`; absent → app-only mode (no sync, no firmware drop). const int offbandCapBlock = 0x02; +/// `OFFBAND_CAP_FEM_LNA` bit (bit 2) in the `offband_caps` byte: this radio can +/// control its external FEM LNA (firmware #298). +/// +/// PROVISIONAL — firmware owns the caps byte and has not yet confirmed 0x04 as +/// free. Do not ship against this without that confirmation (#304). +/// +/// Gate on the BIT ONLY, never on model or version: firmware derives it at +/// runtime from the auto-detected FEM chip (KCT8103L vs GC1109), so it is a +/// per-unit answer — two Heltec V4s can legitimately disagree, and other +/// FEM-bearing boards report false today. +const int offbandCapFemLna = 0x04; + +bool firmwareSupportsOffbandFemLna(int? offbandCaps) => + offbandCaps != null && (offbandCaps & offbandCapFemLna) != 0; + bool firmwareSupportsOffbandBlock(int? offbandCaps, int? firmwareVerCode) => offbandCaps != null && (offbandCaps & offbandCapBlock) != 0 && diff --git a/test/connector/offband_fem_lna_test.dart b/test/connector/offband_fem_lna_test.dart new file mode 100644 index 0000000..2768dd8 --- /dev/null +++ b/test/connector/offband_fem_lna_test.dart @@ -0,0 +1,84 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; + +void main() { + group('FEM LNA capability gate (#304)', () { + test('requires the explicit bit, never model or version', () { + expect(firmwareSupportsOffbandFemLna(offbandCapFemLna), isTrue); + expect( + firmwareSupportsOffbandFemLna(offbandCapFemLna | offbandCapBlock), + isTrue, + ); + }); + + test('false when the bit is clear, even on an Offband radio', () { + // Offband firmware that supports block but not FEM LNA control. + expect(firmwareSupportsOffbandFemLna(offbandCapBlock), isFalse); + expect(firmwareSupportsOffbandFemLna(0x00), isFalse); + }); + + test('false on stock firmware (no caps byte at all)', () { + expect(firmwareSupportsOffbandFemLna(null), isFalse); + }); + + test('does not collide with the block capability bit', () { + expect(offbandCapFemLna & offbandCapBlock, equals(0)); + }); + }); + + group('FEM LNA frames (#304)', () { + test('SET carries the enable value', () { + expect( + buildOffbandFemLnaSetFrame(true), + equals(Uint8List.fromList([0xC3, 0x01, 0x01])), + ); + expect( + buildOffbandFemLnaSetFrame(false), + equals(Uint8List.fromList([0xC3, 0x01, 0x00])), + ); + }); + + test('GET is a bare 2-byte request', () { + expect( + buildOffbandFemLnaGetFrame(), + equals(Uint8List.fromList([0xC3, 0x02])), + ); + }); + + test('reply parses sub-type and value', () { + final reply = parseOffbandFemLnaReply( + Uint8List.fromList([0xC3, 0x02, 0x01]), + ); + expect(reply, isNotNull); + expect(reply!.subType, equals(offbandFemLnaGet)); + expect(reply.enabled, isTrue); + + final bypassed = parseOffbandFemLnaReply( + Uint8List.fromList([0xC3, 0x02, 0x00]), + ); + expect(bypassed!.enabled, isFalse); + }); + + test('rejects a short frame instead of throwing', () { + expect(parseOffbandFemLnaReply(Uint8List.fromList([0xC3, 0x02])), isNull); + expect(parseOffbandFemLnaReply(Uint8List.fromList([])), isNull); + }); + + test('ignores frames belonging to another command', () { + // The generic error reply is [0x01][0x06] and is NOT 0xC3-prefixed, so it + // must never be mistaken for a FEM LNA reply. + expect( + parseOffbandFemLnaReply( + Uint8List.fromList([respCodeErr, errCodeIllegalArg]), + ), + isNull, + ); + expect( + parseOffbandFemLnaReply(Uint8List.fromList([0xC2, 0x01, 0x01])), + isNull, + ); + }); + }); +}