Ken Muse

Creating an Int24 for iOS


This is a post in the series Building a Workout Fitness App for watchOS in Swift. The posts in this series include:

In previous posts, we started to explore the basics of building a Bluetooth app for iOS. Many of the basic data types used for transferring the data exist natively on iOS. Unfortunately, one that is needed for the workout app is missing: a 24-bit unsigned integer. This post will explore how to create a custom integer data type in Swift.

There’s not a lot of information available on building out numeric primitives, so perhaps this post will help fill that gap. We are in luck for a key part of the process – the major computer platforms we are working with all use little endian byte ordering. This is the same order that we expect to receive bytes in from Bluetooth devices. That means that a value such as 0x123456 would be serialized with the least significant byte first: 0x56, 0x34, 0x12.

The Basic Implementation

A very basic implementation could be created that met our minimum requirements. We simply need a data type that can receive and properly handle the three bytes of data for deserialization. At its most basic level, the type justs need to ensure it stores three bytes of data. A naive implementation could be as simple as this:

  1   struct UInt24 { 
  2       let low: UInt16
  3       let high: UInt8
  4       
  5       init(_ low: UInt16, _ high: UInt8){
  6           self.low = word
  7           self.high = byte
  8       }
  9       
 10       var int: intValue { 
 11           return Int(low) + (Int(high)<<16)
 12       }
 13   }

This basic implementation would decode three bytes of data as a word (2 bytes) and a single byte. The ordering is important – the variables are declared from least significant to most significant. This ensures they can be properly decoded from a byte stream. A future post will dive into decoding types, so for now we’ll leave it at that.

The intValue property allows those values to be combined the two into a single 24-bit value. The value low would contain the lower bytes, while high would represent the most significant value. In our earlier example of 0x123456, low would receive 0x3456 and high would receive 0x12. The intValue property would then combine them into 0x123456 by shifting high 16 bits to the left.

A Better Implementation

The basic implementation is a good start in terms of being functional, but it is not ideal. The implementation is not compatible with the other numeric data types in Swift. As a result, it limits how it can be used in the language. In fact, the implementation really only works for deserializing the value, then using intValue to make it usable. Let’s change that.

This implementation will rely on existing functionality from UInt32. While we could implement all of the low-level features ourselves, relying on the existing functionality ensures compatibility and simplifies the explanations. For this implementation, we will assume that we can work exclusively with little endian platforms. This is a safe assumption for iOS, macOS, and watchOS.

First, we need the type to declare the type and the protocols that need to be implemented. A numeric data type involves a number of data types that form a hierarchy. I’ll break out the protocols as we go. The basic start:

 1   public struct UInt24: FixedWidthInteger,
 2                         UnsignedInteger,
 3                         CustomReflectable,
 4                         Codable {
 5   }

It may seem like a short list, but each of these extends multiple other protocols, so this has a surprising amount of supporting code. To make it easier, I’ll break out the implementation into sections.

The Values

First, we’ll start with some of the basic values that we’ll need for the rest of the type. This includes the bytes values themselves and some helper methods that make it easier to retrieve the values for other methods.

  1       // The individual bytes that make up the UInt24. It's important
  2       // for these to be in order, least significant to most significant
  3       private let low: UInt8
  4       private let med: UInt8
  5       private let high: UInt8
  6       
  7       /**
  8        The number of bytes represented by the type
  9        */
 10       fileprivate static let byteWidth: Int = 3
 11       
 12       /**
 13        The maximum value for the type (16,777,215).
 14        */
 15       fileprivate static let maxUInt = UInt32(0xFFFFFF)
 16       
 17       /**
 18        Initializes the type with a zero value (default)
 19        */
 20       public init() {
 21           low = 0
 22           med = 0
 23           high = 0
 24       }
 25   
 26       /**
 27        Initializes the type using a UInt24
 28        - Parameter value: The value to use for initialization
 29        */
 30       public init(_ value: UInt24) {
 31           low = value.low
 32           med = value.med
 33           high = value.high
 34       }
 35   
 36        /**
 37        Initializes the type from the individual bytes
 38        - Parameters:
 39          - low: The least-significant byte
 40          - med: The middle byte
 41          - high: The most-significant byte
 42        */
 43       fileprivate init(low: UInt8, med: UInt8, high: UInt8)
 44       {
 45           self.low = low
 46           self.med = med
 47           self.high = high
 48       }
 49       
 50       /**
 51        Initializes the type from an array of bytes with the least significant bytes first
 52        - Parameter bytes: The array of bytes to use for initialization
 53        - Precondition: The array should have 3 bytes
 54        */
 55       public init(_ bytes: [UInt8]){
 56           precondition(bytes.count == UInt24.byteWidth,
 57                        "Expected \(UInt24.byteWidth) elements but received \(bytes.count)")
 58           self.init(low: bytes[0],
 59                     med: bytes[1],
 60                     high: bytes[2])
 61       }
 62       
 63       /// Gets the byte representation for the type
 64       fileprivate var bytes : [UInt8] {
 65           return [ low, med, high ]
 66       }
 67       
 68       /// Gets the value of the type as a UInt
 69       public var uintValue: UInt {
 70           let result = UInt(high) << 16 + UInt(med) << 8 + UInt(low)
 71           return result;
 72       }
 73       
 74       /// Gets the value of the type as an Int
 75       public var intValue: Int {
 76           let result = Int(high) << 16 + Int(med) << 8 + Int(low)
 77           return result;
 78       }

