diff --git a/lib/openstrap_protocol.dart b/lib/openstrap_protocol.dart index 20fb05c..226428c 100644 --- a/lib/openstrap_protocol.dart +++ b/lib/openstrap_protocol.dart @@ -100,6 +100,7 @@ export 'src/realtime96.dart' // Source 2 — CRC, constants, framing, commands. export 'src/crc.dart' show crc8, crc32, crc16Modbus; export 'src/constants.dart'; +export 'src/firmware_update.dart'; export 'src/framing.dart' show Frame, pad4, buildFrame, parseFrame, FrameReassembler; export 'src/commands.dart' diff --git a/lib/src/firmware_update.dart b/lib/src/firmware_update.dart new file mode 100644 index 0000000..81ceb3e --- /dev/null +++ b/lib/src/firmware_update.dart @@ -0,0 +1,51 @@ +// Device image update commands (gen5 only). Pure payload builders: nothing +// here sends anything, and none of these opcodes may leave the host without +// the caller's explicit allowDangerous opt-in (0x8E, 0x8F and 0x90 are +// destructive; 0x53 only asks the strap to check what is staged). +// +// 0x8E start load [0x01] +// 0x8F load data [0x01][offset u32 LE][len u8][data] +// 0x53 verify image [0x01] +// 0x90 process image [0x01] — commit; cannot be undone from the host. +// +// A status of 1 on 0x90 only means the staged bytes were accepted and +// checksummed. It is not proof the image is now running. +import 'dart:typed_data'; + +class FwUpdate { + static const int startLoad = 0x8E; + static const int loadData = 0x8F; + static const int verifyImage = 0x53; + static const int processImage = 0x90; + + /// Length field of [loadData] is one byte. + static const int maxChunk = 255; + + /// Bytes per [loadData]. Small enough that the framed write fits a typical + /// negotiated ATT payload (8 header + padded 9+128 inner + 4 crc = 152). + static const int chunk = 128; + + static const List rev1 = [0x01]; + + static Uint8List loadDataPayload(int offset, List data) { + if (data.isEmpty || data.length > maxChunk) { + throw ArgumentError('chunk must be 1..$maxChunk bytes, got ${data.length}'); + } + if (offset < 0 || offset > 0xFFFFFFFF) { + throw ArgumentError('offset out of u32 range: $offset'); + } + final b = BytesBuilder() + ..addByte(0x01) + ..add((ByteData(4)..setUint32(0, offset, Endian.little)) + .buffer + .asUint8List()) + ..addByte(data.length) + ..add(data); + return b.toBytes(); + } + + static int chunkCount(int imageLen) { + if (imageLen <= 0) throw ArgumentError('image length must be > 0, got $imageLen'); + return (imageLen + chunk - 1) ~/ chunk; + } +} diff --git a/test/firmware_update_test.dart b/test/firmware_update_test.dart new file mode 100644 index 0000000..bb471c5 --- /dev/null +++ b/test/firmware_update_test.dart @@ -0,0 +1,31 @@ +import 'package:openstrap_protocol/openstrap_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + test('load data payload layout', () { + final p = FwUpdate.loadDataPayload(0x01020304, [9, 8, 7]); + expect(p, [0x01, 0x04, 0x03, 0x02, 0x01, 3, 9, 8, 7]); + }); + test('rejects empty and oversize chunks', () { + expect(() => FwUpdate.loadDataPayload(0, const []), throwsArgumentError); + expect(() => FwUpdate.loadDataPayload(0, List.filled(256, 0)), + throwsArgumentError); + }); + test('rejects offsets outside u32', () { + expect(() => FwUpdate.loadDataPayload(-1, [1]), throwsArgumentError); + expect(() => FwUpdate.loadDataPayload(0x100000000, [1]), throwsArgumentError); + }); + test('chunk count rejects empty and negative lengths', () { + expect(() => FwUpdate.chunkCount(0), throwsArgumentError); + expect(() => FwUpdate.chunkCount(-5), throwsArgumentError); + }); + test('chunk count rounds up', () { + expect(FwUpdate.chunkCount(128), 1); + expect(FwUpdate.chunkCount(129), 2); + }); + test('start, load and process are blocked by the generic guard', () { + for (final op in [FwUpdate.startLoad, FwUpdate.loadData, FwUpdate.processImage]) { + expect(dangerousCmds.contains(op), isTrue); + } + }); +}