You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/lib/connector/offband_capability_advert.dart

211 lines
8.2 KiB

import 'dart:typed_data';
import 'offband_device_ui.dart';
/// The `0xC5` `0x1C` capability advert (#1095, firmware #1994).
///
/// Replaces the practice of appending Offband capability bytes to upstream's
/// `RESP_CODE_DEVICE_INFO` frame. Three features each computed "the next free
/// byte" as **87** and each was right in isolation, and a collision at a frame
/// offset is silent misparsing rather than a refused command. Two permanent
/// rules came out of that (firmware `docs/offband_info_frame_contract.md` §2):
/// no Offband field is ever added to upstream's frame again, and a number is
/// claimed by merge before any work begins.
///
/// THE AUTHORITY for every number here is firmware
/// `examples/companion_radio/OffbandConfigProtocol.h`, merged to
/// `firmware-base` in PR OffbandMesh/meshcore-firmware#2000 (`28785ebf`,
/// 2026-11-04). The contract's §5 table is a convenience copy and the header
/// wins on any disagreement. Do not take a number from an Agent Mail message:
/// that habit is what caused the collision.
const int offbandUiInfoGet = 0x1C;
/// A tag's PRESENCE is the capability. There is no capability bitmap in this
/// frame: tags already answer "is this supported", so a bitmap beside them
/// would be a second source of truth for one question (the defect removed from
/// contract revision 1).
///
/// - `len = 0` means "I have this feature, nothing to report". The normal case.
/// - `len > 0` only where the value is one or two bytes, fixed for the build,
/// and has no sub-code of its own. Everything else is read with its own GET.
/// - **Absent** means the board does not have the feature. Not an error, and
/// not a zero value.
const int offbandInfoTagChanNotify = 0x01;
const int offbandInfoTagDisplayRotation = 0x02;
const int offbandInfoTagAckCapacity = 0x03;
/// Display control and LED control are separate capabilities (firmware #2067,
/// PR OffbandMesh/meshcore-firmware#2080, merged `c835aeca`): a board can have
/// one without the other, and caps2 `0x04` INDICATORS answers both with one
/// bit. Values are read over `0x05`/`0x06` and `0x07`/`0x08`.
const int offbandInfoTagDisplayControl = 0x04;
const int offbandInfoTagLedControl = 0x05;
const int offbandInfoTagContinue = 0xFF;
/// `0x00` is not a valid tag.
const int _invalidTag = 0x00;
/// Firmware `OFFBAND_UI_ERR_MALFORMED = 5`.
///
/// The `0xC5` handler returns this reason for BOTH a malformed payload on a
/// known sub-code and a sub-code it does not implement at all. That overload is
/// the entire reason the advert request must carry zero payload: with no
/// payload there is nothing to be malformed, so the answer can only mean the
/// radio does not implement `0x1C`.
const int offbandUiErrReasonMalformed = 0x05;
/// Ceiling on continuation rounds. The advert is a handful of bytes today and
/// no shipping firmware can overflow it, so any long chain is a firmware fault
/// rather than a large reply. Bounded because an unbounded request loop driven
/// by device data is a worse failure than a truncated capability list.
const int offbandInfoMaxContinuations = 8;
/// Request the whole advert. **Zero payload, deliberately.**
///
/// A `[0xC5][0x7F][0x05]` answer to a zero-payload request is unambiguous:
/// reason 5 is also what a KNOWN sub-code returns for a malformed payload, so
/// a request carrying bytes could not be told apart from a device that simply
/// predates the advert.
Uint8List buildCapabilityAdvertRequestFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiInfoGet]);
/// Resume from [cursor], which is the value `TAG_CONTINUE` carried.
Uint8List buildCapabilityAdvertResumeFrame(int cursor) {
if (cursor < 0 || cursor > 0xFF) {
throw ArgumentError.value(cursor, 'cursor', 'must be 0 to 255');
}
return Uint8List.fromList([cmdOffbandDeviceUi, offbandUiInfoGet, cursor]);
}
/// One `0xC5` `0x1C` reply, parsed.
class OffbandCapabilityAdvert {
const OffbandCapabilityAdvert({
required this.tags,
this.continueCursor,
this.malformed = false,
});
/// Tag to value. An empty value is the `len = 0` case and still means
/// supported, so membership is the question to ask, never emptiness.
final Map<int, Uint8List> tags;
/// Non-null when the reply ended in `TAG_CONTINUE`: re-request with this as
/// the cursor. A reply without it is complete.
final int? continueCursor;
/// A record was unparseable and parsing stopped there. The tags BEFORE it are
/// still returned, because capability absence is safe (the feature hides)
/// while discarding a whole good reply over a bad trailing byte is not.
///
/// Firmware must not split a record across frames, so this is a firmware
/// fault and worth logging rather than swallowing.
final bool malformed;
bool has(int tag) => tags.containsKey(tag);
/// The single-byte value of [tag], or null when the tag is absent or does not
/// carry exactly one byte. Null is never coerced to 0: "the device did not
/// say" and "the device said zero" are different answers.
int? byteValue(int tag) {
final value = tags[tag];
if (value == null || value.length != 1) return null;
return value[0];
}
@override
String toString() {
final names = tags.keys.toList()..sort();
return 'advert(tags=[${names.map((t) => '0x${t.toRadixString(16).padLeft(2, '0')}').join(',')}]'
'${continueCursor == null ? '' : ', continue=0x${continueCursor!.toRadixString(16).padLeft(2, '0')}'}'
'${malformed ? ', MALFORMED' : ''})';
}
}
/// Parse a `0xC5` `0x1C` reply. Null when the frame is not one of those at all.
///
/// A reply with no records is valid and means "nothing further", which is what
/// a cursor above every available tag returns. It is not an error.
///
/// Unknown tags are KEPT and skipped by `len`, never rejected: a client built
/// today has to stay correct against firmware emitting tags it has never heard
/// of, and failing on one is non-conforming.
OffbandCapabilityAdvert? parseCapabilityAdvertReply(List<int> frame) {
if (frame.length < 2) return null;
if (frame[0] != respCodeOffbandDeviceUi) return null;
if (frame[1] != offbandUiInfoGet) return null;
final tags = <int, Uint8List>{};
int? continueCursor;
var malformed = false;
var i = 2;
while (i < frame.length) {
final tag = frame[i];
if (tag == _invalidTag) {
malformed = true;
break;
}
if (i + 1 >= frame.length) {
// A tag with no length byte.
malformed = true;
break;
}
final len = frame[i + 1];
final valueStart = i + 2;
final valueEnd = valueStart + len;
if (valueEnd > frame.length) {
// A record running past the frame. Firmware must not split one.
malformed = true;
break;
}
final value = Uint8List.fromList(frame.sublist(valueStart, valueEnd));
if (tag == offbandInfoTagContinue) {
if (len != 1) {
malformed = true;
break;
}
continueCursor = value[0];
// TAG_CONTINUE is the last record by construction. Anything after it is
// not something to guess at.
malformed = valueEnd != frame.length;
break;
}
// Records arrive in ascending tag order, but position is never relied on,
// so an out-of-order frame still parses. A repeated tag is a firmware
// fault: keep the FIRST value and say so, rather than letting a later
// record silently redefine a capability.
if (tags.containsKey(tag)) {
malformed = true;
} else {
tags[tag] = value;
}
i = valueEnd;
}
return OffbandCapabilityAdvert(
tags: tags,
continueCursor: continueCursor,
malformed: malformed,
);
}
/// How far the client has got with asking this radio for its advert.
///
/// Distinguishes "has not answered yet" from "answered, and says it does not
/// have this mechanism". Those lead to different gating, and a single nullable
/// field would conflate them exactly where the collision taught us not to.
enum CapabilityAdvertState {
/// No request sent, or sent and still outstanding.
unknown,
/// A `0x1C` reply arrived and was parsed.
advertised,
/// `[0xC5][0x7F][0x05]` to our zero-payload request: this radio predates the
/// advert, so capability reading falls back to the absolute device-info
/// offsets that shipped before it.
unsupported,
}

Powered by TurnKey Linux.