ExpressibleByIntegerLiteral

This protocol is surprisingly simple, relying on an associated type that is used to also define the type of the integer literal. This implementation relies on another initializer that will be developed shortly. This protocol is extended by Numeric.

  1       // Associated type that will be used for the integerLiteral initializer
  2       // For this, we'll use an available native type
  3       public typealias IntegerLiteralType = UInt
  4           
  5       /**
  6        Initializes the type from an integer literal
  7        - Parameter value: The value to use for initialization
  8        */
  9       public init(integerLiteral value: UInt) {
 10           self.init(value)
 11       }

AdditiveArithmetic

Now we start with the actual base of a numeric type, AdditiveArithmetic. This protocol extends Equatable. As a result, it provides support for basic addition, subtraction, and equality operations. The implementation of the protocol will primarily rely on our upcoming implementation of FixedWidthInteger. This makes it easier to catch overflows that may occur. For equality, we’ll rely on reading the bytes in order and comparing them.

  1       /// A constant value of zero
  2       public static var zero : UInt24 = UInt24()
  3       
  4       public static func - (lhs: UInt24, rhs: UInt24) -> UInt24 {
  5           let result = lhs.subtractingReportingOverflow(rhs)
  6           guard !result.overflow else { 
  7               fatalError("Overflow") 
  8           }
  9           return result.partialValue
 10       }
 11       
 12       public static func + (lhs: UInt24, rhs: UInt24) -> UInt24 {
 13           let result = lhs.addingReportingOverflow(rhs)
 14           guard !result.overflow else { 
 15               fatalError("Overflow") 
 16           }
 17           return result.partialValue
 18       }
 19       
 20       public static func -= (lhs: inout UInt24, rhs: UInt24) {
 21           let result = lhs.subtractingReportingOverflow(rhs)
 22           guard !result.overflow else { 
 23               fatalError("Overflow") 
 24           }
 25           lhs = result.partialValue
 26       }
 27       
 28       public static func += (lhs: inout UInt24, rhs: UInt24) {
 29           let result = lhs.addingReportingOverflow(rhs)
 30           guard !result.overflow else { 
 31               fatalError("Overflow") 
 32           }
 33           lhs = result.partialValue
 34       }
 35   
 36       public static func == (lhs: UInt24, rhs: UInt24) -> Bool {
 37           return lhs.bytes == rhs.bytes
 38       }
 39       
 40       public static func != (lhs: UInt24, rhs: UInt24) -> Bool {
 41           !(lhs == rhs)
 42       }

Numeric

