Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
armadsen committed Sep 4, 2023
0 parents commit 69904ba
Show file tree
Hide file tree
Showing 6 changed files with 361 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2023 Andrew R. Madsen

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
21 changes: 21 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "GlassGem",
products: [
.library(
name: "GlassGem",
targets: ["GlassGem"]),
],
targets: [
.target(
name: "GlassGem",
dependencies: []),
.testTarget(
name: "GlassGemTests",
dependencies: ["GlassGem"]),
]
)
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# GlassGem

GlassGem is a Swift package that implements the [Consistent Overhead Byte Stuffing (COBS)](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing) algorithm for encoding arbitrary data with single byte packet delimiters.

It consists of an extension on `Data` with exactly two methods: `encodedUsingCOBS()` and `decodedFromCOBS()`.

## Usage

Encoding:

```swift
let someData = ...
let cobsEncodedData = someData.encodedUsingCOBS()
// Do something with cobsEncodedData, e.g. sending across a communications link
```

Decoding:
```swift
let someCOBSEncodeData = ... // e.g. from a communications link
let someData = someData.decodedFromCOBS()
// Use someData like normal
```

The package includes a suite of unit tests.

## Installation

To use the `GlassGem` library in a SwiftPM project,
add the following line to the dependencies in your `Package.swift` file:

```swift
.package(url: "https://github.com/armadsen/GlassGem", from: "1.0.0"),
```

Include `"GlassGem"` as a dependency for your executable target:

```swift
.target(name: "<target>", dependencies: [
.product(name: "GlassGem", package: "GlassGem"),
]),
```

Finally, add `import GlassGem` to your source code.

## To Do

GlassGem is already completely usable for the most common scenarios. However, there are a few things I'd like to implement in the future. Pull requests for these are completely welcome. Please include tests for anything you add.

- [ ] Support for using GlassGem from Objective-C
- [ ] Support for arbitrary delimiter bytes, not just 0x00
- [ ] Performance improvements (while still emphasizing readability)
134 changes: 134 additions & 0 deletions Sources/GlassGem/Data+COBS.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//
// Data+Cobs.swift
//
// Created by Andrew R Madsen on 9/2/23.
//
// MIT License
//
// Copyright (c) 2023 Andrew R. Madsen
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import Foundation

/// This extension provides methods for encoding and decoding data using the Consistent Overhead Byte Stuffing (COBS)
/// algorithm. COBS is way to packetize data for transmission over possibly error-prone communication links. It is very efficient/lower overhead
/// using the zero (0x00) byte as a packet delimiter, and replacing zeros in the actual data being sent with minimal replacement data.
/// For more information, see https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing. This implementation
/// prioritizes readibility over performance, as it is used (by the author) for very small amounts of data.
public extension Data {


/// Encode the receiver using the COBS algorithm. The result will always end in a zero (0x00) byte, which is the packet
/// delimiter under standard COBS.
/// - Returns: A new Data object containing the receiver's data encoded using COBS.
func encodedUsingCOBS() -> Data {
// Here we're using the 'Prefixed block description' encoding algorithm
var scratch = self

// 1. Append a zero byte
scratch.append(0)

var groups = [Data]()
var currentGroup = Data()
var currentCount: UInt8 = 0

// 2. Break into groups of either 254 non-zero bytes or 0-253 non-zero bytes followed by a zero byte
for byte in scratch {
currentGroup.append(byte)
// If we see a zero byte, or we're up to 253 non-zero bytes, we've finished a group
if byte == 0 || currentCount >= 253 {
groups.append(currentGroup)
currentGroup = Data()
currentCount = 0
} else { // Otherwise, just keep building the current group
currentCount += 1
}
}

// 3. Prepend the number of non-zero bytes plus one to each group
groups = groups.map { group in
var scratch = group
if scratch[scratch.endIndex.advanced(by: -1)] == 0x00 {
scratch.removeLast()
}
scratch.insert(UInt8(scratch.count+1), at: 0)
return scratch
}

// 4. Concatenate all encoded groups
var result = Data(groups.flatMap { $0 })
// 5. Append a final trailing zero, which is the packet delimiter
result.append(0)

return result
}

/// Decode the receiver using the COBS algorithm. Calling this on data that is not COBS encoded will
/// produce surprising results.
/// - Returns: An array of individual Data objects each representing a packet in the original COBS encoded receiver. Note that the packet delimiter zero bytes will be removed from each packet.
func decodedFromCOBS() -> [Data] {
// 1. Split the data into invidual packets, which are separated by zero bytes
let rawPackets = self.split(separator: 0)
var result = [Data]()
result.reserveCapacity(rawPackets.count)

// 2. Loop through each packet
for var packet in rawPackets {
var groupHeaderIndexesToRemove = [Data.Index]()

// 3. The first byte is always the index of the first zero byte
var nextZeroOffset = Int(packet[packet.startIndex])
var nextZeroAddress = packet.startIndex.advanced(by: nextZeroOffset)
// 4. Continue walking to each zero byte location as indicated by the value of the
// last zero byte location
while true {
// If we're at the end of the packet, finish up
if nextZeroAddress >= packet.endIndex {
break
}
// 5. Replace each zero byte offset with the original zero value
// But only if it's a group header (ie. we're at the end of a full non-zero group)
if nextZeroOffset < 255 {
// The offest to the next zero byte is the value here
nextZeroOffset = Int(packet[nextZeroAddress])
packet[nextZeroAddress] = 0
} else {
// The offest to the next zero byte is the value here
nextZeroOffset = Int(packet[nextZeroAddress])
// Otherwise just remove it, because it's a group header
groupHeaderIndexesToRemove.append(nextZeroAddress)
}
// Get the address of the next zero byte using the offset and current location
nextZeroAddress = nextZeroAddress.advanced(by: nextZeroOffset)
}

// 6. Remove all group header addresses
for index in groupHeaderIndexesToRemove.reversed() {
packet.remove(at: index)
}

// 7. Remove the first byte which is never part of the original (see step 3 in encoding algorithm)
packet.removeFirst()
result.append(packet)
}

return result
}
}
125 changes: 125 additions & 0 deletions Tests/GlassGemTests/GlassGemTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//
// GlassGemTests.swift
//
// Created by Andrew R Madsen on 9/2/23.
//
// MIT License
//
// Copyright (c) 2023 Andrew R. Madsen
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import XCTest
@testable import GlassGem

