Skip to content

Commit 0131abe

Browse files
committed
feat: add configurable sentinel, in-place decode, sentinel framing
Ports cobs_codec_rs 1.1.0 features; additive and byte-identical (v1.1.0).
1 parent 4f0fc80 commit 0131abe

8 files changed

Lines changed: 244 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@
33
All notable changes to this package are documented here. This project adheres
44
to [Semantic Versioning](https://semver.org).
55

6+
## 1.1.0
7+
8+
### Added
9+
10+
- **Configurable sentinel byte** for both COBS and COBS/R: `cobsEncodeWithSentinel`
11+
/ `cobsDecodeWithSentinel` and `cobsrEncodeWithSentinel` /
12+
`cobsrDecodeWithSentinel` encode to avoid an arbitrary delimiter byte instead
13+
of `0x00` (by XORing the finished encoding with the sentinel). `sentinel == 0`
14+
is byte-for-byte identical to the plain codecs.
15+
- **In-place basic-COBS decoding**: `cobsDecodeInPlace` and
16+
`cobsDecodeInPlaceWithSentinel` decode within the caller's `Uint8List` and
17+
return the decoded length, needing no output buffer and with no "output too
18+
small" case (COBS decoding never expands).
19+
- **Sentinel framing**: `cobsFrame`, `cobsUnframe`, `CobsFrameEncoder` and
20+
`CobsFrameDecoder` gain an optional `sentinel` parameter (defaulting to the
21+
`0x00` `cobsDelimiter`) so a non-zero byte can delimit frames on the wire, for
22+
both the basic and reduced codecs.
23+
624
## 1.0.0
725

826
Initial release.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ dart pub add cobs_codec
4040

4141
```yaml
4242
dependencies:
43-
cobs_codec: ^1.0.0
43+
cobs_codec: ^1.1.0
4444
```
4545
4646
## Usage

lib/cobs_codec.dart

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,27 @@
4040
library;
4141

4242
export 'src/cobs.dart'
43-
show CobsCodec, CobsDecoder, CobsEncoder, cobs, cobsDecode, cobsEncode;
43+
show
44+
CobsCodec,
45+
CobsDecoder,
46+
CobsEncoder,
47+
cobs,
48+
cobsDecode,
49+
cobsDecodeInPlace,
50+
cobsDecodeInPlaceWithSentinel,
51+
cobsDecodeWithSentinel,
52+
cobsEncode,
53+
cobsEncodeWithSentinel;
4454
export 'src/cobsr.dart'
4555
show
4656
CobsrCodec,
4757
CobsrDecoder,
4858
CobsrEncoder,
4959
cobsr,
5060
cobsrDecode,
51-
cobsrEncode;
61+
cobsrDecodeWithSentinel,
62+
cobsrEncode,
63+
cobsrEncodeWithSentinel;
5264
export 'src/exceptions.dart' show CobsDecodeException;
5365
export 'src/framing.dart'
5466
show

lib/src/cobs.dart

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,114 @@ Uint8List cobsDecode(List<int> input) {
130130
return Uint8List.sublistView(out, 0, writeIndex);
131131
}
132132

133+
/// Encodes [input] with basic COBS using an arbitrary [sentinel] byte instead of
134+
/// `0x00`, returning a [Uint8List] that never contains the [sentinel].
135+
///
136+
/// This runs the ordinary [cobsEncode] and then XORs every output byte with
137+
/// [sentinel] (masked to the low 8 bits), which shifts the byte the encoding
138+
/// avoids from `0x00` to [sentinel]. Choosing a [sentinel] that rarely occurs in
139+
/// the payload minimises overhead. `sentinel == 0` is byte-for-byte identical to
140+
/// [cobsEncode].
141+
Uint8List cobsEncodeWithSentinel(List<int> input, int sentinel) {
142+
final encoded = cobsEncode(input);
143+
final s = sentinel & 0xFF;
144+
if (s != 0) {
145+
for (var i = 0; i < encoded.length; i++) {
146+
encoded[i] ^= s;
147+
}
148+
}
149+
return encoded;
150+
}
151+
152+
/// Decodes basic-COBS [input] that was encoded with an arbitrary [sentinel] byte
153+
/// (see [cobsEncodeWithSentinel]), returning the original bytes.
154+
///
155+
/// A fresh copy of [input] is XORed back with [sentinel] (masked to the low 8
156+
/// bits) before decoding, so the caller's [input] is never mutated.
157+
/// `sentinel == 0` is identical to [cobsDecode].
158+
///
159+
/// Throws a [CobsDecodeException] if the recovered bytes are not valid COBS (for
160+
/// example, if [input] itself contained the [sentinel]).
161+
Uint8List cobsDecodeWithSentinel(List<int> input, int sentinel) {
162+
final s = sentinel & 0xFF;
163+
if (s == 0) return cobsDecode(input);
164+
final copy = Uint8List(input.length);
165+
for (var i = 0; i < copy.length; i++) {
166+
copy[i] = input[i] ^ s;
167+
}
168+
return cobsDecode(copy);
169+
}
170+
171+
/// Decodes basic-COBS data in place, overwriting [buffer] with the decoded bytes
172+
/// and returning their length; the decoded output occupies `buffer[0..n]`.
173+
///
174+
/// This needs no separate output buffer: COBS decoding never expands, so the
175+
/// write position always trails the read position — there is therefore no
176+
/// "output too small" case. The bytes of [buffer] beyond the returned length are
177+
/// left in an unspecified (partially overwritten) state.
178+
///
179+
/// Throws a [CobsDecodeException] if [buffer] is not valid COBS.
180+
int cobsDecodeInPlace(Uint8List buffer) {
181+
final srcLen = buffer.length;
182+
if (srcLen == 0) return 0;
183+
184+
var writeIndex = 0;
185+
var index = 0;
186+
187+
while (true) {
188+
final code = buffer[index];
189+
if (code == 0) {
190+
throw CobsDecodeException('zero byte in COBS input', buffer, index);
191+
}
192+
index++;
193+
final blockEnd = index + code - 1;
194+
final copyEnd = blockEnd < srcLen ? blockEnd : srcLen;
195+
for (; index < copyEnd; index++) {
196+
final byte = buffer[index];
197+
if (byte == 0) {
198+
throw CobsDecodeException('zero byte in COBS input', buffer, index);
199+
}
200+
// `writeIndex` trails `index` throughout, so this never clobbers a byte
201+
// that has not yet been read.
202+
buffer[writeIndex++] = byte;
203+
}
204+
if (blockEnd > srcLen) {
205+
throw CobsDecodeException(
206+
'length code points past end of input',
207+
buffer,
208+
blockEnd - code, // index of the offending length code
209+
);
210+
}
211+
if (blockEnd < srcLen) {
212+
if (code < 0xFF) buffer[writeIndex++] = 0;
213+
} else {
214+
break;
215+
}
216+
}
217+
218+
return writeIndex;
219+
}
220+
221+
/// Decodes basic-COBS data that was encoded with an arbitrary [sentinel] byte in
222+
/// place, overwriting [buffer] with the decoded bytes and returning their
223+
/// length; the decoded output occupies `buffer[0..n]`.
224+
///
225+
/// When [sentinel] is non-zero, [buffer] is first XORed with it (masked to the
226+
/// low 8 bits) and then decoded in place by [cobsDecodeInPlace]. `sentinel == 0`
227+
/// is identical to [cobsDecodeInPlace]. As an in-place operation this
228+
/// necessarily consumes (overwrites) [buffer].
229+
///
230+
/// Throws a [CobsDecodeException] if [buffer] is not valid.
231+
int cobsDecodeInPlaceWithSentinel(Uint8List buffer, int sentinel) {
232+
final s = sentinel & 0xFF;
233+
if (s != 0) {
234+
for (var i = 0; i < buffer.length; i++) {
235+
buffer[i] ^= s;
236+
}
237+
}
238+
return cobsDecodeInPlace(buffer);
239+
}
240+
133241
/// A [Codec] that encodes and decodes bytes with basic Consistent Overhead Byte
134242
/// Stuffing (COBS).
135243
///

lib/src/cobsr.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,43 @@ Uint8List cobsrDecode(List<int> input) {
129129
return Uint8List.sublistView(out, 0, writeIndex);
130130
}
131131

132+
/// Encodes [input] with COBS/R using an arbitrary [sentinel] byte instead of
133+
/// `0x00`, returning a [Uint8List] that never contains the [sentinel].
134+
///
135+
/// This runs the ordinary [cobsrEncode] and then XORs every output byte with
136+
/// [sentinel] (masked to the low 8 bits), which shifts the byte the encoding
137+
/// avoids from `0x00` to [sentinel]. `sentinel == 0` is byte-for-byte identical
138+
/// to [cobsrEncode].
139+
Uint8List cobsrEncodeWithSentinel(List<int> input, int sentinel) {
140+
final encoded = cobsrEncode(input);
141+
final s = sentinel & 0xFF;
142+
if (s != 0) {
143+
for (var i = 0; i < encoded.length; i++) {
144+
encoded[i] ^= s;
145+
}
146+
}
147+
return encoded;
148+
}
149+
150+
/// Decodes COBS/R [input] that was encoded with an arbitrary [sentinel] byte
151+
/// (see [cobsrEncodeWithSentinel]), returning the original bytes.
152+
///
153+
/// A fresh copy of [input] is XORed back with [sentinel] (masked to the low 8
154+
/// bits) before decoding, so the caller's [input] is never mutated.
155+
/// `sentinel == 0` is identical to [cobsrDecode].
156+
///
157+
/// Throws a [CobsDecodeException] if the recovered bytes contain a `0x00` byte
158+
/// (for example, if [input] itself contained the [sentinel]).
159+
Uint8List cobsrDecodeWithSentinel(List<int> input, int sentinel) {
160+
final s = sentinel & 0xFF;
161+
if (s == 0) return cobsrDecode(input);
162+
final copy = Uint8List(input.length);
163+
for (var i = 0; i < copy.length; i++) {
164+
copy[i] = input[i] ^ s;
165+
}
166+
return cobsrDecode(copy);
167+
}
168+
132169
/// A [Codec] that encodes and decodes bytes with Consistent Overhead Byte
133170
/// Stuffing — Reduced (COBS/R).
134171
///

lib/src/framing.dart

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,29 @@ const int cobsDelimiter = 0x00;
2323
Uint8List _asBytes(List<int> bytes) =>
2424
bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
2525

26-
/// Encodes [packet] with [codec] (basic [cobs] by default) and appends the
27-
/// [cobsDelimiter], producing a self-delimiting frame ready to transmit.
26+
/// Returns the frame `bytes[start..end)` XORed by [sentinel], ready to hand to a
27+
/// codec's plain decoder.
28+
///
29+
/// For a non-zero [sentinel] a fresh copy is made so the caller's [bytes] are
30+
/// never mutated; for `0` the slice is viewed without copying, preserving the
31+
/// zero-copy behaviour of the plain delimiter path.
32+
Uint8List _deSentinel(Uint8List bytes, int start, int end, int sentinel) {
33+
if (sentinel == 0) return Uint8List.sublistView(bytes, start, end);
34+
final frame = Uint8List(end - start);
35+
for (var i = 0; i < frame.length; i++) {
36+
frame[i] = bytes[start + i] ^ sentinel;
37+
}
38+
return frame;
39+
}
40+
41+
/// Encodes [packet] with [codec] (basic [cobs] by default) and appends the frame
42+
/// delimiter, producing a self-delimiting frame ready to transmit.
43+
///
44+
/// By default frames are delimited by the [cobsDelimiter] (`0x00`). Pass a
45+
/// non-zero [sentinel] to delimit on that byte instead: the encoding is made to
46+
/// avoid [sentinel] (by XORing the codec's output with it) and [sentinel] is
47+
/// appended as the delimiter. Decode such a frame with a matching [sentinel] in
48+
/// [cobsUnframe] or [CobsFrameDecoder].
2849
///
2950
/// ```dart
3051
/// final frame = cobsFrame([0x11, 0x00, 0x22]);
@@ -33,17 +54,29 @@ Uint8List _asBytes(List<int> bytes) =>
3354
Uint8List cobsFrame(
3455
List<int> packet, {
3556
Codec<List<int>, List<int>> codec = cobs,
57+
int sentinel = cobsDelimiter,
3658
}) {
3759
final encoded = codec.encode(packet);
3860
final out = Uint8List(encoded.length + 1);
3961
out.setRange(0, encoded.length, encoded);
40-
// out[encoded.length] is already 0x00 (the delimiter).
62+
final s = sentinel & 0xFF;
63+
if (s != 0) {
64+
for (var i = 0; i < encoded.length; i++) {
65+
out[i] ^= s;
66+
}
67+
}
68+
out[encoded.length] = s; // the delimiter (already 0 when s == 0)
4169
return out;
4270
}
4371

44-
/// Splits [data] on the [cobsDelimiter] and decodes each frame with [codec],
72+
/// Splits [data] on the frame delimiter and decodes each frame with [codec],
4573
/// returning the list of recovered packets.
4674
///
75+
/// By default frames are delimited by the [cobsDelimiter] (`0x00`). Pass the
76+
/// non-zero [sentinel] used to frame the data to split on that byte instead and
77+
/// undo its XOR before decoding; the frame slice is copied before it is XORed, so
78+
/// [data] is never mutated.
79+
///
4780
/// Any trailing bytes after the final delimiter are treated as an incomplete
4881
/// frame and ignored. When [skipEmpty] is true (the default), empty frames
4982
/// (produced by consecutive or leading delimiters) are skipped rather than
@@ -55,17 +88,18 @@ List<Uint8List> cobsUnframe(
5588
List<int> data, {
5689
Codec<List<int>, List<int>> codec = cobs,
5790
bool skipEmpty = true,
91+
int sentinel = cobsDelimiter,
5892
}) {
5993
final frames = <Uint8List>[];
6094
final bytes = _asBytes(data);
95+
final s = sentinel & 0xFF;
6196
var start = 0;
6297
for (var i = 0; i < bytes.length; i++) {
63-
if (bytes[i] == cobsDelimiter) {
98+
if (bytes[i] == s) {
6499
if (i == start) {
65100
if (!skipEmpty) frames.add(Uint8List(0));
66101
} else {
67-
frames.add(
68-
_asBytes(codec.decode(Uint8List.sublistView(bytes, start, i))));
102+
frames.add(_asBytes(codec.decode(_deSentinel(bytes, start, i, s))));
69103
}
70104
start = i + 1;
71105
}
@@ -80,16 +114,21 @@ List<Uint8List> cobsUnframe(
80114
/// outgoingPackets.transform(const CobsFrameEncoder()).pipe(serialSink);
81115
/// ```
82116
class CobsFrameEncoder extends StreamTransformerBase<List<int>, Uint8List> {
83-
/// Creates a frame encoder using [codec] (basic [cobs] by default).
84-
const CobsFrameEncoder({this.codec = cobs});
117+
/// Creates a frame encoder using [codec] (basic [cobs] by default), delimiting
118+
/// each frame with [sentinel] (the `0x00` [cobsDelimiter] by default).
119+
const CobsFrameEncoder({this.codec = cobs, this.sentinel = cobsDelimiter});
85120

86121
/// The codec used to encode each packet.
87122
final Codec<List<int>, List<int>> codec;
88123

124+
/// The byte value used to delimit frames on the wire (and that the encoding is
125+
/// made to avoid). Defaults to the `0x00` [cobsDelimiter].
126+
final int sentinel;
127+
89128
@override
90129
Stream<Uint8List> bind(Stream<List<int>> stream) async* {
91130
await for (final packet in stream) {
92-
yield cobsFrame(packet, codec: codec);
131+
yield cobsFrame(packet, codec: codec, sentinel: sentinel);
93132
}
94133
}
95134
}
@@ -125,11 +164,17 @@ class CobsFrameDecoder extends StreamTransformerBase<List<int>, Uint8List> {
125164
this.skipEmpty = true,
126165
this.maxFrameLength,
127166
this.onInvalidFrame,
167+
this.sentinel = cobsDelimiter,
128168
});
129169

130170
/// The codec used to decode each frame.
131171
final Codec<List<int>, List<int>> codec;
132172

173+
/// The byte value that delimits frames on the wire (and that the encoding is
174+
/// made to avoid). The stream is split on this byte and each frame is XORed
175+
/// back with it before decoding. Defaults to the `0x00` [cobsDelimiter].
176+
final int sentinel;
177+
133178
/// Whether to skip empty frames (from consecutive or leading delimiters)
134179
/// rather than emit empty packets.
135180
final bool skipEmpty;
@@ -160,6 +205,7 @@ class CobsFrameDecoder extends StreamTransformerBase<List<int>, Uint8List> {
160205
// completes a frame — so a delimiter-less run would otherwise ignore
161206
// backpressure and make the subscription impossible to cancel.
162207
final buffer = BytesBuilder(copy: false);
208+
final delimiter = sentinel & 0xFF;
163209
late final StreamController<Uint8List> controller;
164210
StreamSubscription<List<int>>? subscription;
165211

@@ -177,7 +223,7 @@ class CobsFrameDecoder extends StreamTransformerBase<List<int>, Uint8List> {
177223
final bytes = _asBytes(chunk);
178224
var start = 0;
179225
for (var i = 0; i < bytes.length; i++) {
180-
if (bytes[i] != cobsDelimiter) continue;
226+
if (bytes[i] != delimiter) continue;
181227
// The delimiter completes a frame: buffered bytes + this chunk up to
182228
// (but not including) the delimiter. This sublist view is consumed
183229
// synchronously by takeBytes/decode below, so it need not be copied.
@@ -188,6 +234,14 @@ class CobsFrameDecoder extends StreamTransformerBase<List<int>, Uint8List> {
188234
if (!skipEmpty) controller.add(Uint8List(0));
189235
continue;
190236
}
237+
// `frame` is owned by this decoder (from takeBytes), so undoing the
238+
// sentinel XOR in place is safe; when the delimiter is 0x00 this is a
239+
// no-op and the plain-COBS path is unchanged.
240+
if (delimiter != 0) {
241+
for (var j = 0; j < frame.length; j++) {
242+
frame[j] ^= delimiter;
243+
}
244+
}
191245
try {
192246
controller.add(_asBytes(codec.decode(frame)));
193247
} on CobsDecodeException catch (error, stackTrace) {

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: cobs_codec
22
description: >-
33
Consistent Overhead Byte Stuffing (COBS) and COBS/R codecs for zero-free,
44
low-overhead framing of serial, UART and packet byte streams.
5-
version: 1.0.0
5+
version: 1.1.0
66
repository: https://github.com/firechip/cobs_codec
77
issue_tracker: https://github.com/firechip/cobs_codec/issues
88
homepage: https://firechip.dev

test/features_test.dart

4.59 KB
Binary file not shown.

0 commit comments

Comments
 (0)