This protocol extends AdditiveArithmetic and ExpressibleByIntegerLiteral to add support for multiplication and magnitude (absolute value). Since this type won’t have a sign (always positive), the magnitude will always be the value itself. This protocol also adds an initializer for an exact value; it returns nil if the type cannot represented as a UInt24. Similar to before, we’ll rely on the FixedWithInteger implementation for multiplication and overflow handling.

  1       // Associated type that will be used to return the magnitude
  2       public typealias Magnitude = UInt24
  3   
  4       /**
  5        Creates a new instance from the given integer, if it can be represented exactly.
  6        - Parameter source: A value to convert to UInt24
  7        */
  8       public init?<T>(exactly source: T) where T : BinaryInteger {
  9           guard source >= 0 && source <= UInt24.maxUInt else {
 10               return nil
 11           }
 12           
 13           self.init(source)
 14       }
 15       
 16       public var magnitude: UInt24 {
 17           return self
 18       }
 19       
 20       public static func *= (lhs: inout UInt24, rhs: UInt24) {
 21           let result = lhs.multipliedReportingOverflow(by: rhs)
 22           guard !result.overflow else { 
 23               fatalError("Overflow")
 24           }
 25           lhs = result.partialValue
 26       }
 27       
 28       public static func * (lhs: UInt24, rhs: UInt24) -> UInt24 {
 29           let result = lhs.multipliedReportingOverflow(by: rhs)
 30           guard !result.overflow else {
 31               fatalError("Overflow") 
 32           }
 33           return result.partialValue
 34       }

BinaryInteger

The BinaryInteger protocol is a basis for all integer types. It builds on Numeric, but adds Convertible and Hashable. This is the largest protocol to implement, containing support for division, remainder, and bit operations. Because these operations are intended to work across binary integer types, we can utilize UInt32 to support them, just being mindful that the limits of this type are lower.

The protocol also introduces a collection containing the words of the binary representation, from least significant to most significant. The type needs to conform to RandomAccessCollection. This type isn’t using the classical definition of a “word” (2 bytes). Instead, it assumes “machine words”: UInt32 on 32-bit platforms and UInt64 on 64-bit platforms. Since the UInt24 is smaller than either of these, it will only have one word. As a result, we can use the helper property that was created earlier, uintValue to return the correct value.

  1       /// Gets the words for the type
  2       public var words: Words { UInt24.Words(self) }
  3       
  4       public struct Words: RandomAccessCollection {
  5           public typealias Element = UInt
  6           public typealias Index = Int
  7           public typealias SubSequence = Slice<Self>
  8           public typealias Indices = Range<Int>
  9           
 10           public var startIndex: Int { 0 }
 11           public var endIndex: Int { 1 }
 12           public var value: UInt24
 13   
 14           /**
 15             Initializes the collection with the value.
 16             - Parameter value: The value to use
 17           **/
 18           public init(_ value: UInt24) {
 19               self.value = value
 20           }
 21           
 22           public subscript(position: Int) -> UInt {
 23               get {
 24                   precondition(position >= 0 && position < count)
 25                   return value.uintValue
 26               }
 27           }
 28       }

At this point, we’ll rely on UInt32 to do the heavy lifting. The key to making that work is limiting the results to only values allowed by UInt24. The operations could be handled directly on the bytes, but the overhead of using UInt32 is minimal. Implementing the operations by hand is left as an exercise for the reader (if you find value in working through that).

Some of the functionality is worth explaining a bit further. The truncatingIfNeeded initializer ensures that only the lowest 24 bits of a BinaryInteger are considered when initializing a UInt24. Everything else is ignored (truncated). For example, initializing with the 32-bit value 0x12345678 would result in a UInt24 with the value 0x345678. Notice the first byte was truncated since it was beyond the 24-bit limit. It’s important to also know that negative numbers are bit-extended. As an example, -21 is represented as a UInt8 in binary as 11101011 (0xEB). Larger types will add more leading 1’s. Extending this until we have 24 bits, it becomes 0xFFFFEB (binary 11111111 11111111 11101011).

