Skip to content

Commit 904d80a

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 588b0b0 commit 904d80a

7 files changed

Lines changed: 350 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Changelog
2+
3+
All notable changes to this library are documented here. This project adheres
4+
to [Semantic Versioning](https://semver.org).
5+
6+
## 1.1.0
7+
8+
### Added
9+
10+
- **Configurable sentinel**: `encodeWithSentinel` / `decodeWithSentinel` on both
11+
`Cobs` and `Cobsr`, for framing with a delimiter other than `0x00`. The
12+
encoded output never contains the chosen sentinel byte, and a sentinel of `0`
13+
is byte-for-byte identical to the plain codecs.
14+
- **In-place decoding**: `Cobs.decodeInPlace` (with an optional `sentinel`
15+
overload), which decodes basic COBS within the same buffer and returns the
16+
decoded length — no second array required.
17+
- **Framing sentinel**: `CobsFraming.frame` / `unframe` and `CobsStreamDecoder`
18+
gained a trailing `sentinel` parameter (defaulting to the `0x00`
19+
`COBS_DELIMITER`), so a stream can be framed on a non-`0x00` delimiter.
20+
21+
All additions are backward compatible.
22+
23+
## 1.0.0
24+
25+
Initial release.
26+
27+
### Added
28+
29+
- **Basic COBS** and **COBS/R** encode/decode (`Cobs`, `Cobsr`).
30+
- **Stream framing** for `0x00`-delimited links: `CobsFraming.frame` /
31+
`unframe`, and the incremental `CobsStreamDecoder` with a `maxFrameLength`
32+
guard.
33+
- Size helpers `maxEncodedLength` and `encodingOverhead`.
34+
- `CobsDecodeException` for invalid encoded input.
35+
- Golden-vector tests plus a conformance test against
36+
[firechip/cobs-conformance](https://github.com/firechip/cobs-conformance),
37+
byte-identical to the reference.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ repositories {
4949
}
5050

5151
dependencies {
52-
implementation("dev.firechip:cobs_codec:1.0.0")
52+
implementation("dev.firechip:cobs_codec:1.1.0")
5353
}
5454
```
5555

@@ -67,7 +67,7 @@ authentication:
6767

6868
```kotlin
6969
dependencies {
70-
implementation(files("libs/cobs_codec-1.0.0.aar"))
70+
implementation(files("libs/cobs_codec-1.1.0.aar"))
7171
}
7272
```
7373

cobs/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66
}
77

88
group = "dev.firechip"
9-
version = "1.0.0"
9+
version = "1.1.0"
1010

1111
android {
1212
namespace = "dev.firechip.cobs"

cobs/src/main/kotlin/dev/firechip/cobs/Cobs.kt

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@ public object Cobs {
5757
return dst.copyOf(writeIndex)
5858
}
5959

60+
/**
61+
* Encodes [input] with basic COBS using an arbitrary [sentinel] byte instead
62+
* of `0x00`, returning a [ByteArray] that never contains the [sentinel] byte.
63+
*
64+
* This runs the ordinary encode and then XORs every output byte with
65+
* [sentinel], so `sentinel` (rather than `0x00`) may be used to delimit
66+
* packets. Choosing a sentinel that rarely occurs in the payload minimises
67+
* overhead. A [sentinel] of `0` is byte-for-byte identical to [encode].
68+
*/
69+
@JvmStatic
70+
public fun encodeWithSentinel(input: ByteArray, sentinel: Byte): ByteArray {
71+
val out = encode(input)
72+
val s = sentinel.toInt() and 0xFF
73+
if (s != 0) {
74+
for (i in out.indices) {
75+
out[i] = (out[i].toInt() xor s).toByte()
76+
}
77+
}
78+
return out
79+
}
80+
6081
/**
6182
* Decodes basic-COBS-encoded [input], returning the original bytes.
6283
*
@@ -107,6 +128,100 @@ public object Cobs {
107128
return out.copyOf(writeIndex)
108129
}
109130

131+
/**
132+
* Decodes basic-COBS [input] that was encoded with an arbitrary [sentinel]
133+
* byte (see [encodeWithSentinel]), returning the original bytes.
134+
*
135+
* A fresh copy of [input] is XORed back with [sentinel] before decoding, so
136+
* the caller's array is never mutated. A [sentinel] of `0` is identical to
137+
* [decode].
138+
*
139+
* @throws CobsDecodeException if [input] contains the [sentinel] byte or a
140+
* length code points past the end of the input.
141+
*/
142+
@JvmStatic
143+
public fun decodeWithSentinel(input: ByteArray, sentinel: Byte): ByteArray {
144+
val s = sentinel.toInt() and 0xFF
145+
if (s == 0) return decode(input)
146+
val src = input.copyOf()
147+
for (i in src.indices) {
148+
src[i] = (src[i].toInt() xor s).toByte()
149+
}
150+
return decode(src)
151+
}
152+
153+
/**
154+
* Decodes basic-COBS data in place, overwriting [buffer] with the decoded
155+
* output and returning its length; the decoded bytes occupy the first `len`
156+
* elements of [buffer].
157+
*
158+
* This needs no output buffer: COBS decoding never expands, so the write
159+
* position always trails the read position.
160+
*
161+
* @throws CobsDecodeException if [buffer] contains a `0x00` byte or a length
162+
* code points past the end of the input.
163+
*/
164+
@JvmStatic
165+
public fun decodeInPlace(buffer: ByteArray): Int = decodeInPlace(buffer, COBS_DELIMITER)
166+
167+
/**
168+
* Decodes basic-COBS data that was encoded with an arbitrary [sentinel] byte
169+
* in place, overwriting [buffer] with the decoded output and returning its
170+
* length. A [sentinel] of `0` is identical to [decodeInPlace].
171+
*
172+
* When [sentinel] is non-zero the buffer is first XORed back to its
173+
* `0x00`-based form in place, then decoded in place.
174+
*
175+
* @throws CobsDecodeException if [buffer] is not valid encoded data.
176+
*/
177+
@JvmStatic
178+
public fun decodeInPlace(buffer: ByteArray, sentinel: Byte): Int {
179+
val srcLen = buffer.size
180+
if (srcLen == 0) return 0
181+
182+
val s = sentinel.toInt() and 0xFF
183+
if (s != 0) {
184+
for (i in 0 until srcLen) {
185+
buffer[i] = (buffer[i].toInt() xor s).toByte()
186+
}
187+
}
188+
189+
var writeIndex = 0
190+
var index = 0
191+
192+
while (true) {
193+
val code = buffer[index].toInt() and 0xFF
194+
if (code == 0) {
195+
throw CobsDecodeException("zero byte in COBS input", index)
196+
}
197+
index++
198+
val blockEnd = index + code - 1
199+
val copyEnd = if (blockEnd < srcLen) blockEnd else srcLen
200+
while (index < copyEnd) {
201+
val b = buffer[index].toInt() and 0xFF
202+
if (b == 0) {
203+
throw CobsDecodeException("zero byte in COBS input", index)
204+
}
205+
// writeIndex < index throughout, so this never clobbers unread input.
206+
buffer[writeIndex++] = b.toByte()
207+
index++
208+
}
209+
if (blockEnd > srcLen) {
210+
throw CobsDecodeException(
211+
"length code points past end of input",
212+
blockEnd - code,
213+
)
214+
}
215+
if (blockEnd < srcLen) {
216+
if (code < 0xFF) buffer[writeIndex++] = 0
217+
} else {
218+
break
219+
}
220+
}
221+
222+
return writeIndex
223+
}
224+
110225
/** See the top-level [encodingOverhead]. */
111226
@JvmStatic
112227
public fun encodingOverhead(sourceLength: Int): Int =

cobs/src/main/kotlin/dev/firechip/cobs/Cobsr.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ public object Cobsr {
6464
return dst.copyOf(writeIndex)
6565
}
6666

67+
/**
68+
* Encodes [input] with COBS/R using an arbitrary [sentinel] byte instead of
69+
* `0x00`, returning a [ByteArray] that never contains the [sentinel] byte.
70+
*
71+
* This runs the ordinary encode and then XORs every output byte with
72+
* [sentinel]. A [sentinel] of `0` is byte-for-byte identical to [encode].
73+
*/
74+
@JvmStatic
75+
public fun encodeWithSentinel(input: ByteArray, sentinel: Byte): ByteArray {
76+
val out = encode(input)
77+
val s = sentinel.toInt() and 0xFF
78+
if (s != 0) {
79+
for (i in out.indices) {
80+
out[i] = (out[i].toInt() xor s).toByte()
81+
}
82+
}
83+
return out
84+
}
85+
6786
/**
6887
* Decodes COBS/R-encoded [input], returning the original bytes. The empty
6988
* input decodes to an empty array.
@@ -108,4 +127,25 @@ public object Cobsr {
108127

109128
return out.copyOf(writeIndex)
110129
}
130+
131+
/**
132+
* Decodes COBS/R [input] that was encoded with an arbitrary [sentinel] byte
133+
* (see [encodeWithSentinel]), returning the original bytes.
134+
*
135+
* A fresh copy of [input] is XORed back with [sentinel] before decoding, so
136+
* the caller's array is never mutated. A [sentinel] of `0` is identical to
137+
* [decode].
138+
*
139+
* @throws CobsDecodeException if [input] contains the [sentinel] byte.
140+
*/
141+
@JvmStatic
142+
public fun decodeWithSentinel(input: ByteArray, sentinel: Byte): ByteArray {
143+
val s = sentinel.toInt() and 0xFF
144+
if (s == 0) return decode(input)
145+
val src = input.copyOf()
146+
for (i in src.indices) {
147+
src[i] = (src[i].toInt() xor s).toByte()
148+
}
149+
return decode(src)
150+
}
111151
}

cobs/src/main/kotlin/dev/firechip/cobs/Framing.kt

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,38 @@ public object CobsFraming {
1717

1818
/**
1919
* Encodes [packet] (with COBS/R when [reduced] is true, otherwise basic
20-
* COBS) and appends the [COBS_DELIMITER], producing a self-delimiting frame.
20+
* COBS) and appends the delimiter, producing a self-delimiting frame.
21+
*
22+
* [sentinel] selects the delimiter byte (and the byte the encoding avoids);
23+
* it defaults to the `0x00` [COBS_DELIMITER]. The packet is encoded with the
24+
* same [sentinel], so the output never contains it before the trailing
25+
* delimiter.
2126
*/
2227
@JvmStatic
2328
@JvmOverloads
24-
public fun frame(packet: ByteArray, reduced: Boolean = false): ByteArray {
25-
val encoded = if (reduced) Cobsr.encode(packet) else Cobs.encode(packet)
26-
// copyOf pads the extra byte with 0x00, which is the delimiter.
27-
return encoded.copyOf(encoded.size + 1)
29+
public fun frame(
30+
packet: ByteArray,
31+
reduced: Boolean = false,
32+
sentinel: Byte = COBS_DELIMITER,
33+
): ByteArray {
34+
val encoded = if (reduced) {
35+
Cobsr.encodeWithSentinel(packet, sentinel)
36+
} else {
37+
Cobs.encodeWithSentinel(packet, sentinel)
38+
}
39+
val framed = encoded.copyOf(encoded.size + 1)
40+
framed[encoded.size] = sentinel
41+
return framed
2842
}
2943

3044
/**
31-
* Splits [data] on the [COBS_DELIMITER] and decodes each frame, returning
32-
* the recovered packets. Trailing bytes after the final delimiter are
33-
* ignored. Empty frames are skipped when [skipEmpty] is true.
45+
* Splits [data] on the delimiter and decodes each frame, returning the
46+
* recovered packets. Trailing bytes after the final delimiter are ignored.
47+
* Empty frames are skipped when [skipEmpty] is true.
48+
*
49+
* [sentinel] selects the delimiter byte (and the byte each frame was encoded
50+
* to avoid); it defaults to the `0x00` [COBS_DELIMITER] and must match the
51+
* sentinel used to frame.
3452
*
3553
* @throws CobsDecodeException if a complete frame is not valid encoded data.
3654
*/
@@ -40,16 +58,23 @@ public object CobsFraming {
4058
data: ByteArray,
4159
reduced: Boolean = false,
4260
skipEmpty: Boolean = true,
61+
sentinel: Byte = COBS_DELIMITER,
4362
): List<ByteArray> {
4463
val frames = ArrayList<ByteArray>()
4564
var start = 0
4665
for (i in data.indices) {
47-
if (data[i].toInt() != 0) continue
66+
if (data[i] != sentinel) continue
4867
if (i == start) {
4968
if (!skipEmpty) frames.add(ByteArray(0))
5069
} else {
5170
val slice = data.copyOfRange(start, i)
52-
frames.add(if (reduced) Cobsr.decode(slice) else Cobs.decode(slice))
71+
frames.add(
72+
if (reduced) {
73+
Cobsr.decodeWithSentinel(slice, sentinel)
74+
} else {
75+
Cobs.decodeWithSentinel(slice, sentinel)
76+
},
77+
)
5378
}
5479
start = i + 1
5580
}
@@ -58,22 +83,27 @@ public object CobsFraming {
5883
}
5984

6085
/**
61-
* A stateful, incremental decoder for a stream of [COBS_DELIMITER]-framed data,
62-
* for reading COBS packets from a serial/UART link where bytes arrive in
63-
* arbitrarily sized chunks that do not align with frame boundaries.
86+
* A stateful, incremental decoder for a stream of delimiter-framed data, for
87+
* reading COBS packets from a serial/UART link where bytes arrive in arbitrarily
88+
* sized chunks that do not align with frame boundaries.
6489
*
6590
* Feed raw bytes with [feed]; it returns any packets completed by a delimiter,
6691
* buffering the rest until a later call. [maxFrameLength] (when greater than 0)
6792
* bounds how many bytes are buffered for a single unterminated frame, guarding
6893
* against unbounded memory use on a noisy link that never sends the delimiter.
6994
* A frame that fails to decode is passed to [onInvalidFrame] if provided
7095
* (decoding then continues), otherwise the [CobsDecodeException] is thrown.
96+
*
97+
* [sentinel] selects the delimiter byte (and the byte each frame was encoded to
98+
* avoid); it defaults to the `0x00` [COBS_DELIMITER] and must match the sentinel
99+
* used to frame the stream.
71100
*/
72101
public class CobsStreamDecoder @JvmOverloads constructor(
73102
private val reduced: Boolean = false,
74103
private val skipEmpty: Boolean = true,
75104
private val maxFrameLength: Int = 0,
76105
private val onInvalidFrame: ((CobsDecodeException, ByteArray) -> Unit)? = null,
106+
private val sentinel: Byte = COBS_DELIMITER,
77107
) {
78108
// Concatenation allocates a fresh array, so buffered bytes never alias a
79109
// caller-supplied chunk that may be reused for the next read.
@@ -86,7 +116,7 @@ public class CobsStreamDecoder @JvmOverloads constructor(
86116
val out = ArrayList<ByteArray>()
87117
var start = 0
88118
for (i in chunk.indices) {
89-
if (chunk[i].toInt() != 0) continue
119+
if (chunk[i] != sentinel) continue
90120
val frame = buffer + chunk.copyOfRange(start, i)
91121
buffer = ByteArray(0)
92122
start = i + 1
@@ -95,7 +125,13 @@ public class CobsStreamDecoder @JvmOverloads constructor(
95125
continue
96126
}
97127
try {
98-
out.add(if (reduced) Cobsr.decode(frame) else Cobs.decode(frame))
128+
out.add(
129+
if (reduced) {
130+
Cobsr.decodeWithSentinel(frame, sentinel)
131+
} else {
132+
Cobs.decodeWithSentinel(frame, sentinel)
133+
},
134+
)
99135
} catch (e: CobsDecodeException) {
100136
val handler = onInvalidFrame ?: throw e
101137
handler(e, frame)

0 commit comments

Comments
 (0)