SLIP and COBS byte stuffing with streaming decoders, so a UART byte stream carries discrete packets. One capability of pamoja, one memory-safe Rust core with bindings for TypeScript, Python, and C#.
npm install @pamoja/serial
This pulls in @pamoja/native, the compiled engine. npm install pamoja is the whole framework in one package.
The test that runs in CI, spliced here as it ran.
From bindings/node/guides/serial.ts:
import {
COBS_DELIMITER_BYTE,
SLIP_END_BYTE,
SLIP_ESC_BYTE,
SlipDecoder,
cobs,
slip,
} from '@pamoja/serial'
// A UART carries bytes, not packets, so a framing has to mark where one packet ends.
// SLIP reserves two byte values for that, and the package names both: the end byte closes
// a frame, the escape byte carries a value that would otherwise look like one.
const payload = Buffer.concat([Buffer.from('lvl='), Buffer.from([SLIP_END_BYTE, SLIP_ESC_BYTE])])
const framed = slip.encode(payload)
console.log(`slip ${payload.length} payload bytes framed as ${framed.length}`)
// Decoding gives the payload back unchanged, reserved bytes and all.
const restored = slip.decode(framed)
console.log(`slip decoded back to ${restored.length} bytes`)
// COBS trades that escaping for one code byte per run of up to 254 non-zero bytes, each
// run led by its own length, so a frame never grows by more than a byte per 254. Zero is
// the delimiter, and never appears inside a frame.
const packet = Buffer.concat([Buffer.from('lvl='), Buffer.from([COBS_DELIMITER_BYTE]), Buffer.from('7')])
const cobsFramed = cobs.encode(packet)
console.log(`cobs ${packet.length} payload bytes framed as ${cobsFramed.length}`)
// A read from a port returns whatever arrived, which is rarely one whole frame. This
// chunk holds two good frames with a truncated one between them; the decoder hands over
// the good ones and discards only the bad frame.
const decoder = new SlipDecoder()
const chunk = Buffer.concat([
Buffer.from('ok'),
Buffer.from([SLIP_END_BYTE]),
Buffer.from([SLIP_ESC_BYTE]), // a frame that ends before its escape pair completes
Buffer.from([SLIP_END_BYTE]),
Buffer.from('go'),
Buffer.from([SLIP_END_BYTE]),
])
const frames = decoder.feed(chunk)
for (const frame of frames) {
console.log(`received ${frame.toString()}`)
}
console.log(`discarded ${decoder.discarded} frame the stream mangled`)
| Language | Package | Reference |
|---|---|---|
| Rust | pamoja-serial |
reference, docs.rs, install |
| TypeScript | @pamoja/serial |
reference, install |
| Python | pamoja-serial |
reference, install |
| C# | Pamoja.Serial |
reference, install |
@pamoja/serial reference, every class, function, and type this package exports.MIT
Ergonomic facade over the generated serial-framing binding.
A serial line is a stream of bytes with no packet boundaries, so something has to mark where one message ends and the next begins. SLIP and COBS are the two ways to do that, and each is offered both as a one-shot call over a complete frame and as a streaming decoder for the arbitrary chunks a port delivers.
The streaming decoders are what a real read loop uses. A corrupt frame does not throw, because the frames around it are still good; it is dropped and counted on SlipDecoder.discarded.