The clamping functionality, by comparison, ensures that all values remain within the range of UInt24. If the value is too large to be represented, the maximum value of UInt24 is used. Similarly, if the value is negative it is clamped to zero.

  1    /**
  2     Initializes the type from a BinaryInteger value, truncating if necessary
  3     - Parameter value: The value to use for initialization
  4    **/
  5    public init<T>(_ value: T) where T : BinaryInteger {
  6        precondition(value.signum() > -1, "Cannot assign negative value to unsigned type")
  7        precondition(value <= UInt24.maxUInt, "Not enough bits to represent value")
  8        self.init(truncatingIfNeeded: value)
  9    }
  10       
  11       /**
  12        Initializes the type from a BinaryInteger value, truncating if necessary
  13        - Parameter value: The value to use for initialization
  14       **/
  15       public init<T: BinaryInteger>(truncatingIfNeeded source: T) {
  16            if let word = source.words.first {
  17                // Can only handle a single word for UInt24, so we don't
  18                // need to worry about the higher words. Remember these are
  19                // returned with the lowest byte first.
  20                let low = UInt8(word & 0xFF)
  21                let med = UInt8((word >> 8) & 0xFF)
  22                let high = UInt8((word >> 16) & 0xFF)
  23                self.init(low: low, med: med, high: high)
  24            }
  25            else {
  26                self = 0
  27            }
  28        }
  29       
  30       /**
  31        Initializes the type from a BinaryInteger value, with out of range values clamped to the nearest representable value
  32        - Parameter value: The value to use for initialization
  33       **/
  34       public init<T>(clamping source: T) where T: BinaryInteger {
  35           guard let value = Self(exactly: source) else {
  36               self = source < .zero ? .zero : .max
  37               return
  38           }
  39           self = value
  40       }
  41       
  42       public var trailingZeroBitCount: Int {
  43           return self.uintValue.trailingZeroBitCount
  44       }
  45       
  46       public static func /= (lhs: inout UInt24, rhs: UInt24) {
  47           lhs = lhs/rhs
  48       }
  49       
  50       public static func / (lhs: UInt24, rhs: UInt24) -> UInt24 {
  51           let result = lhs.dividedReportingOverflow(by: rhs)
  52           guard !result.overflow else {
  53               fatalError("Overflow")
  54           }
  55           return result.partialValue
  56       }
  57       
  58       public static func % (lhs: UInt24, rhs: UInt24) -> UInt24 {
  59           let result = lhs.remainderReportingOverflow(dividingBy: rhs)
  60           guard !result.overflow else {
  61               fatalError("Overflow")
  62           }
  63           return result.partialValue
  64       }
  65       
  66       public static func %= (lhs: inout UInt24, rhs: UInt24) {
  67           lhs = lhs % rhs
  68       }
  69       
  70       public static func &= (lhs: inout UInt24, rhs: UInt24) {
  71           lhs = lhs & rhs
  72       }
  73       
  74       public static func |= (lhs: inout UInt24, rhs: UInt24) {
  75           lhs = lhs | rhs
  76       }
  77       
  78       public static func ^= (lhs: inout UInt24, rhs: UInt24) {
  79           lhs = lhs ^ rhs
  80       }
  81       
  82       public static func == <Other>(lhs: UInt24, rhs: Other) -> Bool where Other : BinaryInteger {
  83           return UInt32(lhs) == rhs
  84       }
  85       
  86       public static func != <Other>(lhs: UInt24, rhs: Other) -> Bool where Other : BinaryInteger {
  87           !(lhs == rhs)
  88       }
  89       
  90       public static func < (lhs: UInt24, rhs: UInt24) -> Bool {
  91           return UInt32(lhs) < UInt32(rhs)
  92       }
  93       
  94       public static func <= (lhs: UInt24, rhs: UInt24) -> Bool {
  95           return UInt32(lhs) <= UInt32(rhs)
  96       }
  97       
  98       public static func >= (lhs: UInt24, rhs: UInt24) -> Bool {
  99           return UInt32(lhs) >= UInt32(rhs)
 100       }
 101       
 102       public static func < <Other>(lhs: UInt24, rhs: Other) -> Bool where Other : BinaryInteger {
 103           return UInt32(lhs) < UInt32(rhs)
 104       }
 105       
 106       public static func > (lhs: UInt24, rhs: UInt24) -> Bool {
 107           return UInt32(lhs) > UInt32(rhs)
 108       }
 109       
 110       public static func > <Other>(lhs: UInt24, rhs: Other) -> Bool where Other : BinaryInteger {
 111           return UInt32(lhs) > rhs
 112       }
 113       
 114       /**
 115        Helper function to perform a byte-safe bitwise operation on two UInt24 values
 116        - Parameter lhs: The left hand side of the operation
 117        - Parameter rhs: The right hand side of the operation
 118        - Parameter operation: A function to perform on each byte of the two values
 119        - Returns: The result of the operation
 120       **/
 121       private static func bitOperations(lhs: UInt24, rhs: UInt24, using operation: (UInt8, UInt8) -> UInt8) -> UInt24 {
 122           var lhsBytes = lhs.bytes
 123           let rhsBytes = rhs.bytes
 124           for i in 0..<lhsBytes.count {
 125               lhsBytes[i] = operation(lhsBytes[i], rhsBytes[i])
 126           }
 127           
 128           return UInt24(lhsBytes)
 129       }
 130       
 131       public static func & (lhs: UInt24, rhs: UInt24) -> UInt24  {
 132           return bitOperations(lhs: lhs, rhs: rhs, using: { (lb, rb) in lb & rb })
 133       }
 134       
 135       public static func | (lhs: UInt24, rhs: UInt24) -> UInt24  {
 136           return bitOperations(lhs: lhs, rhs: rhs, using: { (lb, rb) in lb | rb })
 137       }
 138       
 139       public static func ^ (lhs: UInt24, rhs: UInt24) -> UInt24  {
 140           return bitOperations(lhs: lhs, rhs: rhs, using: { (lb, rb) in lb ^ rb })
 141       }
 142       
 143       public static func >>(lhs: UInt24, rhs: UInt24) -> UInt24 {
 144          guard rhs != 0 else { return lhs }
 145          let shifted = UInt32(lhs) >> UInt32(rhs)
 146          return UInt24(shifted & 0x00FFFFFF)
 147       }
 148       
 149       public static func >><Other>(lhs: UInt24, rhs: Other) -> UInt24 where Other : BinaryInteger {
 150           guard rhs != 0 else { return lhs }
 151           return lhs >> UInt24(rhs)
 152       }
 153       
 154       public static func << (lhs: UInt24, rhs: UInt24) -> UInt24  {
 155           guard rhs != 0 else { return lhs }
 156           let shifted = UInt32(lhs) << UInt32(rhs)
 157           return UInt24(shifted & 0x00FFFFFF)
 158       }
 159       
 160       public static func <<<Other>(lhs: UInt24, rhs: Other) -> UInt24 where Other : BinaryInteger {
 161           guard rhs != 0 else { return lhs }
 162           return lhs << UInt24(rhs)
 163       }