final class GlassGemTests: XCTestCase {

let baseTestTable: [[UInt8] : [UInt8]] = [
[0x01] : [0x02, 0x01, 0x00],
[0x00] : [0x01, 0x01, 0x00],
[0x00, 0x00] : [0x01, 0x01, 0x01, 0x00],
[0x00, 0x11, 0x00] : [0x01, 0x02, 0x11, 0x01, 0x00],
[0x11, 0x22, 0x00, 0x33] : [0x03, 0x11, 0x22, 0x02, 0x33, 0x00],
[0x11, 0x22, 0x33, 0x44] : [0x05, 0x11, 0x22, 0x33, 0x44, 0x00],
[0x11, 0x00, 0x00, 0x00] : [0x02, 0x11, 0x01, 0x01, 0x01, 0x00],
]

func testEncoding() {
for (original, encoded) in baseTestTable {
let encodedOriginal = Data(original).encodedUsingCOBS()
XCTAssertEqual(encodedOriginal, Data(encoded), "Encoding failed for \(original)). Expected \(encoded). Got \([UInt8](encodedOriginal)).")
}
}

func testDecoding() {
for (original, encoded) in baseTestTable {
let decodedResults = Data(encoded).decodedFromCOBS()
XCTAssertEqual(decodedResults.count, 1)
let decodedResult = decodedResults[0]
XCTAssertEqual(decodedResult, Data(original), "Decoding failed for \(encoded)). Expected \(original). Got \([UInt8](decodedResult)).")
}
}

func testRoundTrip() {
for original in baseTestTable.keys {
let encoded = Data(original).encodedUsingCOBS()
let decoded = encoded.decodedFromCOBS()[0]
XCTAssertEqual(Data(original), decoded, "Round trip encode/decode failed for \(original)). Expected \(original). Got \([UInt8](decoded)).")
}
}

func testDecodingMultiplePacketsInOneData() {
let allOriginals = baseTestTable.keys.map { Data($0) }
let allEncoded = allOriginals.map { $0.encodedUsingCOBS() }
let multiplePackets = Data(allEncoded.joined())
let decoded = multiplePackets.decodedFromCOBS()
XCTAssertEqual(allOriginals, decoded)
}

func testPacketSizesNearGroupBoundaries() {
for i in 1...10 {
let original = Data.random(byteCount: i * 254)
let encoded = original.encodedUsingCOBS()
let decoded = encoded.decodedFromCOBS()[0]
XCTAssertEqual(original, decoded, "Round trip encode/decode failed for random \([UInt8](original)). Expected \([UInt8](original)). Got \([UInt8](decoded)).")
}
}

func testLongZeroRuns() {
for i in 1...10 {
let basePacket = Data.random(byteCount: 50)
let zeros = Data(repeating: 0, count: i*50)
let original = basePacket + zeros + basePacket
let encoded = original.encodedUsingCOBS()
let decoded = encoded.decodedFromCOBS()[0]
XCTAssertEqual(original, decoded, "Round trip encode/decode failed for random \([UInt8](original)). Expected \([UInt8](original)). Got \([UInt8](decoded)).")
}
}

func testLongNonZeroRuns() {
for i in 1...10 {
let basePacket = Data.random(byteCount: 50)
let nonZero = Data.random(byteCount: i*50, allowZero: false)
let original = basePacket + nonZero + basePacket
let encoded = original.encodedUsingCOBS()
let decoded = encoded.decodedFromCOBS()[0]
XCTAssertEqual(original, decoded, "Round trip encode/decode failed for random \([UInt8](original)). Expected \([UInt8](original)). Got \([UInt8](decoded)).")
}
}

func testLargeRandomPackets() {
for _ in 0..<1000 {
let original = Data.random(byteCount: Int.random(in: 0...10000))
let encoded = original.encodedUsingCOBS()
let decoded = encoded.decodedFromCOBS()[0]
XCTAssertEqual(original, decoded, "Round trip encode/decode failed for random \([UInt8](original)). Expected \([UInt8](original)). Got \([UInt8](decoded)).")
}
}
}

private extension Data {
static func random(byteCount: Int, allowZero: Bool = true) -> Data {
var result = Data(capacity: byteCount)
let range: ClosedRange<UInt8> = allowZero ? 0...255 : 1...255
for _ in 0..<byteCount {
result.append(UInt8.random(in: range))
}
return result
}
}

0 comments on commit 69904ba

Please sign in to comment.