ble_framed_protocol

pub package License: MIT

Encode and decode XOR-checksummed framed-byte protocols common to industrial BLE devices — electronic locks, GPS trackers, smart-meter dongles, Nordic NUS clones. Pure Dart, no Flutter dependency.

The frame layout

[HEADER bytes ...][CMD][LEN_H][LEN_L][PAYLOAD ...LEN bytes][XOR][TRAILER bytes ...]
  • Header — fixed magic bytes, default [0xAA, 0xBB].
  • CMD — single command byte (caller-defined).
  • LEN — big-endian 16-bit unsigned length of PAYLOAD.
  • PAYLOADLEN arbitrary bytes.
  • XOR — XOR of every byte from the frame start up to (but not including) the checksum byte.
  • Trailer — fixed magic bytes, default [0x0D, 0x0A] (CRLF).

If you've reverse-engineered the protocol of a Chinese-OEM e-lock or GPS tracker recently, this is probably the layout you found. Default bytes match the most common variant; custom magic bytes are configurable.

Install

dart pub add ble_framed_protocol

Usage

import 'dart:typed_data';
import 'package:ble_framed_protocol/ble_framed_protocol.dart';

final codec = FramedProtocol.standard;

// Encode a command for transmission.
final tx = codec.encode(
  Frame(
    cmdId: 0x01,
    payload: Uint8List.fromList([0x10, 0x20, 0x30]),
  ),
);
// → 0xAA 0xBB 0x01 0x00 0x03 0x10 0x20 0x30 0x13 0x0D 0x0A

// Decode a response from the device. Returns null on any framing error
// (bad header, bad trailer, length mismatch, bad checksum).
final rx = codec.decode(receivedBytes);
if (rx != null) {
  final responseCode = rx.payload.first;          // your domain split
  final responseData = rx.payload.sublist(1);     // (this lib stays
  print('cmd 0x${rx.cmdId.toRadixString(16)} → '  //  framing-only)
        '$responseCode / ${responseData.length} bytes');
}

Custom magic bytes

final codec = FramedProtocol(
  headerBytes: const [0x55, 0xAA],
  trailerBytes: const [0xFF],
);

Any non-empty header/trailer works. The XOR checksum still covers everything up to its own position regardless of header/trailer length.

API

Frame

Field Type Notes
cmdId int Single byte, 0..0xFF.
payload Uint8List Bytes between LEN and XOR. May be empty.

FramedProtocol

Method Returns Notes
encode(Frame) Uint8List Throws ArgumentError on out-of-range cmdId or oversized payload.
decode(Uint8List) Frame? Returns null on any framing/checksum error.
verifyChecksum(Uint8List) bool Cheap structural + checksum check, no allocation.
Static / constant Type Notes
FramedProtocol.standard FramedProtocol Default [0xAA, 0xBB] / [0x0D, 0x0A] codec.
FramedProtocol.maxPayloadSize int 0xFFFF — hard limit from the 16-bit length field.

Why "framing-only"?

The library deliberately does not know about response codes, command enums, BCD time encoding, or device-specific status bits. Those vary by manufacturer. This package gives you a tested codec for the bytes on the wire — you build the typed command/response layer on top.

A typical higher layer (~30 lines per device) looks like:

class MyDeviceCommand {
  static Uint8List buildLock(Uint8List deviceId, Uint8List password) {
    final data = Uint8List(13)
      ..setRange(0, 6, deviceId)
      ..setRange(6, 12, password)
      ..[12] = 0x00;
    return FramedProtocol.standard.encode(
      Frame(cmdId: 0x01, payload: data),
    );
  }
}

Testing

dart test

Tests cover round-trips at varied payload sizes (0, 1, 16, 255, 1024 bytes), custom magic bytes, and rejection of every malformed-frame variant: bad header, bad trailer, length mismatch, checksum mismatch, too-short input, oversized payload, out-of-range command id.

Example

dart run example/encode_decode.dart

License

MIT — see LICENSE.

Libraries

ble_framed_protocol
Encode and decode XOR-checksummed framed-byte protocols common to industrial BLE devices.