The last component of this protocol to implement is Hashable. Thankfully, this one is simpler. It just needs the stored values to calculate the value:

 1       public func hash(into hasher: inout Hasher) {
 2           hasher.combine(bytes)
 3       }

FixedWidthInteger

The FixedWidthInteger protocol adds the functionality for mathematics with overflow. This was the basis of many of our implementations above. Once again, we’ll take advantage of the existing implementation in UInt32 to save ourself from implementing the lower-level math. The functions will report an overflow in two cases: the operation returns an overflow indication or the results are outside the range of values UInt24 supports.

  1       public init(_truncatingBits truncatingBits: UInt) {
  2           self.init(truncatingIfNeeded: truncatingBits)
  3       }
  4   
  5       public static let min: UInt24 = 0
  6       public static let max: UInt24 = UInt24(maxUInt)
  7       public static let bitWidth: Int = 24
  8       
  9       public func addingReportingOverflow(_ rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
 10           let result = UInt32(self).addingReportingOverflow(UInt32(rhs))
 11           return UInt24.reportIfOverflow(result)
 12       }
 13       
 14       public func subtractingReportingOverflow(_ rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
 15           let result = UInt32(self).subtractingReportingOverflow(UInt32(rhs))
 16           return UInt24.reportIfOverflow(result)
 17       }
 18       
 19       public func multipliedReportingOverflow(by rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
 20           let result = UInt32(self).multipliedReportingOverflow(by: UInt32(rhs))
 21           return UInt24.reportIfOverflow(result)
 22       }
 23       
 24       public func dividedReportingOverflow(by rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
 25           let result = UInt32(self).dividedReportingOverflow(by: UInt32(rhs))
 26           return UInt24.reportIfOverflow(result)
 27       }
 28       
 29       public func remainderReportingOverflow(dividingBy rhs: UInt24) -> (partialValue: UInt24, overflow: Bool) {
 30           let result = UInt32(self).remainderReportingOverflow(dividingBy: UInt32(rhs))
 31           return UInt24.reportIfOverflow(result)
 32       }
 33       
 34       public func dividingFullWidth(_ dividend: (high: UInt24, low: UInt24)) -> (quotient: UInt24, remainder: UInt24) {
 35           let result = UInt32(self).dividingFullWidth((high: UInt32(dividend.high), low: UInt32(dividend.low)))
 36           return (quotient: UInt24(result.quotient), remainder: UInt24(result.remainder))
 37       }
 38       
 39       private static func reportIfOverflow(_ result: (partialValue: UInt32, overflow: Bool)) -> (partialValue: UInt24, overflow: Bool) {
 40           let overflow = result.overflow || result.partialValue > UInt24.max || result.partialValue < UInt24.min
 41           let value = UInt24(truncatingIfNeeded: result.partialValue)
 42           return (partialValue: value, overflow: overflow )
 43       }
 44   
 45       public var nonzeroBitCount: Int {
 46           let result = high.nonzeroBitCount + med.nonzeroBitCount + low.nonzeroBitCount
 47           return result
 48       }
 49       
 50       public var leadingZeroBitCount: Int {
 51           let result = (high.leadingZeroBitCount < 8) ? high.leadingZeroBitCount
 52                        : (med.leadingZeroBitCount < 8) ? 8 + med.leadingZeroBitCount
 53                        : 16 + low.leadingZeroBitCount
 54           return result
 55       }   
 56       
 57       /// A representation of this integer with the byte order swapped.
 58       public var byteSwapped: UInt24 {
 59           return UInt24(low: self.high, med: self.med, high: self.low)
 60       }
 61       

