feat(#304): 0xC3 FEM LNA protocol layer + capability gate

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 <noreply@anthropic.com>
pull/317/head
Strycher 3 days ago
parent 879523d082
commit 8f11fe5b4a

@ -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 &&

@ -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,
);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.