commit
fdbb0a3942
@ -0,0 +1,160 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'offband_device_ui.dart';
|
||||
|
||||
/// The radio's display and LED over `0xC5` (#523, #944): display mode, display
|
||||
/// rotation and the status LED. Gated on caps byte 2 `0x04` INDICATORS
|
||||
/// (meshcore-firmware #542, rotation #1990).
|
||||
///
|
||||
/// display GET `[0xC5][0x05]` -> `[0xC5][0x05][mode]`
|
||||
/// SET `[0xC5][0x06][mode]` -> the same
|
||||
/// led GET `[0xC5][0x07]` -> `[0xC5][0x07][on]`
|
||||
/// SET `[0xC5][0x08][on]` -> the same
|
||||
/// rotation GET `[0xC5][0x1A]` -> `[0xC5][0x1A][deg]`
|
||||
/// SET `[0xC5][0x1B][deg]` -> the same
|
||||
///
|
||||
/// ROTATION IS `0x1A`/`0x1B`, NOT `0x18`/`0x19`. Those are CHNOTIFY, and this
|
||||
/// file held them until 2026-11-04. The collision was not theoretical:
|
||||
/// firmware `4e5a6c85`, already flashed on the owner's T1000-E, answers
|
||||
/// `0x18`/`0x19` as per-channel channel-notify and its SET validates only
|
||||
/// `mode < 4`, so a rotation SET of `0` landed as a valid notify-mode write on
|
||||
/// channel slot 0, which is Public and undeletable, and BOTH sides reported
|
||||
/// success. No error, no banner, nothing in any log.
|
||||
///
|
||||
/// The numbers here are transcribed from firmware
|
||||
/// `examples/companion_radio/OffbandConfigProtocol.h` on `firmware-base` at
|
||||
/// `28785ebf` (PR OffbandMesh/meshcore-firmware#2000). The header is the
|
||||
/// registry. Do not take a sub-code from an Agent Mail message or from this
|
||||
/// comment: read the header, because taking numbers from mail is what caused
|
||||
/// the collision.
|
||||
///
|
||||
/// Rotation is advertised by `TAG_DISPLAY_ROTATION` in the `0xC5` `0x1C`
|
||||
/// advert, NOT by caps2 `0x04` INDICATORS and NOT by probing `0x1A` and
|
||||
/// watching for a refusal. Contract §3 forbids that probing outright:
|
||||
/// "what is forbidden is discovering support by trying each feature's sub-code
|
||||
/// and watching for errors." Display mode and the LED keep the INDICATORS bit.
|
||||
///
|
||||
/// A board without a display, or without a controllable LED, refuses that
|
||||
/// half with reason 6; a display that cannot turn refuses rotation with 10.
|
||||
|
||||
const int offbandCap2Indicators = 0x04;
|
||||
|
||||
const int offbandUiDisplayGet = 0x05;
|
||||
const int offbandUiDisplaySet = 0x06;
|
||||
const int offbandUiLedGet = 0x07;
|
||||
const int offbandUiLedSet = 0x08;
|
||||
const int offbandUiRotationGet = 0x1A;
|
||||
const int offbandUiRotationSet = 0x1B;
|
||||
|
||||
/// The rotations the radio accepts, in degrees.
|
||||
const List<int> displayRotations = [0, 180];
|
||||
|
||||
bool firmwareSupportsIndicators(int? offbandCaps2) =>
|
||||
offbandCaps2 != null && (offbandCaps2 & offbandCap2Indicators) != 0;
|
||||
|
||||
/// When the radio's screen is lit. Values are the wire encoding.
|
||||
enum DisplayMode {
|
||||
auto(0, 'Auto', 'On while in use, then off after the screen timeout'),
|
||||
alwaysOn(1, 'Always on', 'Never turns off by itself'),
|
||||
alwaysOff(2, 'Always off', 'Stays dark');
|
||||
|
||||
const DisplayMode(this.code, this.label, this.description);
|
||||
final int code;
|
||||
final String label;
|
||||
final String description;
|
||||
|
||||
static DisplayMode? fromCode(int code) {
|
||||
for (final m in DisplayMode.values) {
|
||||
if (m.code == code) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List buildDisplayGetFrame() =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiDisplayGet]);
|
||||
|
||||
Uint8List buildDisplaySetFrame(DisplayMode mode) =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiDisplaySet, mode.code]);
|
||||
|
||||
Uint8List buildLedGetFrame() =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiLedGet]);
|
||||
|
||||
Uint8List buildLedSetFrame({required bool on}) =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiLedSet, on ? 1 : 0]);
|
||||
|
||||
Uint8List buildRotationGetFrame() =>
|
||||
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiRotationGet]);
|
||||
|
||||
Uint8List buildRotationSetFrame(int degrees) {
|
||||
if (!displayRotations.contains(degrees)) {
|
||||
throw ArgumentError.value(degrees, 'degrees', 'must be 0 or 180');
|
||||
}
|
||||
return Uint8List.fromList([
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiRotationSet,
|
||||
degrees,
|
||||
]);
|
||||
}
|
||||
|
||||
/// Parse a display, LED or rotation reply. Null if this is not one, or if it
|
||||
/// is truncated. The shared 0x7F error is owned by [parseButtonMatrixReply].
|
||||
IndicatorReply? parseIndicatorReply(Uint8List frame) {
|
||||
if (frame.length < 3 || frame[0] != respCodeOffbandDeviceUi) return null;
|
||||
final sub = frame[1];
|
||||
final value = frame[2];
|
||||
switch (sub) {
|
||||
case offbandUiLedGet || offbandUiLedSet:
|
||||
return IndicatorReply(sub: sub, ledOn: value != 0);
|
||||
case offbandUiDisplayGet || offbandUiDisplaySet:
|
||||
final mode = DisplayMode.fromCode(value);
|
||||
if (mode == null) {
|
||||
return IndicatorReply(
|
||||
sub: sub,
|
||||
errorMessage:
|
||||
'The radio reported an unknown display mode (${_hex(value)})',
|
||||
);
|
||||
}
|
||||
return IndicatorReply(sub: sub, displayMode: mode);
|
||||
case offbandUiRotationGet || offbandUiRotationSet:
|
||||
if (!displayRotations.contains(value)) {
|
||||
return IndicatorReply(
|
||||
sub: sub,
|
||||
errorMessage: 'The radio reported an unknown rotation ($value)',
|
||||
);
|
||||
}
|
||||
return IndicatorReply(sub: sub, rotation: value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _hex(int b) => '0x${b.toRadixString(16).padLeft(2, '0')}';
|
||||
|
||||
class IndicatorReply {
|
||||
const IndicatorReply({
|
||||
required this.sub,
|
||||
this.displayMode,
|
||||
this.ledOn,
|
||||
this.rotation,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final int sub;
|
||||
final DisplayMode? displayMode;
|
||||
final bool? ledOn;
|
||||
|
||||
/// Degrees, always one of [displayRotations].
|
||||
final int? rotation;
|
||||
final String? errorMessage;
|
||||
|
||||
DeviceUiSurface get surface => switch (sub) {
|
||||
offbandUiLedGet || offbandUiLedSet => DeviceUiSurface.led,
|
||||
offbandUiRotationGet || offbandUiRotationSet => DeviceUiSurface.rotation,
|
||||
_ => DeviceUiSurface.display,
|
||||
};
|
||||
|
||||
bool get isWrite =>
|
||||
sub == offbandUiDisplaySet ||
|
||||
sub == offbandUiLedSet ||
|
||||
sub == offbandUiRotationSet;
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connector/meshcore_connector.dart';
|
||||
import '../../connector/offband_device_ui.dart';
|
||||
import '../../connector/offband_indicators.dart';
|
||||
import '../../widgets/device_ui_error_banner.dart';
|
||||
|
||||
/// The radio's display and LED (#523, #944): display mode, rotation and the
|
||||
/// status LED. Gated on caps byte 2 `0x04`; the Node Settings row that leads
|
||||
/// here is gated the same way.
|
||||
///
|
||||
/// Every control draws from what the radio reported, never from the tap. A
|
||||
/// board without a display or without a controllable LED refuses that write
|
||||
/// and says so in the banner. English-only, matching [DeviceUiView].
|
||||
class IndicatorsView extends StatelessWidget {
|
||||
const IndicatorsView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final error = connector.deviceUiError;
|
||||
final errorSurface = connector.deviceUiErrorSurface;
|
||||
final mode = connector.displayMode;
|
||||
final led = connector.ledEnabled;
|
||||
return ListView(
|
||||
children: [
|
||||
if (error != null &&
|
||||
(errorSurface == DeviceUiSurface.display ||
|
||||
errorSurface == DeviceUiSurface.led ||
|
||||
errorSurface == DeviceUiSurface.rotation))
|
||||
DeviceUiErrorBanner(
|
||||
message: error,
|
||||
onDismiss: connector.clearDeviceUiError,
|
||||
),
|
||||
const _Header('Display'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SegmentedButton<DisplayMode>(
|
||||
segments: [
|
||||
for (final m in DisplayMode.values)
|
||||
ButtonSegment(value: m, label: Text(m.label)),
|
||||
],
|
||||
selected: {?mode},
|
||||
emptySelectionAllowed: true,
|
||||
onSelectionChanged: (v) {
|
||||
if (v.isNotEmpty) connector.setDisplayMode(v.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Text(
|
||||
mode?.description ?? 'The radio has not reported its display mode',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (connector.supportsDisplayRotation) ...[
|
||||
const _Header('Rotation'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 0, label: Text('Normal')),
|
||||
ButtonSegment(value: 180, label: Text('Flipped')),
|
||||
],
|
||||
// Null until the radio answers the rotation GET, and the
|
||||
// capability tag arrives BEFORE that reply, so on any slow link
|
||||
// this builds with no value at all. Empty selection, never a
|
||||
// force unwrap, matching the display-mode control above.
|
||||
selected: {?connector.displayRotation},
|
||||
emptySelectionAllowed: true,
|
||||
onSelectionChanged: (v) {
|
||||
if (v.isNotEmpty) connector.setDisplayRotation(v.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const _Header('LED'),
|
||||
SwitchListTile(
|
||||
title: const Text('Status LED'),
|
||||
subtitle: Text(
|
||||
led == null
|
||||
? 'The radio has not reported its LED'
|
||||
: 'Turn off to save battery or to stay unseen',
|
||||
),
|
||||
value: led ?? false,
|
||||
onChanged: connector.setLedEnabled,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header(this.title);
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,353 @@
|
||||
// #523 / #944: display mode, rotation and the status LED over 0xC5. Display and
|
||||
// LED are 0x05 to 0x08 (#542) under the INDICATORS caps bit.
|
||||
//
|
||||
// ROTATION IS 0x1A / 0x1B, advertised by TAG_DISPLAY_ROTATION in the 0xC5 0x1C
|
||||
// capability advert (#1098, firmware #1990). It was 0x18 / 0x19 under the
|
||||
// INDICATORS bit until 2026-11-04, and both of those were wrong:
|
||||
//
|
||||
// 0x18 / 0x19 are CHNOTIFY. Firmware 4e5a6c85, already flashed on the
|
||||
// owner's T1000-E, answers them as per-channel notify and its SET validates
|
||||
// only `mode < 4`, so a rotation SET of 0 landed as a valid notify-mode
|
||||
// write on channel slot 0, which is Public, with success reported on BOTH
|
||||
// sides. Silent corruption, no error anywhere.
|
||||
//
|
||||
// Discovering rotation by sending the GET and watching for a refusal is
|
||||
// forbidden by contract section 3: "what is forbidden is discovering support
|
||||
// by trying each feature's sub-code and watching for errors." The tag
|
||||
// replaces the probe, so capability and value are no longer conflated.
|
||||
//
|
||||
// Pins the gate (the tag, not 0x04 and not a probe), the device-info bytes 85
|
||||
// and 86, that offset 87 is read by nothing, and that every value follows the
|
||||
// radio's reply rather than the tap.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/connector/offband_capability_advert.dart';
|
||||
import 'package:meshcore_open/connector/offband_device_ui.dart';
|
||||
import 'package:meshcore_open/connector/offband_indicators.dart';
|
||||
import 'package:meshcore_open/storage/drift/blob_store.dart';
|
||||
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// An Offband device-info reply: caps byte 2 at 84, then LED and display mode.
|
||||
/// [extra] appends bytes past 86, to prove nothing there is read as rotation.
|
||||
List<int> deviceInfo({
|
||||
int caps2 = offbandCap2Indicators,
|
||||
int led = 1,
|
||||
int mode = 0,
|
||||
List<int> extra = const [],
|
||||
}) {
|
||||
final v = ascii.encode('1.5.0-1.17.0');
|
||||
final f = Uint8List(87 + extra.length);
|
||||
f[0] = 13;
|
||||
f[1] = 22;
|
||||
f.setRange(60, 60 + v.length, v);
|
||||
f[84] = caps2;
|
||||
f[85] = led;
|
||||
f[86] = mode;
|
||||
f.setRange(87, 87 + extra.length, extra);
|
||||
return f;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late OffbandDatabase db;
|
||||
late MeshCoreConnector connector;
|
||||
late List<List<int>> sent;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
PrefsManager.reset();
|
||||
await PrefsManager.initialize();
|
||||
db = OffbandDatabase(NativeDatabase.memory());
|
||||
BlobStore.overrideForTest(BlobStore(db));
|
||||
sent = [];
|
||||
connector = MeshCoreConnector();
|
||||
connector.sendFrameOverrideForTest = (data) => sent.add(data.toList());
|
||||
connector.setConnectedForTest();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
BlobStore.clearTestOverride();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
bool sentSub(int sub) => sent.any(
|
||||
(f) => f.length >= 2 && f[0] == cmdOffbandDeviceUi && f[1] == sub,
|
||||
);
|
||||
|
||||
/// A `0xC5` `0x1C` advert reply carrying [tags] as zero-length records.
|
||||
List<int> advert(List<int> tags) => [
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiInfoGet,
|
||||
for (final t in tags) ...[t, 0x00],
|
||||
];
|
||||
|
||||
/// `[0xC5][0x7F][0x05]`: this radio predates the advert.
|
||||
List<int> advertUnsupported() => [
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiErr,
|
||||
offbandUiErrReasonMalformed,
|
||||
];
|
||||
|
||||
/// Connect a radio that ADVERTISES rotation, then answer the GET with
|
||||
/// [rotation] degrees. Capability now comes from the tag, and the GET
|
||||
/// reports the current value; the two are separate steps on purpose.
|
||||
void connectWithRotation(int rotation, {int mode = 0}) {
|
||||
connector.handleFrameForTest(deviceInfo(mode: mode));
|
||||
connector.handleFrameForTest(
|
||||
Uint8List.fromList(advert([offbandInfoTagDisplayRotation])),
|
||||
);
|
||||
connector.handleFrameForTest([
|
||||
cmdOffbandDeviceUi,
|
||||
offbandUiRotationGet,
|
||||
rotation,
|
||||
]);
|
||||
sent.clear();
|
||||
}
|
||||
|
||||
group('frames', () {
|
||||
test('a mode SET is three bytes, with no rotation in it', () {
|
||||
expect(buildDisplaySetFrame(DisplayMode.alwaysOff), [0xC5, 0x06, 2]);
|
||||
});
|
||||
|
||||
test('rotation is 0x1A / 0x1B, and NEVER the CHNOTIFY pair', () {
|
||||
// The collision, pinned at the wire. 0x18/0x19 against firmware
|
||||
// 4e5a6c85 writes a channel-notify mode onto Public with a success
|
||||
// reply on both sides.
|
||||
expect(buildRotationGetFrame(), [0xC5, 0x1A]);
|
||||
expect(buildRotationSetFrame(180), [0xC5, 0x1B, 180]);
|
||||
expect(offbandUiRotationGet, isNot(0x18));
|
||||
expect(offbandUiRotationSet, isNot(0x19));
|
||||
});
|
||||
|
||||
test('a rotation other than 0 or 180 is refused before sending', () {
|
||||
expect(() => buildRotationSetFrame(90), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('the LED SET is one byte', () {
|
||||
expect(buildLedSetFrame(on: false), [0xC5, 0x08, 0]);
|
||||
});
|
||||
|
||||
test('a rotation reply carries degrees', () {
|
||||
final r = parseIndicatorReply(Uint8List.fromList([0xC5, 0x1B, 180]));
|
||||
expect(r!.rotation, 180);
|
||||
expect(r.surface, DeviceUiSurface.rotation);
|
||||
expect(r.isWrite, isTrue);
|
||||
});
|
||||
|
||||
test('an unknown mode or rotation is an error, not a guess', () {
|
||||
final mode = parseIndicatorReply(Uint8List.fromList([0xC5, 0x05, 9]));
|
||||
expect(mode!.errorMessage, contains('unknown display mode'));
|
||||
final rot = parseIndicatorReply(Uint8List.fromList([0xC5, 0x1A, 90]));
|
||||
expect(rot!.errorMessage, contains('unknown rotation'));
|
||||
});
|
||||
|
||||
test('the indicator parser does not claim the CHNOTIFY pair', () {
|
||||
// Belt and braces on the collision: even if a 0x18/0x19 frame reaches
|
||||
// this parser, it must not be read as a rotation reply.
|
||||
expect(parseIndicatorReply(Uint8List.fromList([0xC5, 0x18, 0])), isNull);
|
||||
expect(parseIndicatorReply(Uint8List.fromList([0xC5, 0x19, 0])), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('device-info bytes 85 and 86 render on connect', () {
|
||||
connector.handleFrameForTest(deviceInfo(led: 0, mode: 2));
|
||||
|
||||
expect(connector.supportsIndicators, isTrue);
|
||||
expect(connector.ledEnabled, isFalse);
|
||||
expect(connector.displayMode, DisplayMode.alwaysOff);
|
||||
});
|
||||
|
||||
test('a radio without 0x04 never sees an indicator frame', () async {
|
||||
connector.handleFrameForTest(deviceInfo(caps2: 0x01));
|
||||
await connector.setDisplayMode(DisplayMode.alwaysOn);
|
||||
await connector.setLedEnabled(false);
|
||||
|
||||
expect(connector.supportsIndicators, isFalse);
|
||||
expect(sentSub(offbandUiDisplaySet), isFalse);
|
||||
expect(sentSub(offbandUiLedSet), isFalse);
|
||||
expect(sentSub(offbandUiRotationGet), isFalse);
|
||||
});
|
||||
|
||||
group('rotation', () {
|
||||
test('the INDICATORS bit alone does NOT open rotation', () {
|
||||
// The gate moved to the advert tag. 0x04 means indicators, which is a
|
||||
// different capability; gating rotation on it was the old contract.
|
||||
connector.handleFrameForTest(deviceInfo());
|
||||
|
||||
expect(connector.supportsIndicators, isTrue);
|
||||
expect(connector.supportsDisplayRotation, isFalse);
|
||||
expect(sentSub(offbandUiRotationGet), isFalse);
|
||||
});
|
||||
|
||||
test('rotation is NOT probed, ever', () {
|
||||
// Contract section 3: "what is forbidden is discovering support by
|
||||
// trying each feature's sub-code and watching for errors." An advert
|
||||
// without the tag must produce no 0x1A on the wire at all.
|
||||
connector.handleFrameForTest(deviceInfo());
|
||||
connector.handleFrameForTest(
|
||||
Uint8List.fromList(advert([offbandInfoTagChanNotify])),
|
||||
);
|
||||
|
||||
expect(connector.supportsDisplayRotation, isFalse);
|
||||
expect(sentSub(offbandUiRotationGet), isFalse);
|
||||
});
|
||||
|
||||
test('a radio that predates the advert gets no rotation and no frame', () {
|
||||
connector.handleFrameForTest(deviceInfo());
|
||||
connector.handleFrameForTest(Uint8List.fromList(advertUnsupported()));
|
||||
|
||||
expect(connector.supportsDisplayRotation, isFalse);
|
||||
expect(sentSub(offbandUiRotationGet), isFalse);
|
||||
expect(connector.deviceUiError, isNull, reason: 'not a failure to show');
|
||||
});
|
||||
|
||||
test('the tag opens it, and the GET follows', () {
|
||||
connector.handleFrameForTest(deviceInfo());
|
||||
connector.handleFrameForTest(
|
||||
Uint8List.fromList(advert([offbandInfoTagDisplayRotation])),
|
||||
);
|
||||
|
||||
expect(connector.supportsDisplayRotation, isTrue);
|
||||
expect(sentSub(offbandUiRotationGet), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'capability and value are separate: the tag does not imply a value',
|
||||
() {
|
||||
// The old gate was `supportsIndicators && _displayRotation != null`,
|
||||
// which made "not answered yet" indistinguishable from "cannot rotate".
|
||||
connector.handleFrameForTest(deviceInfo());
|
||||
connector.handleFrameForTest(
|
||||
Uint8List.fromList(advert([offbandInfoTagDisplayRotation])),
|
||||
);
|
||||
|
||||
expect(connector.supportsDisplayRotation, isTrue);
|
||||
expect(connector.displayRotation, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('a byte past 86 in device-info is never read as rotation', () {
|
||||
connector.handleFrameForTest(deviceInfo(extra: [180]));
|
||||
|
||||
expect(connector.supportsDisplayRotation, isFalse);
|
||||
expect(connector.displayRotation, isNull);
|
||||
});
|
||||
|
||||
test('the value arrives from the GET reply', () {
|
||||
connectWithRotation(180);
|
||||
|
||||
expect(connector.supportsDisplayRotation, isTrue);
|
||||
expect(connector.displayRotation, 180);
|
||||
});
|
||||
|
||||
test('an advert refusal does not swallow the next write refusal', () {
|
||||
connector.handleFrameForTest(deviceInfo());
|
||||
connector.handleFrameForTest(Uint8List.fromList(advertUnsupported()));
|
||||
connector.handleFrameForTest(
|
||||
Uint8List.fromList(advert([offbandInfoTagDisplayRotation])),
|
||||
);
|
||||
sent.clear();
|
||||
|
||||
expect(connector.deviceUiError, isNull);
|
||||
});
|
||||
|
||||
test('a rotation write before the tag sends nothing and says so', () async {
|
||||
connector.handleFrameForTest(deviceInfo());
|
||||
sent.clear();
|
||||
|
||||
await connector.setDisplayRotation(180);
|
||||
|
||||
expect(sent, isEmpty);
|
||||
expect(connector.deviceUiErrorSurface, DeviceUiSurface.rotation);
|
||||
expect(connector.deviceUiError, isNotNull);
|
||||
});
|
||||
|
||||
test('a rotation write sends 0x1B and follows the echo', () async {
|
||||
connectWithRotation(0);
|
||||
|
||||
await connector.setDisplayRotation(180);
|
||||
expect(sent, [buildRotationSetFrame(180)]);
|
||||
expect(connector.displayRotation, 0);
|
||||
|
||||
connector.handleFrameForTest([0xC5, 0x1B, 180]);
|
||||
expect(connector.displayRotation, 180);
|
||||
});
|
||||
|
||||
test(
|
||||
'a rotation SET refused with reason 10 says so on its surface',
|
||||
() async {
|
||||
connectWithRotation(0);
|
||||
|
||||
await connector.setDisplayRotation(180);
|
||||
connector.handleFrameForTest([0xC5, 0x7F, 0x0A]);
|
||||
|
||||
expect(connector.deviceUiErrorSurface, DeviceUiSurface.rotation);
|
||||
expect(
|
||||
connector.deviceUiError,
|
||||
"This radio's display cannot be rotated",
|
||||
);
|
||||
expect(connector.displayRotation, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a mode write never touches rotation', () async {
|
||||
connectWithRotation(180);
|
||||
|
||||
await connector.setDisplayMode(DisplayMode.alwaysOff);
|
||||
expect(sent, [buildDisplaySetFrame(DisplayMode.alwaysOff)]);
|
||||
|
||||
connector.handleFrameForTest([0xC5, 0x06, 2]);
|
||||
expect(connector.displayMode, DisplayMode.alwaysOff);
|
||||
expect(connector.displayRotation, 180);
|
||||
});
|
||||
|
||||
test('state follows the echo, not the request', () async {
|
||||
connectWithRotation(0);
|
||||
await connector.setDisplayMode(DisplayMode.alwaysOn);
|
||||
|
||||
expect(connector.displayMode, DisplayMode.auto);
|
||||
|
||||
connector.handleFrameForTest([0xC5, 0x06, 1]);
|
||||
expect(connector.displayMode, DisplayMode.alwaysOn);
|
||||
|
||||
connector.handleFrameForTest([0xC5, 0x08, 0]);
|
||||
expect(connector.ledEnabled, isFalse);
|
||||
});
|
||||
|
||||
test('a board without a display refuses on the display surface', () async {
|
||||
connectWithRotation(0);
|
||||
await connector.setDisplayMode(DisplayMode.alwaysOff);
|
||||
connector.handleFrameForTest([0xC5, 0x7F, 0x06]);
|
||||
|
||||
expect(connector.deviceUiErrorSurface, DeviceUiSurface.display);
|
||||
expect(connector.deviceUiError, 'This radio has no display or LED to set');
|
||||
expect(connector.displayMode, DisplayMode.auto);
|
||||
});
|
||||
|
||||
test('an LED refusal lands on the LED surface', () async {
|
||||
connectWithRotation(0);
|
||||
await connector.setLedEnabled(false);
|
||||
connector.handleFrameForTest([0xC5, 0x7F, 0x06]);
|
||||
|
||||
expect(connector.deviceUiErrorSurface, DeviceUiSurface.led);
|
||||
expect(connector.ledEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('a stock-shape frame reads no indicator bytes', () {
|
||||
final stock = Uint8List(82);
|
||||
stock[0] = 13;
|
||||
stock[1] = 22;
|
||||
connector.handleFrameForTest(stock);
|
||||
|
||||
expect(connector.supportsIndicators, isFalse);
|
||||
expect(connector.displayMode, isNull);
|
||||
expect(connector.ledEnabled, isNull);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
// Widget tests for the Display and LED pane (#523/#1098).
|
||||
//
|
||||
// These pin the crash the adversarial review caught before this was pushed:
|
||||
// the rotation control force-unwrapped `displayRotation`, but the capability
|
||||
// tag that ENABLES the control arrives in the `0xC5` `0x1C` advert, which lands
|
||||
// BEFORE the rotation GET reply that supplies the value. So on any slow link
|
||||
// the pane was built with the control enabled and the value still null, and it
|
||||
// threw. The display-mode control beside it was already null-safe; only
|
||||
// rotation was not.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/screens/settings/indicators_view.dart';
|
||||
|
||||
/// Rotation is advertised, but the radio has not answered the GET yet. This is
|
||||
/// the real window between the advert reply and the rotation reply.
|
||||
class _RotationUnanswered extends MeshCoreConnector {
|
||||
@override
|
||||
bool get supportsIndicators => true;
|
||||
@override
|
||||
bool get supportsDisplayRotation => true;
|
||||
@override
|
||||
int? get displayRotation => null;
|
||||
}
|
||||
|
||||
class _RotationAnswered extends MeshCoreConnector {
|
||||
_RotationAnswered(this._degrees);
|
||||
final int _degrees;
|
||||
@override
|
||||
bool get supportsIndicators => true;
|
||||
@override
|
||||
bool get supportsDisplayRotation => true;
|
||||
@override
|
||||
int? get displayRotation => _degrees;
|
||||
}
|
||||
|
||||
Future<void> _pump(WidgetTester tester, MeshCoreConnector connector) =>
|
||||
tester.pumpWidget(
|
||||
ChangeNotifierProvider<MeshCoreConnector>.value(
|
||||
value: connector,
|
||||
child: const MaterialApp(home: Scaffold(body: IndicatorsView())),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'the pane builds before the radio has answered the rotation GET',
|
||||
(tester) async {
|
||||
await _pump(tester, _RotationUnanswered());
|
||||
|
||||
// If this ever fails again, something is force-unwrapping a value that is
|
||||
// null for the whole window between the advert and the GET reply.
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Rotation'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('no rotation is shown as chosen until the radio answers', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, _RotationUnanswered());
|
||||
|
||||
final button = tester.widget<SegmentedButton<int>>(
|
||||
find.byType(SegmentedButton<int>),
|
||||
);
|
||||
// An empty selection, not a guessed default: showing "Normal" here would
|
||||
// claim a rotation the radio never reported.
|
||||
expect(button.selected, isEmpty);
|
||||
expect(button.emptySelectionAllowed, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('the rotation the radio reported is the one shown', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, _RotationAnswered(180));
|
||||
|
||||
final button = tester.widget<SegmentedButton<int>>(
|
||||
find.byType(SegmentedButton<int>),
|
||||
);
|
||||
expect(button.selected, {180});
|
||||
});
|
||||
|
||||
testWidgets('a radio that does not advertise rotation gets no control', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, _DummyConnector());
|
||||
|
||||
expect(find.text('Rotation'), findsNothing);
|
||||
expect(find.byType(SegmentedButton<int>), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
class _DummyConnector extends MeshCoreConnector {}
|
||||
Loading…
Reference in new issue