UnsignedInteger

This is the easiest protocol to implement. It’s actually covered by the work we’ve done above. As a result, there’s no additional code required. At this point, you have a fully functional numeric type that should be compatible with other Swift numerics. Not too painful, right?

There are still a few protocols we haven’t implemented. These help to improve the experience with using and debugging the data type.

Other protocols

First, we’ll add the function necessary to conform to CustomDebugStringConvertible. This controls how the type is presented when a debug description is requested. The second function we’ll add conforms to CustomReflectable and hides the three bytes we implemented to store the values. Both of these help with the debugging visualization experience.

  1       // MARK: CustomDebugStringConvertible 
  2       /// Gets a diagnostic string containing the type's value
  3       public var debugDescription: String {
  4           return String(uintValue)
  5       }
  6   
  7       // MARK: CustomReflectable
  8       /// Gets mirror for reflecting on the instance, hiding the implementation details
  9       public var customMirror: Mirror {
 10           return Mirror(self, children: EmptyCollection())
 11       }

The last protocol we’ll add is Codable. This provides the support for serialization, allowing the type to be encoded and decoded. This is a simple implementation that just encodes the value using the native Swift functionality. As I mentioned before, this will be covered in more depth in the future. For now, we’ll keep the code simple. It may seem odd to encode/decode as a UInt32, but this avoids a problem with the way the encoders/decoders are implemented in Swift. Under the covers, they don’t know how to handle the custom type, even if it implements the various protocols. Instead, they expect to serialize and deserialize one of the built-in types. This is a bit of a hack, but it maximizes compatibility.

  1       // MARK: Codable   
  2        /**
  3        Initializes the type using the provided decoder
  4        - Parameter decoder: the decoder to use for initializing the type
  5       */
  6       public init(from decoder: Decoder) throws {
  7           let container = try decoder.singleValueContainer()
  8           let value = try container.decode(UInt.self)
  9           self = UInt24(value)
 10       }
 11       
 12       /**
 13        Serializes the type using the provided encoder
 14        - Parameter encoder: the encoder to use for serializing the type
 15       */
 16       public func encode(to encoder: Encoder) throws {
 17           var container = encoder.singleValueContainer()
 18           try container.encode(UInt(self))
 19       }

Conclusion

This article has covered quite a lot (and provided a healthy amount of code). Hopefully it demystifies how custom numeric types work in Swift. At the end of the day, a simple type is really just conforming to some protocols. It’s a lot of typing, but it’s fairly simple code. Thankfully, the remaining code we’ll need is less complex. There’s more you could do to make this type more robust (such as adding in the Sendable protocol and implementing localization), but this is a good starting point.

In future articles, we’ll put together the rest of the pieces needed to read Bluetooth values in a testable way. We’ll also cover how and why to create a Swift Package for this code, including having a GitHub repo that contains the project. See you there!