|
| 1 | +// Copyright (c) 2026 Alexander Salas Bastidas <ajsb85@firechip.dev> |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +package dev.firechip.cobs |
| 5 | + |
| 6 | +import java.io.ByteArrayOutputStream |
| 7 | +import java.io.FilterInputStream |
| 8 | +import java.io.FilterOutputStream |
| 9 | +import java.io.IOException |
| 10 | +import java.io.InputStream |
| 11 | +import java.io.OutputStream |
| 12 | + |
| 13 | +/** |
| 14 | + * An [OutputStream] wrapper that writes whole packets as self-delimiting COBS |
| 15 | + * frames, for pushing packets onto a byte stream such as a serial/UART link. |
| 16 | + * |
| 17 | + * Each [writeFrame] call COBS-encodes a packet (COBS/R when [reduced] is true, |
| 18 | + * otherwise basic COBS) and writes the encoded bytes followed by the [sentinel] |
| 19 | + * delimiter. Because the encoding never emits the sentinel, the delimiter |
| 20 | + * unambiguously ends the frame. This is the streaming counterpart of |
| 21 | + * [CobsFraming.frame]. |
| 22 | + * |
| 23 | + * The class extends [FilterOutputStream], so [flush] and [close] delegate to the |
| 24 | + * wrapped [out] stream; closing this stream closes the underlying one. |
| 25 | + * |
| 26 | + * @param out the underlying stream that framed bytes are written to. |
| 27 | + * @param reduced use COBS/R (reduced) encoding instead of basic COBS. |
| 28 | + * @param sentinel the delimiter byte (and the byte the encoding avoids); defaults |
| 29 | + * to the `0x00` [COBS_DELIMITER] and must match the reader's sentinel. |
| 30 | + */ |
| 31 | +public class CobsFramedOutputStream @JvmOverloads constructor( |
| 32 | + out: OutputStream, |
| 33 | + private val reduced: Boolean = false, |
| 34 | + private val sentinel: Byte = COBS_DELIMITER, |
| 35 | +) : FilterOutputStream(out) { |
| 36 | + |
| 37 | + /** |
| 38 | + * Encodes [packet] and writes the resulting frame — the encoded bytes plus a |
| 39 | + * trailing [sentinel] delimiter — to the underlying stream. |
| 40 | + * |
| 41 | + * The bytes are written straight to the wrapped stream; call [flush] to push |
| 42 | + * them through a buffered stream. An empty [packet] still produces a |
| 43 | + * non-empty frame (a single length byte plus the delimiter). |
| 44 | + * |
| 45 | + * @throws IOException if the underlying stream fails to accept the bytes. |
| 46 | + */ |
| 47 | + @Throws(IOException::class) |
| 48 | + public fun writeFrame(packet: ByteArray) { |
| 49 | + val encoded = if (reduced) { |
| 50 | + Cobsr.encodeWithSentinel(packet, sentinel) |
| 51 | + } else { |
| 52 | + Cobs.encodeWithSentinel(packet, sentinel) |
| 53 | + } |
| 54 | + out.write(encoded) |
| 55 | + out.write(sentinel.toInt()) |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +/** |
| 60 | + * An [InputStream] wrapper that reads self-delimiting COBS frames back into |
| 61 | + * packets, the reading counterpart of [CobsFramedOutputStream]. |
| 62 | + * |
| 63 | + * [readFrame] consumes bytes up to the next [sentinel] delimiter (or end of |
| 64 | + * stream), decodes them (COBS/R when [reduced] is true, otherwise basic COBS) |
| 65 | + * and returns the recovered packet, or `null` once the stream is exhausted. |
| 66 | + * Empty frames — produced by a leading or consecutive delimiter — are skipped |
| 67 | + * when [skipEmpty] is true. [frames] exposes the same reads as a [Sequence]. |
| 68 | + * |
| 69 | + * Bytes are pulled one at a time from the source, so wrap an unbuffered source |
| 70 | + * (a socket or file) in a [java.io.BufferedInputStream] for throughput. The |
| 71 | + * class extends [FilterInputStream], so [close] delegates to the wrapped |
| 72 | + * [source]. |
| 73 | + * |
| 74 | + * @param source the underlying stream framed bytes are read from. |
| 75 | + * @param reduced decode with COBS/R (reduced) instead of basic COBS. |
| 76 | + * @param skipEmpty drop empty frames instead of returning empty arrays. |
| 77 | + * @param sentinel the delimiter byte (and the byte each frame avoids); defaults |
| 78 | + * to the `0x00` [COBS_DELIMITER] and must match the writer's sentinel. |
| 79 | + */ |
| 80 | +public class CobsFramedInputStream @JvmOverloads constructor( |
| 81 | + source: InputStream, |
| 82 | + private val reduced: Boolean = false, |
| 83 | + private val skipEmpty: Boolean = true, |
| 84 | + private val sentinel: Byte = COBS_DELIMITER, |
| 85 | +) : FilterInputStream(source) { |
| 86 | + |
| 87 | + /** |
| 88 | + * Reads and decodes the next frame, returning the recovered packet, or `null` |
| 89 | + * at end of stream. |
| 90 | + * |
| 91 | + * Bytes are read until the [sentinel] delimiter or end of stream; the bytes |
| 92 | + * before it form the frame. A frame that ends at end of stream without a |
| 93 | + * delimiter (a truncated tail) is still decoded and returned. Empty frames |
| 94 | + * are skipped while [skipEmpty] is true, otherwise each yields an empty array. |
| 95 | + * |
| 96 | + * @throws IOException if the underlying stream fails. |
| 97 | + * @throws CobsDecodeException if a complete frame is not valid encoded data. |
| 98 | + */ |
| 99 | + @Throws(IOException::class) |
| 100 | + public fun readFrame(): ByteArray? { |
| 101 | + while (true) { |
| 102 | + val first = `in`.read() |
| 103 | + if (first == -1) return null |
| 104 | + if (first.toByte() == sentinel) { |
| 105 | + // Empty frame from a leading or consecutive delimiter. |
| 106 | + if (skipEmpty) continue |
| 107 | + return ByteArray(0) |
| 108 | + } |
| 109 | + val buffer = ByteArrayOutputStream() |
| 110 | + buffer.write(first) |
| 111 | + while (true) { |
| 112 | + val b = `in`.read() |
| 113 | + if (b == -1 || b.toByte() == sentinel) break |
| 114 | + buffer.write(b) |
| 115 | + } |
| 116 | + return decodeFrame(buffer.toByteArray()) |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + /** |
| 121 | + * Returns a [Sequence] that yields each frame from [readFrame] until the |
| 122 | + * stream is exhausted. Iterating the sequence drives the same underlying |
| 123 | + * reads, so it is single-pass. |
| 124 | + */ |
| 125 | + public fun frames(): Sequence<ByteArray> = generateSequence { readFrame() } |
| 126 | + |
| 127 | + private fun decodeFrame(frame: ByteArray): ByteArray = |
| 128 | + if (reduced) { |
| 129 | + Cobsr.decodeWithSentinel(frame, sentinel) |
| 130 | + } else { |
| 131 | + Cobs.decodeWithSentinel(frame, sentinel) |
| 132 | + } |
| 133 | +} |
0 commit comments