commit
0c405bb659
@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail if an em-dash (U+2014) appears in source-authored text.
|
||||
|
||||
The em-dash reads as an AI tell, so it is kept out of the public repo (#457,
|
||||
#462, #464). This guard runs in CI (the `analyze` job) and can also be wired
|
||||
into a local pre-commit hook.
|
||||
|
||||
In scope:
|
||||
- lib/**/*.dart and test/**/*.dart (code comments + string literals)
|
||||
- lib/l10n/app_en.arb (English UI copy)
|
||||
|
||||
Out of scope (never flagged):
|
||||
- Generated files: *.g.dart, lib/l10n/app_localizations*.dart
|
||||
- Non-English ARB (lib/l10n/app_*.arb except app_en.arb): an em-dash may be
|
||||
legitimate target-language punctuation.
|
||||
- The lone "no data / not available" glyph placeholder: a dash that is the
|
||||
entire quoted value, e.g. `return '—';`, `"key": "—"`, `text: '—'`. That is
|
||||
a deliberate UI element, not prose.
|
||||
|
||||
Exit status: 0 when clean, 1 when any in-scope em-dash is found (each printed
|
||||
as file:line: <content>).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
EM_DASH = "—"
|
||||
ROOTS = ("lib", "test")
|
||||
|
||||
|
||||
def _has_prose_emdash(line: str) -> bool:
|
||||
"""True if the line has an em-dash that is NOT a lone glyph placeholder.
|
||||
|
||||
The lone "no data" glyph is a dash that is the entire quoted value (`'—'`
|
||||
or `"—"`). Strip those tokens first, so a line carrying both a glyph and a
|
||||
real prose em-dash is still flagged.
|
||||
"""
|
||||
stripped = line.replace("'" + EM_DASH + "'", "").replace('"' + EM_DASH + '"', "")
|
||||
return EM_DASH in stripped
|
||||
|
||||
|
||||
def _in_scope(path: str) -> bool:
|
||||
base = os.path.basename(path)
|
||||
if base.endswith(".g.dart") or base.startswith("app_localizations"):
|
||||
return False
|
||||
if base.endswith(".dart"):
|
||||
return True
|
||||
# ARB: only the English source is in scope.
|
||||
return base == "app_en.arb"
|
||||
|
||||
|
||||
def find_violations(roots=ROOTS):
|
||||
hits = []
|
||||
for root in roots:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, _dirs, filenames in os.walk(root):
|
||||
for name in filenames:
|
||||
if not (name.endswith(".dart") or name.endswith(".arb")):
|
||||
continue
|
||||
path = os.path.join(dirpath, name)
|
||||
if not _in_scope(path):
|
||||
continue
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for lineno, line in enumerate(fh, 1):
|
||||
if _has_prose_emdash(line):
|
||||
hits.append((path.replace(os.sep, "/"), lineno, line.rstrip()))
|
||||
return hits
|
||||
|
||||
|
||||
def main() -> int:
|
||||
roots = tuple(sys.argv[1:]) or ROOTS
|
||||
hits = find_violations(roots)
|
||||
if not hits:
|
||||
print("check_no_emdash: OK (no em-dash characters in source)")
|
||||
return 0
|
||||
print(
|
||||
"check_no_emdash: found the em-dash character (U+2014) in source. "
|
||||
"Replace it with a comma, colon, or period (see #464):",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for path, lineno, content in hits:
|
||||
print(f" {path}:{lineno}: {content}", file=sys.stderr)
|
||||
print(f"\n{len(hits)} em-dash(es) found.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -0,0 +1,15 @@
|
||||
🔧 **Offband Meshcore 1.4.0** is up, a focused follow-up to the 1.3.0 production launch.
|
||||
|
||||
🔘 **Button and buzzer settings for headless devices.** On radios that report support, configure the button and buzzer straight from settings.
|
||||
|
||||
🔁 **"Send Again" on channel messages.** Resend a channel message in one tap, with the DM resend action relabeled to match.
|
||||
|
||||
🔎 **More capability detail in Device Info**, so more of your radio's capability bits are visible at a glance.
|
||||
|
||||
🛠️ **Fixes:** the block list is scoped per radio again (an accidental global list is gone), per-radio caches clear on reconnect so nothing lingers from another radio, translation-model downloads retry on transient errors, and routed replies use the correct path width.
|
||||
|
||||
**📥 Get it on Google Play:** search **Offband MeshCore**, or https://play.google.com/store/apps/details?id=app.offband.meshcore
|
||||
💻 **Desktop (Windows / Linux) and Android APK:** https://github.com/OffbandMesh/meshcore-client/releases/tag/v1.4.0
|
||||
🌐 **Or run it in your browser:** https://offband.app
|
||||
|
||||
💚 Thanks as always to the OKIMesh community (https://okimesh.org/). Offband is free and open source and always will be. If you'd like to help keep it going: https://offband.org/donate
|
||||
@ -0,0 +1,318 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Headless-device UI configuration: the button-action matrix (#474) and the
|
||||
/// device notification scope (#475).
|
||||
///
|
||||
/// ⚠ THE COMMAND DOES NOT EXIST ON THE WIRE YET. Firmware confirmed
|
||||
/// (FuchsiaCreek, 2026-08-01) that nothing currently reads or writes either
|
||||
/// surface: the capability bit is discoverable, but the scope changes only by
|
||||
/// triple-pressing the device, and the button matrix is unwritten. Firmware
|
||||
/// asked for a client-specified shape and is building to the spec below, so
|
||||
/// this file is the proposal, kept in one place so a landed contract is a
|
||||
/// constant change rather than a rewrite.
|
||||
///
|
||||
/// Until firmware lands it, [MeshCoreConnector.supportsDeviceUiCommand] is
|
||||
/// false and no frame is ever emitted. The client can see that a radio supports
|
||||
/// the feature without being able to query it, which is exactly what the UI
|
||||
/// reports.
|
||||
///
|
||||
/// ONE command byte covers both epics, sub-code selects the surface, so #510
|
||||
/// does not need a second allocation. 0xC5 is the next free code after 0xC0
|
||||
/// config, 0xC1 GPS, 0xC2 block, 0xC3 FEM LNA, 0xC4 caplog.
|
||||
///
|
||||
/// Deliberately NOT folded into `CMD_OFFBAND_CONFIG` 0xC0: that command is
|
||||
/// observer-only (its backend compiles only under `OFFBAND_OBSERVER` and the
|
||||
/// client gates it on `WIFI_OBSERVER_SUPPORT`), so extending it would make the
|
||||
/// feature unreachable on headless trackers, the exact boards it exists for.
|
||||
///
|
||||
/// Companion-API only, NEVER on the mesh: it changes only this node's own
|
||||
/// button handling and buzzer, touching no forwarding, relay, or advert path.
|
||||
///
|
||||
/// scope GET `[0xC5][0x01]` -> `[0xC5][0x01][scope]`
|
||||
/// SET `[0xC5][0x02][scope]` -> echo on success
|
||||
/// matrix GET `[0xC5][0x03]`
|
||||
/// -> `[0xC5][0x03][supportedActions][n]([sequence][action]) * n`
|
||||
/// SET `[0xC5][0x04][sequence][action]` -> echo on success
|
||||
/// error `[0xC5][0x7F][reason]`
|
||||
/// Whether the `0xC5` get/set command exists in shipped firmware.
|
||||
///
|
||||
/// TRUE: the client renders the real controls and speaks the canonical `0xC5`
|
||||
/// contract published in the firmware registry.
|
||||
///
|
||||
/// Owner instruction 2026-08-01: the app is where this gets configured. The
|
||||
/// client is not the thing holding the feature back, so the controls are live
|
||||
/// and code to the contract as written.
|
||||
///
|
||||
/// Until the firmware handler answers, a read shows its loading row and a write
|
||||
/// is not confirmed, because state only ever follows the device's reply and is
|
||||
/// never assumed from the request. Nothing is faked and nothing is written
|
||||
/// locally that the radio has not acknowledged.
|
||||
const bool deviceUiCommandLanded = true;
|
||||
|
||||
const int cmdOffbandDeviceUi = 0xC5;
|
||||
const int respCodeOffbandDeviceUi = 0xC5;
|
||||
|
||||
const int offbandUiScopeGet = 0x01;
|
||||
const int offbandUiScopeSet = 0x02;
|
||||
const int offbandUiMatrixGet = 0x03;
|
||||
const int offbandUiMatrixSet = 0x04;
|
||||
|
||||
/// Shared error sub-code. The reply carries a reason byte rather than the
|
||||
/// generic `[RESP_CODE_ERR][ERR_CODE_ILLEGAL_ARG]` pair, because both epics
|
||||
/// require the user to be shown *why* an assignment was refused: "this board
|
||||
/// has no buzzer" and "unknown action" are different answers and a single
|
||||
/// illegal-arg code cannot express them.
|
||||
const int offbandUiErr = 0x7F;
|
||||
|
||||
/// Why the device refused a write. Unknown values are preserved and surfaced
|
||||
/// verbatim rather than collapsed into a generic failure, so firmware can add
|
||||
/// reasons without the client hiding them.
|
||||
enum ButtonConfigError {
|
||||
unsupportedAction(0x01, 'This radio does not support that action'),
|
||||
unknownSequence(0x02, 'This radio does not recognise that button sequence'),
|
||||
noBuzzer(0x03, 'This radio has no buzzer'),
|
||||
noGps(0x04, 'This radio has no GPS'),
|
||||
malformed(0x05, 'The radio could not read the request');
|
||||
|
||||
const ButtonConfigError(this.code, this.message);
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
static String describe(int code) {
|
||||
for (final e in ButtonConfigError.values) {
|
||||
if (e.code == code) return e.message;
|
||||
}
|
||||
// Never swallow an unrecognised reason: show the raw code so a newer
|
||||
// firmware's error is still actionable rather than invisible.
|
||||
return 'The radio refused the change (reason 0x'
|
||||
'${code.toRadixString(16).padLeft(2, '0')})';
|
||||
}
|
||||
}
|
||||
|
||||
/// A button press sequence. Values are the wire encoding.
|
||||
///
|
||||
/// Long-press sequences are deliberately absent: firmware reserves long-press
|
||||
/// under 8 seconds for CLI rescue and 8 seconds or more for power off, and
|
||||
/// making either reassignable would let a user lock themselves out of a
|
||||
/// screenless device with no way back.
|
||||
enum ButtonSequence {
|
||||
single(0x00, 'Single press'),
|
||||
double(0x01, 'Double press'),
|
||||
triple(0x02, 'Triple press'),
|
||||
quadruple(0x03, 'Quadruple press');
|
||||
|
||||
const ButtonSequence(this.code, this.label);
|
||||
final int code;
|
||||
final String label;
|
||||
|
||||
static ButtonSequence? fromCode(int code) {
|
||||
for (final s in ButtonSequence.values) {
|
||||
if (s.code == code) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// An action assignable to a sequence. The device advertises which of these it
|
||||
/// actually supports; the client never offers one the radio did not claim.
|
||||
enum ButtonAction {
|
||||
none(0x00, 'Unassigned'),
|
||||
sendAdvert(0x01, 'Send advert'),
|
||||
toggleGps(0x02, 'Toggle GPS'),
|
||||
cycleNotifyScope(0x03, 'Cycle notification scope'),
|
||||
batteryBeep(0x04, 'Battery / status beep');
|
||||
|
||||
const ButtonAction(this.code, this.label);
|
||||
final int code;
|
||||
final String label;
|
||||
|
||||
/// Bit position in the device's supported-actions mask.
|
||||
int get mask => code == 0 ? 0 : 1 << (code - 1);
|
||||
|
||||
static ButtonAction? fromCode(int code) {
|
||||
for (final a in ButtonAction.values) {
|
||||
if (a.code == code) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Device notification scope (#475). Governs whether the DEVICE buzzer sounds.
|
||||
///
|
||||
/// Distinct from the app's per-channel [ChannelNotifyMode], which governs
|
||||
/// whether the PHONE notifies. Same vocabulary deliberately; they are
|
||||
/// complementary and must never be merged or made to shadow each other.
|
||||
enum DeviceNotifyScope {
|
||||
all(0x00, 'All', 'Beep for any received message'),
|
||||
self(0x01, 'Self', 'Beep only for direct messages and @[mentions]'),
|
||||
none(0x02, 'None', 'Never beep');
|
||||
|
||||
const DeviceNotifyScope(this.code, this.label, this.description);
|
||||
final int code;
|
||||
final String label;
|
||||
final String description;
|
||||
|
||||
static DeviceNotifyScope? fromCode(int code) {
|
||||
for (final s in DeviceNotifyScope.values) {
|
||||
if (s.code == code) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's current button configuration, as reported by a GET.
|
||||
class ButtonMatrix {
|
||||
/// Sequence to action, only for sequences the device reported.
|
||||
final Map<ButtonSequence, ButtonAction> assignments;
|
||||
|
||||
/// Bitmask of actions this specific radio can perform. Derived at runtime by
|
||||
/// firmware from compiled-in hardware (no buzzer, no GPS), so it is a
|
||||
/// per-unit answer and must never be inferred from model or version.
|
||||
final int supportedActions;
|
||||
|
||||
const ButtonMatrix({
|
||||
required this.assignments,
|
||||
required this.supportedActions,
|
||||
});
|
||||
|
||||
bool supports(ButtonAction action) =>
|
||||
action == ButtonAction.none || (supportedActions & action.mask) != 0;
|
||||
|
||||
/// Actions this radio will accept, always including "Unassigned" so any
|
||||
/// sequence can be cleared.
|
||||
List<ButtonAction> get availableActions =>
|
||||
ButtonAction.values.where(supports).toList();
|
||||
|
||||
ButtonMatrix withAssignment(ButtonSequence seq, ButtonAction action) =>
|
||||
ButtonMatrix(
|
||||
assignments: {...assignments, seq: action},
|
||||
supportedActions: supportedActions,
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List buildButtonMatrixGetFrame() =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiMatrixGet]);
|
||||
|
||||
Uint8List buildButtonMatrixSetFrame(
|
||||
ButtonSequence sequence,
|
||||
ButtonAction action,
|
||||
) => Uint8List.fromList([
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiMatrixSet,
|
||||
sequence.code,
|
||||
action.code,
|
||||
]);
|
||||
|
||||
Uint8List buildNotifyScopeGetFrame() =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiScopeGet]);
|
||||
|
||||
Uint8List buildNotifyScopeSetFrame(DeviceNotifyScope scope) =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiScopeSet, scope.code]);
|
||||
|
||||
/// Outcome of a `0xC5` / `0xC6` reply. Exactly one of the payload fields is
|
||||
/// non-null; [errorMessage] is set when the device refused.
|
||||
class OffbandUiReply {
|
||||
final int command;
|
||||
final int sub;
|
||||
final ButtonMatrix? matrix;
|
||||
final ButtonSequence? setSequence;
|
||||
final ButtonAction? setAction;
|
||||
final DeviceNotifyScope? scope;
|
||||
final String? errorMessage;
|
||||
|
||||
const OffbandUiReply({
|
||||
required this.command,
|
||||
required this.sub,
|
||||
this.matrix,
|
||||
this.setSequence,
|
||||
this.setAction,
|
||||
this.scope,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
bool get isError => errorMessage != null;
|
||||
}
|
||||
|
||||
/// Parse a `0xC5` button-matrix reply. Null if this is not one.
|
||||
///
|
||||
/// Every length check is explicit: a truncated or hostile frame yields null or
|
||||
/// an error reply, never an out-of-range index.
|
||||
OffbandUiReply? parseButtonMatrixReply(Uint8List frame) {
|
||||
if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null;
|
||||
final sub = frame[1];
|
||||
|
||||
if (sub == offbandUiErr) {
|
||||
return OffbandUiReply(
|
||||
command: respCodeOffbandDeviceUi,
|
||||
sub: sub,
|
||||
errorMessage: ButtonConfigError.describe(
|
||||
frame.length >= 3 ? frame[2] : 0x00,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (sub == offbandUiMatrixGet) {
|
||||
if (frame.length < 4) return null;
|
||||
final supported = frame[2];
|
||||
final count = frame[3];
|
||||
final assignments = <ButtonSequence, ButtonAction>{};
|
||||
for (var i = 0; i < count; i++) {
|
||||
final base = 4 + (i * 2);
|
||||
// Stop at the real end of the frame rather than trusting the count byte.
|
||||
if (base + 1 >= frame.length) break;
|
||||
final seq = ButtonSequence.fromCode(frame[base]);
|
||||
final action = ButtonAction.fromCode(frame[base + 1]);
|
||||
// An unknown sequence or action from newer firmware is skipped rather
|
||||
// than guessed at; the rows the client does understand still render.
|
||||
if (seq != null && action != null) assignments[seq] = action;
|
||||
}
|
||||
return OffbandUiReply(
|
||||
command: respCodeOffbandDeviceUi,
|
||||
sub: sub,
|
||||
matrix: ButtonMatrix(
|
||||
assignments: assignments,
|
||||
supportedActions: supported,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (sub == offbandUiMatrixSet) {
|
||||
if (frame.length < 4) return null;
|
||||
return OffbandUiReply(
|
||||
command: respCodeOffbandDeviceUi,
|
||||
sub: sub,
|
||||
setSequence: ButtonSequence.fromCode(frame[2]),
|
||||
setAction: ButtonAction.fromCode(frame[3]),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Parse a `0xC6` notification-scope reply. Null if this is not one.
|
||||
OffbandUiReply? parseNotifyScopeReply(Uint8List frame) {
|
||||
if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null;
|
||||
final sub = frame[1];
|
||||
|
||||
// The shared 0x7F error sub-code is owned by parseButtonMatrixReply, which
|
||||
// the dispatcher tries first, so it is deliberately not handled twice here.
|
||||
if (sub != offbandUiScopeGet && sub != offbandUiScopeSet) return null;
|
||||
if (frame.length < 3) return null;
|
||||
final scope = DeviceNotifyScope.fromCode(frame[2]);
|
||||
if (scope == null) {
|
||||
// A scope the client does not know is an error the user can see, not a
|
||||
// silent fallback to some default that would misreport the device.
|
||||
return OffbandUiReply(
|
||||
command: respCodeOffbandDeviceUi,
|
||||
sub: sub,
|
||||
errorMessage:
|
||||
'The radio reported an unknown notification scope '
|
||||
'(0x${frame[2].toRadixString(16).padLeft(2, '0')})',
|
||||
);
|
||||
}
|
||||
return OffbandUiReply(
|
||||
command: respCodeOffbandDeviceUi,
|
||||
sub: sub,
|
||||
scope: scope,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,247 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connector/meshcore_connector.dart';
|
||||
import '../../connector/offband_device_ui.dart';
|
||||
|
||||
/// Settings for a headless device's physical UI: the button-action matrix
|
||||
/// (#474) and the device notification scope (#475).
|
||||
///
|
||||
/// The whole pane is capability-gated. A radio that does not advertise the bit
|
||||
/// gets no screen and never sees the command, so stock and older firmware
|
||||
/// degrade silently with nothing shown and no error.
|
||||
///
|
||||
/// English-only for now, matching the serial-capture pane (#430); localization
|
||||
/// is a follow-up rather than a blocker on shipping the capability.
|
||||
class DeviceUiView extends StatefulWidget {
|
||||
const DeviceUiView({super.key});
|
||||
|
||||
@override
|
||||
State<DeviceUiView> createState() => _DeviceUiViewState();
|
||||
}
|
||||
|
||||
class _DeviceUiViewState extends State<DeviceUiView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Re-read on open as well as on device-info: the user may have changed the
|
||||
// scope by triple-pressing the device since the last refresh.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final c = context.read<MeshCoreConnector>();
|
||||
c.requestButtonMatrix();
|
||||
c.requestNotifyScope();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final showButtons = connector.supportsButtonMatrix;
|
||||
final showScope = connector.supportsNotifyScope;
|
||||
// A radio advertising neither bit has no button and no buzzer to
|
||||
// configure, so it gets nothing. The tile that leads here is gated the
|
||||
// same way, so this is a belt-and-braces guard rather than a path a
|
||||
// user can reach. (#474 negative test)
|
||||
if (!showButtons && !showScope) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// The radio advertises the capability but shipped firmware has no
|
||||
// get/set command yet, so it can be detected and not queried. Say that
|
||||
// plainly instead of spinning forever on a read that never returns.
|
||||
if (!connector.supportsDeviceUiCommand) {
|
||||
return ListView(
|
||||
children: [
|
||||
const _SectionHeader('Supported by this radio'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Text(
|
||||
'This radio reports that it supports '
|
||||
'${showScope && showButtons
|
||||
? 'notification scope and button actions'
|
||||
: showScope
|
||||
? 'the notification scope'
|
||||
: 'button actions'}.\n\n'
|
||||
'Reading and changing it from the app needs a firmware '
|
||||
'update that is still in progress, so there is nothing to '
|
||||
'set here yet.',
|
||||
),
|
||||
),
|
||||
if (showScope)
|
||||
const ListTile(
|
||||
leading: Icon(Icons.touch_app_outlined),
|
||||
title: Text('Change it on the device'),
|
||||
subtitle: Text(
|
||||
'Triple-press the button to cycle All, Self, then None. '
|
||||
'The default is All.',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
children: [
|
||||
if (connector.deviceUiError != null)
|
||||
_ErrorBanner(
|
||||
message: connector.deviceUiError!,
|
||||
onDismiss: connector.clearDeviceUiError,
|
||||
),
|
||||
if (showScope) ...[
|
||||
const _SectionHeader('Device notification scope'),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
'Controls whether this radio\'s buzzer sounds. This is '
|
||||
'separate from per-channel notifications, which control '
|
||||
'whether this app notifies you.',
|
||||
),
|
||||
),
|
||||
..._scopeTiles(connector),
|
||||
const Divider(height: 24),
|
||||
],
|
||||
if (showButtons) ...[
|
||||
const _SectionHeader('Button actions'),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
'Assign what each button press does. Long press is reserved '
|
||||
'by the firmware for CLI rescue and power off, so it cannot '
|
||||
'be reassigned.',
|
||||
),
|
||||
),
|
||||
..._buttonTiles(connector),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _scopeTiles(MeshCoreConnector connector) {
|
||||
final current = connector.deviceNotifyScope;
|
||||
if (current == null) {
|
||||
return [
|
||||
const ListTile(
|
||||
leading: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
title: Text('Reading scope from the radio'),
|
||||
),
|
||||
];
|
||||
}
|
||||
// Plain ListTiles rather than RadioListTile: the Radio group API is
|
||||
// deprecated in this Flutter version and the replacement needs a
|
||||
// RadioGroup ancestor, which buys nothing for three mutually exclusive
|
||||
// rows.
|
||||
return DeviceNotifyScope.values
|
||||
.map(
|
||||
(scope) => ListTile(
|
||||
leading: Icon(
|
||||
scope == current
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: scope == current
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(scope.label),
|
||||
subtitle: Text(scope.description),
|
||||
selected: scope == current,
|
||||
onTap: () => connector.setNotifyScope(scope),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<Widget> _buttonTiles(MeshCoreConnector connector) {
|
||||
final matrix = connector.buttonMatrix;
|
||||
if (matrix == null) {
|
||||
return [
|
||||
const ListTile(
|
||||
leading: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
title: Text('Reading button configuration from the radio'),
|
||||
),
|
||||
];
|
||||
}
|
||||
// Only actions the radio said it can perform are offered, so a board with
|
||||
// no buzzer or no GPS never shows a choice it would reject. (#474)
|
||||
final actions = matrix.availableActions;
|
||||
return ButtonSequence.values.map((seq) {
|
||||
final assigned = matrix.assignments[seq] ?? ButtonAction.none;
|
||||
return ListTile(
|
||||
title: Text(seq.label),
|
||||
subtitle: Text(assigned.label),
|
||||
trailing: DropdownButton<ButtonAction>(
|
||||
value: actions.contains(assigned) ? assigned : ButtonAction.none,
|
||||
onChanged: (value) {
|
||||
if (value != null) connector.setButtonAction(seq, value);
|
||||
},
|
||||
items: actions
|
||||
.map(
|
||||
(a) => DropdownMenuItem<ButtonAction>(
|
||||
value: a,
|
||||
child: Text(a.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader(this.title);
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
);
|
||||
}
|
||||
|
||||
/// Persistent error banner. Stays until dismissed rather than flashing, per the
|
||||
/// error-visibility rule: an error the user cannot finish reading is not a
|
||||
/// surfaced error.
|
||||
class _ErrorBanner extends StatelessWidget {
|
||||
const _ErrorBanner({required this.message, required this.onDismiss});
|
||||
final String message;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: scheme.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(color: scheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: scheme.onErrorContainer),
|
||||
onPressed: onDismiss,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
Offband Meshcore 1.4.0
|
||||
|
||||
A follow-up to the 1.3.0 launch. New button and buzzer settings for headless devices, a one-tap Send Again on channel messages, and more capability detail in Device Info. Fixes: the block list is scoped per radio again, per-radio caches are cleared on reconnect so nothing lingers from another radio, translation-model downloads retry on transient errors, and routed replies use the correct path width.
|
||||
@ -0,0 +1,34 @@
|
||||
## Offband Meshcore 1.4.0
|
||||
|
||||
A focused follow-up to the 1.3.0 production launch: new settings for headless
|
||||
devices, a resend action on channel messages, and a set of per-radio
|
||||
data-correctness fixes.
|
||||
|
||||
### New
|
||||
|
||||
- **Button and buzzer settings for headless devices.** On radios that report
|
||||
support, you can now configure the button and buzzer right from settings,
|
||||
capability-gated so the controls only show where they apply (#483).
|
||||
- **"Send Again" on channel messages.** Resend a channel message in one tap, with
|
||||
the direct-message resend action relabeled to match (#513).
|
||||
- **More capability detail in Device Info.** The second offband capabilities byte is
|
||||
now shown, so more of your radio's capability bits are visible at a glance (#480).
|
||||
- **Case-insensitive self-mentions.** An `@[name]` mention of yourself now matches
|
||||
regardless of case (#486).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Block list is per-radio again.** An accidental global block list was dropped, so
|
||||
blocking someone on one radio no longer carries over to another (#505, #506).
|
||||
- **Clean slate on reconnect.** Per-radio in-memory caches are cleared on reconnect,
|
||||
so data from a previously connected radio can't linger into the next session
|
||||
(#472).
|
||||
- **Sturdier translation-model download.** Transient server errors now retry with
|
||||
backoff instead of failing the download outright (#425).
|
||||
- **Mention and routing correctness.** `@[name]` mentions are handled as a verbatim
|
||||
wire token, and routed replies are sent at the contact's path-hash width instead
|
||||
of width 1 (#497, #495).
|
||||
|
||||
Offband is free and open source and always will be. If you would like to help keep
|
||||
it going, there is a donation page at https://offband.org/donate. Thank you for being
|
||||
part of it.
|
||||
@ -0,0 +1,68 @@
|
||||
// #472: switching radios must not show the previous radio's history.
|
||||
//
|
||||
// The per-radio in-memory caches (_channelMessages, _conversations,
|
||||
// _loadedConversationKeys) are keyed by channel index / contact key, not by
|
||||
// radio. _resetConnectionHandshakeState() runs at the start of every
|
||||
// connection, so it must clear them; otherwise a new radio whose store is empty
|
||||
// for a channel cannot overwrite the stale entry and keeps rendering the prior
|
||||
// radio's history (including its outgoing messages). On-disk stores are already
|
||||
// per-radio (device+PSK), so this is purely the runtime cache.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/models/channel_message.dart';
|
||||
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
PrefsManager.reset();
|
||||
await PrefsManager.initialize();
|
||||
});
|
||||
|
||||
test(
|
||||
'connection reset clears the previous radio in-memory caches (#472)',
|
||||
() {
|
||||
final connector = MeshCoreConnector();
|
||||
|
||||
// Seed the caches as if a prior radio's history had loaded.
|
||||
connector.channelMessagesForTest[0] = [
|
||||
ChannelMessage(
|
||||
senderName: 'PrevRadio',
|
||||
text: 'history from the other radio',
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(1000),
|
||||
isOutgoing: true,
|
||||
status: ChannelMessageStatus.sent,
|
||||
),
|
||||
];
|
||||
connector.conversationsForTest['deadbeef00'] = [];
|
||||
connector.loadedConversationKeysForTest.add('deadbeef00');
|
||||
|
||||
expect(connector.channelMessagesForTest, isNotEmpty);
|
||||
expect(connector.conversationsForTest, isNotEmpty);
|
||||
expect(connector.loadedConversationKeysForTest, isNotEmpty);
|
||||
|
||||
// A new connection begins.
|
||||
connector.resetConnectionHandshakeStateForTest();
|
||||
|
||||
expect(
|
||||
connector.channelMessagesForTest,
|
||||
isEmpty,
|
||||
reason: 'channel history cache must be dropped on reconnect',
|
||||
);
|
||||
expect(
|
||||
connector.conversationsForTest,
|
||||
isEmpty,
|
||||
reason: 'DM conversation cache must be dropped on reconnect',
|
||||
);
|
||||
expect(
|
||||
connector.loadedConversationKeysForTest,
|
||||
isEmpty,
|
||||
reason: 'loaded-conversation markers must be dropped so DMs reload',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
|
||||
/// Cross-repo contract tests for the `@[name]` self-mention rule (client #486,
|
||||
/// firmware #510). Firmware implements the identical rule, so any change that
|
||||
/// breaks one of these breaks agreement with the device and must ship in an
|
||||
/// aligned build pair.
|
||||
void main() {
|
||||
group('@[name] matching', () {
|
||||
test('matches the canonical bracketed form', () {
|
||||
expect(
|
||||
MeshCoreConnector.mentionsName('hey @[Ben] you there', 'Ben'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('bare @name does not match', () {
|
||||
expect(
|
||||
MeshCoreConnector.mentionsName('hey @Ben you there', 'Ben'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches as a plain substring, not anchored or word-bounded', () {
|
||||
// Deliberate: the rule is `contains`, and firmware must agree.
|
||||
expect(MeshCoreConnector.mentionsName('x@[Ben]y', 'Ben'), isTrue);
|
||||
});
|
||||
|
||||
test('the name is compared VERBATIM, whitespace included (#497)', () {
|
||||
// Owner ruling 2026-08-01: @[name] is a wire token carrying the advert
|
||||
// name byte-for-byte. A name with surrounding spaces is a DIFFERENT
|
||||
// name, and trimming it here is what stopped mentions beeping.
|
||||
expect(MeshCoreConnector.mentionsName('yo @[Ben]', ' Ben '), isFalse);
|
||||
expect(
|
||||
MeshCoreConnector.mentionsName('yo @[ Ben ]', ' Ben '),
|
||||
isTrue,
|
||||
);
|
||||
expect(MeshCoreConnector.mentionsName('yo @[Ben ]', 'Ben '), isTrue);
|
||||
expect(MeshCoreConnector.mentionsName('yo @[Ben]', 'Ben '), isFalse);
|
||||
});
|
||||
|
||||
test('empty or whitespace-only self-name matches nothing', () {
|
||||
expect(MeshCoreConnector.mentionsName('yo @[Ben]', ''), isFalse);
|
||||
expect(MeshCoreConnector.mentionsName('yo @[Ben]', ' '), isFalse);
|
||||
expect(MeshCoreConnector.mentionsName('yo @[Ben]', null), isFalse);
|
||||
});
|
||||
|
||||
test('a name that is not mentioned does not match', () {
|
||||
expect(MeshCoreConnector.mentionsName('yo @[Alice]', 'Ben'), isFalse);
|
||||
expect(
|
||||
MeshCoreConnector.mentionsName('no mentions here', 'Ben'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ASCII-only case folding (owner decision 2026-07-31, #486)', () {
|
||||
test('ASCII names match case-insensitively in both directions', () {
|
||||
expect(MeshCoreConnector.mentionsName('yo @[BEN]', 'ben'), isTrue);
|
||||
expect(MeshCoreConnector.mentionsName('yo @[ben]', 'BEN'), isTrue);
|
||||
expect(MeshCoreConnector.mentionsName('yo @[BeN]', 'bEn'), isTrue);
|
||||
});
|
||||
|
||||
test('non-ASCII names compare case-sensitively', () {
|
||||
// The deliberate divergence from String.toLowerCase(): firmware folds
|
||||
// bytes and cannot do Unicode case mapping, so the client must not
|
||||
// either, or the two sides disagree on the same message.
|
||||
expect(MeshCoreConnector.mentionsName('yo @[Érik]', 'Érik'), isTrue);
|
||||
expect(MeshCoreConnector.mentionsName('yo @[érik]', 'Érik'), isFalse);
|
||||
expect(MeshCoreConnector.mentionsName('yo @[ÉRIK]', 'érik'), isFalse);
|
||||
});
|
||||
|
||||
test('ASCII folding still applies around non-ASCII characters', () {
|
||||
// "Ben-Érik": the ASCII half folds, the accented character does not.
|
||||
expect(
|
||||
MeshCoreConnector.mentionsName('yo @[BEN-Érik]', 'ben-Érik'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
MeshCoreConnector.mentionsName('yo @[BEN-érik]', 'ben-Érik'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-letter ASCII is untouched by the fold', () {
|
||||
expect(MeshCoreConnector.mentionsName('yo @[Node_7]', 'node_7'), isTrue);
|
||||
expect(MeshCoreConnector.mentionsName('yo @[N0DE-7]', 'n0de-7'), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'names containing spaces work, which bare @name could not delimit',
|
||||
() {
|
||||
expect(
|
||||
MeshCoreConnector.mentionsName('yo @[Base Station]', 'base station'),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('foldAscii is the single equivalence rule (Gemini review, #497)', () {
|
||||
test('ASCII case collapses, non-ASCII case does not', () {
|
||||
// Dedup used String.toLowerCase(), which collapses these; matching uses
|
||||
// foldAscii, which keeps them distinct. One of two real contacts would
|
||||
// have silently vanished from the mention list while staying matchable.
|
||||
expect('É'.toLowerCase() == 'é'.toLowerCase(), isTrue);
|
||||
expect(
|
||||
MeshCoreConnector.foldAscii('É') == MeshCoreConnector.foldAscii('é'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
MeshCoreConnector.foldAscii('Ben') ==
|
||||
MeshCoreConnector.foldAscii('ben'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('folding leaves non-ASCII bytes untouched', () {
|
||||
expect(MeshCoreConnector.foldAscii('Érik'), equals('Érik'));
|
||||
expect(MeshCoreConnector.foldAscii('BEN'), equals('ben'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,213 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/connector/offband_device_ui.dart';
|
||||
|
||||
void main() {
|
||||
group('capability gating on caps byte 2 (#474/#475)', () {
|
||||
test('requires the explicit bit', () {
|
||||
expect(firmwareSupportsButtonMatrix(offbandCap2ButtonMatrix), isTrue);
|
||||
expect(firmwareSupportsNotifyScope(offbandCap2NotifyScope), isTrue);
|
||||
});
|
||||
|
||||
test('false when the other feature bit is set but not this one', () {
|
||||
expect(firmwareSupportsButtonMatrix(offbandCap2NotifyScope), isFalse);
|
||||
expect(firmwareSupportsNotifyScope(offbandCap2ButtonMatrix), isFalse);
|
||||
});
|
||||
|
||||
test('absent byte 2 is unsupported, not an error', () {
|
||||
expect(firmwareSupportsButtonMatrix(null), isFalse);
|
||||
expect(firmwareSupportsNotifyScope(null), isFalse);
|
||||
expect(firmwareSupportsButtonMatrix(0x00), isFalse);
|
||||
});
|
||||
|
||||
test('the two bits do not collide', () {
|
||||
expect(offbandCap2ButtonMatrix & offbandCap2NotifyScope, equals(0));
|
||||
});
|
||||
});
|
||||
|
||||
group('request frames', () {
|
||||
test('command bytes do not collide with the shipped fork commands', () {
|
||||
final used = {
|
||||
cmdOffbandGps,
|
||||
cmdOffbandBlock,
|
||||
cmdOffbandFemLna,
|
||||
cmdOffbandCaplog,
|
||||
};
|
||||
expect(used.contains(cmdOffbandDeviceUi), isFalse);
|
||||
});
|
||||
|
||||
test('matrix get and set encode as documented', () {
|
||||
expect(buildButtonMatrixGetFrame(), equals([0xC5, 0x03]));
|
||||
expect(
|
||||
buildButtonMatrixSetFrame(
|
||||
ButtonSequence.double,
|
||||
ButtonAction.sendAdvert,
|
||||
),
|
||||
equals([0xC5, 0x04, 0x01, 0x01]),
|
||||
);
|
||||
});
|
||||
|
||||
test('scope get and set encode as documented', () {
|
||||
expect(buildNotifyScopeGetFrame(), equals([0xC5, 0x01]));
|
||||
expect(
|
||||
buildNotifyScopeSetFrame(DeviceNotifyScope.self),
|
||||
equals([0xC5, 0x02, 0x01]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('button matrix GET parse', () {
|
||||
test('reads the supported mask and the assignment rows', () {
|
||||
// supported = advert | gps, two rows: single->none, double->advert
|
||||
final mask = ButtonAction.sendAdvert.mask | ButtonAction.toggleGps.mask;
|
||||
final reply = parseButtonMatrixReply(
|
||||
Uint8List.fromList([0xC5, 0x03, mask, 2, 0x00, 0x00, 0x01, 0x01]),
|
||||
);
|
||||
expect(reply, isNotNull);
|
||||
expect(reply!.isError, isFalse);
|
||||
final m = reply.matrix!;
|
||||
expect(m.assignments[ButtonSequence.single], ButtonAction.none);
|
||||
expect(m.assignments[ButtonSequence.double], ButtonAction.sendAdvert);
|
||||
expect(m.supports(ButtonAction.sendAdvert), isTrue);
|
||||
expect(m.supports(ButtonAction.toggleGps), isTrue);
|
||||
expect(m.supports(ButtonAction.batteryBeep), isFalse);
|
||||
});
|
||||
|
||||
test('unassigned is always offered so a sequence can be cleared', () {
|
||||
final m = ButtonMatrix(assignments: const {}, supportedActions: 0);
|
||||
expect(m.supports(ButtonAction.none), isTrue);
|
||||
expect(m.availableActions, contains(ButtonAction.none));
|
||||
});
|
||||
|
||||
test('a lying count byte cannot read past the frame', () {
|
||||
// count says 5 rows, only one is present.
|
||||
final reply = parseButtonMatrixReply(
|
||||
Uint8List.fromList([0xC5, 0x03, 0xFF, 5, 0x00, 0x01]),
|
||||
);
|
||||
expect(reply, isNotNull);
|
||||
expect(reply!.matrix!.assignments.length, equals(1));
|
||||
});
|
||||
|
||||
test('unknown sequence or action codes are skipped, not guessed', () {
|
||||
final reply = parseButtonMatrixReply(
|
||||
Uint8List.fromList([0xC5, 0x03, 0xFF, 2, 0x7E, 0x01, 0x01, 0x7E]),
|
||||
);
|
||||
expect(reply!.matrix!.assignments, isEmpty);
|
||||
});
|
||||
|
||||
test('truncated GET yields null rather than throwing', () {
|
||||
for (final f in [
|
||||
<int>[0xC5],
|
||||
<int>[0xC5, 0x03],
|
||||
<int>[0xC5, 0x03, 0x00],
|
||||
]) {
|
||||
expect(parseButtonMatrixReply(Uint8List.fromList(f)), isNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('a foreign command byte is not claimed', () {
|
||||
expect(
|
||||
parseButtonMatrixReply(Uint8List.fromList([0xC3, 0x01, 0x00])),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
parseNotifyScopeReply(Uint8List.fromList([0xC3, 0x01, 0x00])),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('error replies carry the device reason', () {
|
||||
test('known reason codes map to their message', () {
|
||||
final reply = parseButtonMatrixReply(
|
||||
Uint8List.fromList([0xC5, 0x7F, 0x03]),
|
||||
);
|
||||
expect(reply!.isError, isTrue);
|
||||
// Firmware-owned reason codes (FuchsiaCreek, 2026-08-01): 1 unsupported
|
||||
// action, 2 unknown sequence, 3 no buzzer, 4 no GPS, 5 malformed.
|
||||
expect(reply.errorMessage, ButtonConfigError.noBuzzer.message);
|
||||
expect(ButtonConfigError.noBuzzer.code, equals(0x03));
|
||||
expect(ButtonConfigError.unknownSequence.code, equals(0x02));
|
||||
expect(ButtonConfigError.noGps.code, equals(0x04));
|
||||
expect(ButtonConfigError.malformed.code, equals(0x05));
|
||||
});
|
||||
|
||||
test(
|
||||
'an unknown reason is surfaced with its raw code, never swallowed',
|
||||
() {
|
||||
final reply = parseButtonMatrixReply(
|
||||
Uint8List.fromList([0xC5, 0x7F, 0x5A]),
|
||||
);
|
||||
expect(reply!.isError, isTrue);
|
||||
expect(reply.errorMessage, contains('0x5a'));
|
||||
},
|
||||
);
|
||||
|
||||
test('the shared error sub-code is owned by one parser, not both', () {
|
||||
// Both surfaces ride 0xC5, so 0x7F must be handled exactly once or an
|
||||
// error would be processed twice by the dispatcher.
|
||||
final frame = Uint8List.fromList([0xC5, 0x7F, 0x04]);
|
||||
expect(parseButtonMatrixReply(frame)!.isError, isTrue);
|
||||
expect(parseNotifyScopeReply(frame), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('notification scope parse', () {
|
||||
test('reads each scope', () {
|
||||
for (final s in DeviceNotifyScope.values) {
|
||||
final reply = parseNotifyScopeReply(
|
||||
Uint8List.fromList([0xC5, 0x01, s.code]),
|
||||
);
|
||||
expect(reply!.scope, equals(s));
|
||||
}
|
||||
});
|
||||
|
||||
test('an unknown scope is an error, not a silent default', () {
|
||||
final reply = parseNotifyScopeReply(
|
||||
Uint8List.fromList([0xC5, 0x01, 0x40]),
|
||||
);
|
||||
expect(reply!.isError, isTrue);
|
||||
expect(reply.scope, isNull);
|
||||
expect(reply.errorMessage, contains('0x40'));
|
||||
});
|
||||
|
||||
test('truncated scope reply yields null', () {
|
||||
expect(parseNotifyScopeReply(Uint8List.fromList([0xC5, 0x01])), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('SET echo', () {
|
||||
test('confirmed assignment folds into the held matrix', () {
|
||||
final reply = parseButtonMatrixReply(
|
||||
Uint8List.fromList([0xC5, 0x04, 0x02, 0x03]),
|
||||
);
|
||||
expect(reply!.setSequence, ButtonSequence.triple);
|
||||
expect(reply.setAction, ButtonAction.cycleNotifyScope);
|
||||
|
||||
final before = ButtonMatrix(
|
||||
assignments: const {ButtonSequence.triple: ButtonAction.none},
|
||||
supportedActions: 0xFF,
|
||||
);
|
||||
final after = before.withAssignment(reply.setSequence!, reply.setAction!);
|
||||
expect(
|
||||
after.assignments[ButtonSequence.triple],
|
||||
ButtonAction.cycleNotifyScope,
|
||||
);
|
||||
expect(after.supportedActions, equals(0xFF));
|
||||
});
|
||||
});
|
||||
|
||||
group('long press is not assignable', () {
|
||||
test('no sequence models a long press', () {
|
||||
// Firmware reserves long press for CLI rescue and power off. Exposing it
|
||||
// would let a user lock themselves out of a screenless device.
|
||||
expect(ButtonSequence.values.length, equals(4));
|
||||
expect(
|
||||
ButtonSequence.values.map((s) => s.label).join(' ').toLowerCase(),
|
||||
isNot(contains('long')),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/models/path_selection.dart';
|
||||
|
||||
Contact _contact({
|
||||
required int pathLength,
|
||||
required int pathHashWidth,
|
||||
List<int> path = const [],
|
||||
int? pathOverride,
|
||||
List<int>? pathOverrideBytes,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: Uint8List(32),
|
||||
name: 'R',
|
||||
type: advTypeRepeater,
|
||||
pathLength: pathLength,
|
||||
pathHashWidth: pathHashWidth,
|
||||
path: Uint8List.fromList(path),
|
||||
pathOverride: pathOverride,
|
||||
pathOverrideBytes: pathOverrideBytes == null
|
||||
? null
|
||||
: Uint8List.fromList(pathOverrideBytes),
|
||||
lastSeen: DateTime.utc(2026),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
const pathLenOffset = 35; // 1 cmd + 32 pubKey + 1 type + 1 flags
|
||||
|
||||
group('resolvePathSelection width (#279)', () {
|
||||
test('device path reports true hops + the captured width', () {
|
||||
// Bandit 2026-08-01: a 6-hop 2-byte route (12 path bytes). It must NOT be
|
||||
// reported as 12 hops nor sent at width 1.
|
||||
final bytes = [
|
||||
0xC6, 0x5C, 0x64, 0x7A, 0x75, 0xC9, //
|
||||
0x73, 0x60, 0xF6, 0x9F, 0xFB, 0x97,
|
||||
];
|
||||
final r = resolvePathSelection(
|
||||
_contact(pathLength: 6, pathHashWidth: 2, path: bytes),
|
||||
);
|
||||
expect(r.useFlood, isFalse);
|
||||
expect(r.hopCount, 6);
|
||||
expect(r.hashWidth, 2);
|
||||
expect(r.pathBytes.length, 12);
|
||||
});
|
||||
|
||||
test('end to end: the device path encodes as 0x46, not 0x06', () {
|
||||
final bytes = [
|
||||
0xC6, 0x5C, 0x64, 0x7A, 0x75, 0xC9, //
|
||||
0x73, 0x60, 0xF6, 0x9F, 0xFB, 0x97,
|
||||
];
|
||||
final r = resolvePathSelection(
|
||||
_contact(pathLength: 6, pathHashWidth: 2, path: bytes),
|
||||
);
|
||||
final frame = buildUpdateContactPathFrame(
|
||||
Uint8List(32),
|
||||
Uint8List.fromList(r.pathBytes),
|
||||
r.hopCount,
|
||||
hashWidth: r.hashWidth,
|
||||
);
|
||||
expect(frame[pathLenOffset], 0x46);
|
||||
expect(pathHopCount(frame[pathLenOffset]), 6);
|
||||
expect(pathHashSizeBytes(frame[pathLenOffset]), 2);
|
||||
});
|
||||
|
||||
test('override: one 2-byte hop is 1 hop at width 2, not 2 hops', () {
|
||||
// The dialog stores pathOverride as a BYTE count (2); the selection must
|
||||
// still resolve to a single 2-byte hop.
|
||||
final r = resolvePathSelection(
|
||||
_contact(
|
||||
pathLength: 1,
|
||||
pathHashWidth: 2,
|
||||
pathOverride: 2,
|
||||
pathOverrideBytes: [0xC6, 0x5C],
|
||||
),
|
||||
);
|
||||
expect(r.hopCount, 1);
|
||||
expect(r.hashWidth, 2);
|
||||
});
|
||||
|
||||
test('legacy 1-byte net is unchanged', () {
|
||||
final r = resolvePathSelection(
|
||||
_contact(pathLength: 3, pathHashWidth: 1, path: [0xAA, 0xBB, 0xCC]),
|
||||
);
|
||||
expect(r.hopCount, 3);
|
||||
expect(r.hashWidth, 1);
|
||||
});
|
||||
|
||||
test('flood override passes through as flood', () {
|
||||
final r = resolvePathSelection(
|
||||
_contact(pathLength: 0, pathHashWidth: 2, pathOverride: -1),
|
||||
);
|
||||
expect(r.useFlood, isTrue);
|
||||
expect(r.hopCount, -1);
|
||||
});
|
||||
|
||||
test('path-history retry derives width from the contact', () {
|
||||
final retry = const PathSelection(
|
||||
pathBytes: [0xC6, 0x5C, 0xA1, 0xB2],
|
||||
hopCount: 99, // stale/ambiguous; must be recomputed
|
||||
useFlood: false,
|
||||
);
|
||||
final r = resolvePathSelection(
|
||||
_contact(
|
||||
pathLength: 2,
|
||||
pathHashWidth: 2,
|
||||
path: [0x00, 0x00, 0x00, 0x00],
|
||||
),
|
||||
selection: retry,
|
||||
);
|
||||
expect(r.pathBytes.length, 4);
|
||||
expect(r.hopCount, 2);
|
||||
expect(r.hashWidth, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:meshcore_open/services/translation_service.dart';
|
||||
|
||||
void main() {
|
||||
final uri = Uri.parse('https://example.test/model.gguf');
|
||||
http.Request get() => http.Request('GET', uri);
|
||||
Future<void> noSleep(Duration _) async {}
|
||||
bool never() => false;
|
||||
|
||||
group('isRetryableDownloadStatus', () {
|
||||
test('5xx and 429 are retryable', () {
|
||||
for (final s in [500, 502, 503, 504, 429]) {
|
||||
expect(isRetryableDownloadStatus(s), isTrue, reason: '$s');
|
||||
}
|
||||
});
|
||||
test('2xx / 3xx / terminal 4xx are not retryable', () {
|
||||
for (final s in [200, 206, 301, 400, 403, 404]) {
|
||||
expect(isRetryableDownloadStatus(s), isFalse, reason: '$s');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('translationDownloadBackoff', () {
|
||||
test('exponential 1,2,4,8,16 then capped at 30', () {
|
||||
expect(translationDownloadBackoff(1), const Duration(seconds: 1));
|
||||
expect(translationDownloadBackoff(2), const Duration(seconds: 2));
|
||||
expect(translationDownloadBackoff(3), const Duration(seconds: 4));
|
||||
expect(translationDownloadBackoff(4), const Duration(seconds: 8));
|
||||
expect(translationDownloadBackoff(5), const Duration(seconds: 16));
|
||||
expect(translationDownloadBackoff(6), const Duration(seconds: 30));
|
||||
});
|
||||
test('a larger Retry-After wins, also capped at 30', () {
|
||||
expect(
|
||||
translationDownloadBackoff(1, retryAfterSeconds: 20),
|
||||
const Duration(seconds: 20),
|
||||
);
|
||||
expect(
|
||||
translationDownloadBackoff(1, retryAfterSeconds: 999),
|
||||
const Duration(seconds: 30),
|
||||
);
|
||||
// Smaller Retry-After does not shrink the exponential floor.
|
||||
expect(
|
||||
translationDownloadBackoff(4, retryAfterSeconds: 2),
|
||||
const Duration(seconds: 8),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('sendModelDownloadWithRetry', () {
|
||||
test('retries a transient 503 then succeeds', () async {
|
||||
var calls = 0;
|
||||
final client = MockClient((req) async {
|
||||
calls++;
|
||||
return http.Response(
|
||||
calls == 1 ? 'busy' : 'ok',
|
||||
calls == 1 ? 503 : 200,
|
||||
);
|
||||
});
|
||||
final res = await sendModelDownloadWithRetry(
|
||||
client,
|
||||
get,
|
||||
sleep: noSleep,
|
||||
isCancelled: never,
|
||||
);
|
||||
expect(res.statusCode, 200);
|
||||
expect(calls, 2);
|
||||
});
|
||||
|
||||
test('gives up after maxAttempts on persistent 503', () async {
|
||||
var calls = 0;
|
||||
final client = MockClient((req) async {
|
||||
calls++;
|
||||
return http.Response('busy', 503);
|
||||
});
|
||||
final res = await sendModelDownloadWithRetry(
|
||||
client,
|
||||
get,
|
||||
sleep: noSleep,
|
||||
isCancelled: never,
|
||||
maxAttempts: 3,
|
||||
);
|
||||
expect(res.statusCode, 503);
|
||||
expect(calls, 3);
|
||||
});
|
||||
|
||||
test('does not retry a terminal 404', () async {
|
||||
var calls = 0;
|
||||
final client = MockClient((req) async {
|
||||
calls++;
|
||||
return http.Response('nope', 404);
|
||||
});
|
||||
final res = await sendModelDownloadWithRetry(
|
||||
client,
|
||||
get,
|
||||
sleep: noSleep,
|
||||
isCancelled: never,
|
||||
);
|
||||
expect(res.statusCode, 404);
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
test('retries a network exception then succeeds', () async {
|
||||
var calls = 0;
|
||||
final client = MockClient((req) async {
|
||||
calls++;
|
||||
if (calls == 1) throw http.ClientException('connection reset');
|
||||
return http.Response('ok', 200);
|
||||
});
|
||||
final res = await sendModelDownloadWithRetry(
|
||||
client,
|
||||
get,
|
||||
sleep: noSleep,
|
||||
isCancelled: never,
|
||||
);
|
||||
expect(res.statusCode, 200);
|
||||
expect(calls, 2);
|
||||
});
|
||||
|
||||
test('aborts immediately when cancelled', () async {
|
||||
final client = MockClient((req) async => http.Response('ok', 200));
|
||||
expect(
|
||||
() => sendModelDownloadWithRetry(
|
||||
client,
|
||||
get,
|
||||
sleep: noSleep,
|
||||
isCancelled: () => true,
|
||||
),
|
||||
throwsA(isA<TranslationDownloadCancelled>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue