@tempivo/sensor-beacon 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,413 @@
1
+ import Foundation
2
+
3
+ /// Decodes Tempivo sensor BLE manufacturer data (advertisement frame `0x03` + scan response `0x04`).
4
+ public enum TempivoSensorBeaconDecoder {
5
+
6
+ public static let advFrame03Len = 22
7
+
8
+ private static let measureTypes: [UInt8: (name: String, resolution: Double, metaFactor: Int, continuous: Bool)] = [
9
+ 0x01: ("Temperature", 0.1, 1, true),
10
+ 0x02: ("Humidity", 1.0, 1, true),
11
+ 0x03: ("Atmospheric pressure", 0.1, 1, true),
12
+ 0x04: ("Differential pressure", 1.0, 1, true),
13
+ 0x05: ("OK/Alarm", 1.0, 1, false),
14
+ 0x06: ("IAQ", 1.0, 3, true),
15
+ 0x07: ("Flooding", 1.0, 1, false),
16
+ 0x08: ("Pulse count", 1.0, 1, true),
17
+ 0x09: ("Electricity meter", 1.0, 1, true),
18
+ 0x0A: ("Water meter", 1.0, 1, true),
19
+ 0x0B: ("Soil moisture", 1.0, 1, true),
20
+ 0x0C: ("CO", 1.0, 1, true),
21
+ 0x0D: ("NO₂", 1.0, 1, true),
22
+ 0x0E: ("H₂S", 0.01, 1, true),
23
+ 0x0F: ("Ambient light", 0.1, 1, true),
24
+ 0x10: ("PM1.0", 1.0, 1, true),
25
+ 0x11: ("PM2.5", 1.0, 1, true),
26
+ 0x12: ("PM10", 1.0, 1, true),
27
+ 0x13: ("Noise", 0.1, 1, true),
28
+ 0x14: ("NH₃", 1.0, 1, true),
29
+ 0x15: ("CH₄", 1.0, 1, true),
30
+ 0x16: ("High pressure", 1.0, 1, true),
31
+ 0x17: ("Distance", 1.0, 1, true),
32
+ 0x1A: ("CO₂", 1.0, 3, true),
33
+ 0x1B: ("Humidity", 0.1, 1, true),
34
+ 0x1C: ("Static IAQ", 1.0, 3, true),
35
+ 0x1D: ("CO₂ equivalent", 1.0, 3, true),
36
+ 0x1E: ("Breath VOC", 1.0, 3, true),
37
+ 0x20: ("Percentage", 0.01, 1, true),
38
+ 0x21: ("Voltage", 0.1, 1, true),
39
+ 0x22: ("Current", 0.01, 1, true),
40
+ ]
41
+
42
+ /// Strips the 2-byte company id when present so the payload starts with frame tag `0x03` / `0x04`.
43
+ public static func normalizeManufacturerBytes(_ data: Data) -> Data {
44
+ let bytes = [UInt8](data)
45
+ guard bytes.count >= 2 else { return data }
46
+ let company = UInt16(bytes[0]) | (UInt16(bytes[1]) << 8)
47
+ guard company == TEMPVO_SENSOR_MANUFACTURER_ID else { return data }
48
+ return Data(bytes.dropFirst(2))
49
+ }
50
+
51
+ public static func serialKeyFromAdv03(_ frame: [UInt8]) -> String? {
52
+ guard frame.count >= 7, frame[0] == 0x03 else { return nil }
53
+ return frame[1...6].map { String(format: "%02X", $0) }.joined()
54
+ }
55
+
56
+ public static func macKey(_ macWithColons: String) -> String {
57
+ macWithColons.replacingOccurrences(of: ":", with: "").uppercased()
58
+ }
59
+
60
+ public static func macWithColons(fromKey key: String) -> String {
61
+ let k = key.uppercased()
62
+ guard k.count == 12 else { return key }
63
+ var parts: [String] = []
64
+ var idx = k.startIndex
65
+ while idx < k.endIndex {
66
+ let next = k.index(idx, offsetBy: 2, limitedBy: k.endIndex) ?? k.endIndex
67
+ parts.append(String(k[idx..<next]))
68
+ idx = next
69
+ }
70
+ return parts.joined(separator: ":")
71
+ }
72
+
73
+ public static func lookupKeys(primaryMacKey: String, extraSerialKey: String? = nil) -> [String] {
74
+ var keys = [primaryMacKey.uppercased()]
75
+ if let extra = extraSerialKey?.uppercased(), !extra.isEmpty, !keys.contains(extra) {
76
+ keys.append(extra)
77
+ }
78
+ return keys
79
+ }
80
+
81
+ /// Decodes a single manufacturer payload (normalized or iOS-style with company id prefix).
82
+ public static func decodeSensorBeaconPayload(_ data: Data) -> SensorBeaconReading? {
83
+ let payload = normalizeManufacturerBytes(data)
84
+ let adv03 = extractFrame(tag: 0x03, from: payload, fixedLen: advFrame03Len)
85
+ let adv04 = extractFrame(tag: 0x04, from: payload, fixedLen: nil)
86
+ return buildSensorBeaconReading(adv03: adv03, adv04: adv04)
87
+ }
88
+
89
+ /// Stores `0x03` / `0x04` frames in `cache`. Payload may include or omit the company id prefix.
90
+ @discardableResult
91
+ public static func ingestManufacturerPayload(
92
+ _ data: Data,
93
+ primaryMacKey: String,
94
+ cache: inout SensorBeaconFrameCache
95
+ ) -> Bool {
96
+ ingestManufacturerPayload(data, primaryMacKey: primaryMacKey, last03: &cache.last03, last04: &cache.last04)
97
+ }
98
+
99
+ /// iOS `CBAdvertisementDataManufacturerDataKey` includes company id `6C 02` little-endian first.
100
+ @discardableResult
101
+ public static func ingestManufacturerData(
102
+ _ manufacturerData: Data,
103
+ primaryMacKey: String,
104
+ cache: inout SensorBeaconFrameCache
105
+ ) -> Bool {
106
+ ingestManufacturerData(
107
+ manufacturerData,
108
+ primaryMacKey: primaryMacKey,
109
+ last03: &cache.last03,
110
+ last04: &cache.last04
111
+ )
112
+ }
113
+
114
+ public static func buildSensorBeaconReading(
115
+ cacheKeys: [String],
116
+ cache: SensorBeaconFrameCache,
117
+ serialNumber: String? = nil,
118
+ bluetoothMacAddress: String? = nil
119
+ ) -> SensorBeaconReading? {
120
+ let keys = cacheKeys.map { $0.uppercased() }
121
+ let d03 = keys.compactMap { cache.last03[$0] }.first.map { [UInt8]($0) }
122
+ let d04 = keys.compactMap { cache.last04[$0] }.first.map { [UInt8]($0) }
123
+ guard var reading = buildSensorBeaconReading(adv03: d03, adv04: d04) else { return nil }
124
+ if reading.serialNumber == nil { reading = reading.withIdentity(serialNumber: serialNumber, bluetoothMacAddress: bluetoothMacAddress) }
125
+ return reading
126
+ }
127
+
128
+ public static func macFromManufacturerData(_ data: Data) -> String? {
129
+ let bytes = [UInt8](normalizeManufacturerBytes(data))
130
+ guard bytes.count >= 7, bytes[0] == 0x03 else { return nil }
131
+ return bytes[1...6].map { String(format: "%02X", $0) }.joined(separator: ":")
132
+ }
133
+
134
+ // MARK: - Internal frame cache ingest
135
+
136
+ @discardableResult
137
+ private static func ingestManufacturerPayload(
138
+ _ data: Data,
139
+ primaryMacKey: String,
140
+ last03: inout [String: Data],
141
+ last04: inout [String: Data]
142
+ ) -> Bool {
143
+ let payload = [UInt8](normalizeManufacturerBytes(data))
144
+ return ingestNormalizedPayload(payload, primaryMacKey: primaryMacKey, last03: &last03, last04: &last04)
145
+ }
146
+
147
+ @discardableResult
148
+ private static func ingestManufacturerData(
149
+ _ manufacturerData: Data,
150
+ primaryMacKey: String,
151
+ last03: inout [String: Data],
152
+ last04: inout [String: Data]
153
+ ) -> Bool {
154
+ let bytes = [UInt8](manufacturerData)
155
+ guard bytes.count >= 3 else { return false }
156
+ let company = UInt16(bytes[0]) | (UInt16(bytes[1]) << 8)
157
+ guard company == TEMPVO_SENSOR_MANUFACTURER_ID else { return false }
158
+ return ingestNormalizedPayload(Array(bytes.dropFirst(2)), primaryMacKey: primaryMacKey, last03: &last03, last04: &last04)
159
+ }
160
+
161
+ @discardableResult
162
+ private static func ingestNormalizedPayload(
163
+ _ payload: [UInt8],
164
+ primaryMacKey: String,
165
+ last03: inout [String: Data],
166
+ last04: inout [String: Data]
167
+ ) -> Bool {
168
+ var changed = false
169
+ let frames = splitSensorFrames(payload)
170
+ for fr in frames {
171
+ guard let tag = fr.first else { continue }
172
+ if tag == 0x03, fr.count >= advFrame03Len {
173
+ let frame = Array(fr.prefix(advFrame03Len))
174
+ let keys = lookupKeys(primaryMacKey: primaryMacKey, extraSerialKey: serialKeyFromAdv03(frame))
175
+ for key in keys {
176
+ let data = Data(frame)
177
+ if last03[key] != data {
178
+ last03[key] = data
179
+ changed = true
180
+ }
181
+ }
182
+ } else if tag == 0x04 {
183
+ let keys = lookupKeys(primaryMacKey: primaryMacKey)
184
+ let data = Data(fr)
185
+ for key in keys {
186
+ if last04[key] != data {
187
+ last04[key] = data
188
+ changed = true
189
+ }
190
+ }
191
+ }
192
+ }
193
+ return changed
194
+ }
195
+
196
+ private static func extractFrame(tag: UInt8, from payload: Data, fixedLen: Int?) -> [UInt8]? {
197
+ let bytes = [UInt8](payload)
198
+ for fr in splitSensorFrames(bytes) {
199
+ guard fr.first == tag else { continue }
200
+ if let fixedLen, fr.count >= fixedLen {
201
+ return Array(fr.prefix(fixedLen))
202
+ }
203
+ return fr
204
+ }
205
+ return nil
206
+ }
207
+
208
+ private static func splitSensorFrames(_ payload: [UInt8]) -> [[UInt8]] {
209
+ var out: [[UInt8]] = []
210
+ var i = 0
211
+ let n = payload.count
212
+ while i < n {
213
+ if payload[i] == 0x03 && i + advFrame03Len <= n {
214
+ out.append(Array(payload[i..<(i + advFrame03Len)]))
215
+ i += advFrame03Len
216
+ } else if payload[i] == 0x04 {
217
+ var j = i + 1
218
+ while j + 4 <= n {
219
+ if j + 4 > n - 2 { break }
220
+ let mtype = payload[j]
221
+ if mtype == 0 || mtype > 0x26 { break }
222
+ j += 4
223
+ }
224
+ if n - j >= 2 {
225
+ out.append(Array(payload[i..<(j + 2)]))
226
+ i = j + 2
227
+ } else {
228
+ out.append(Array(payload[i..<n]))
229
+ break
230
+ }
231
+ } else {
232
+ i += 1
233
+ }
234
+ }
235
+ return out
236
+ }
237
+
238
+ private static func zigzagDecode24(_ raw: UInt32) -> Int {
239
+ let n = Int(raw)
240
+ return (n >> 1) ^ (-(n & 1))
241
+ }
242
+
243
+ private static func decodeSlot(mtype: UInt8, raw24: UInt32) -> String {
244
+ guard let spec = measureTypes[mtype] else {
245
+ return String(format: "0x%02X: 0x%06X", mtype, raw24)
246
+ }
247
+ if !spec.continuous {
248
+ return "(status)"
249
+ }
250
+ let z = zigzagDecode24(raw24)
251
+ if spec.metaFactor == 1 {
252
+ let val = Double(z) * spec.resolution
253
+ let unit: String
254
+ switch mtype {
255
+ case 0x01: unit = "°C"
256
+ case 0x02, 0x1B: unit = "%"
257
+ case 0x03: unit = "hPa"
258
+ case 0x1A: unit = "ppm"
259
+ case 0x21: unit = "mV"
260
+ case 0x22: unit = "mA"
261
+ default: unit = ""
262
+ }
263
+ let num: String
264
+ if abs(val - floor(val)) < 0.0001 {
265
+ num = String(format: "%.0f", val)
266
+ } else {
267
+ switch mtype {
268
+ case 0x01, 0x03, 0x0F, 0x13: num = String(format: "%.1f", val)
269
+ case 0x02, 0x1B: num = String(format: "%.0f", val)
270
+ default: num = String(format: "%g", val)
271
+ }
272
+ }
273
+ return unit.isEmpty ? num : "\(num) \(unit)"
274
+ }
275
+ if mtype == 0x06 {
276
+ let iaq = z % (1 << 9)
277
+ let cal = (z >> 9) & 0x03
278
+ let calS = ["not_stab", "cal_req", "cal_ongoing", "cal_done"][min(cal, 3)]
279
+ return "\(iaq) (\(calS))"
280
+ }
281
+ if mtype == 0x1A || mtype == 0x1C || mtype == 0x1D || mtype == 0x1E {
282
+ let v = Double(z / spec.metaFactor) * spec.resolution
283
+ return String(format: "%g", v)
284
+ }
285
+ return String(z)
286
+ }
287
+
288
+ static func buildSensorBeaconReading(adv03: [UInt8]?, adv04: [UInt8]?) -> SensorBeaconReading? {
289
+ var firmware: String?
290
+ var batteryOk: Bool?
291
+ var encryptionEnabled: Bool?
292
+ var cellularStatus: String?
293
+ var measurementCounter: Int?
294
+ var readingTimestampUnix: Int?
295
+ var readingTimestampIso: String?
296
+ var periodBaseSeconds: Int?
297
+ var periodFactor: Int?
298
+ var serialNumber: String?
299
+ var bluetoothMacAddress: String?
300
+ var summaryParts: [String] = []
301
+ var readings: [SensorBeaconMeasurement] = []
302
+
303
+ if let a3 = adv03, a3.count >= advFrame03Len, a3[0] == 0x03 {
304
+ serialNumber = serialKeyFromAdv03(a3)
305
+ if let serialNumber {
306
+ bluetoothMacAddress = macWithColons(fromKey: serialNumber)
307
+ }
308
+
309
+ let fwRaw = (UInt16(a3[7]) << 8) | UInt16(a3[8])
310
+ let major = (fwRaw >> 11) & 0x1F
311
+ let minor = (fwRaw >> 5) & 0x3F
312
+ let lts = fwRaw & 0x1F
313
+ firmware = "\(major).\(minor).\(lts)"
314
+
315
+ let st = a3[9]
316
+ batteryOk = (st & 1) != 0
317
+ encryptionEnabled = ((st >> 3) & 1) != 0
318
+ let cell = (st >> 6) & 3
319
+ let cellLabels = ["ble_only", "cell_ok", "no_server", "net_issue"]
320
+ cellularStatus = cellLabels[Int(cell)]
321
+
322
+ if a3.count >= 14 {
323
+ let ts = UInt32(a3[10]) << 24 | UInt32(a3[11]) << 16 | UInt32(a3[12]) << 8 | UInt32(a3[13])
324
+ measurementCounter = Int(ts)
325
+ readingTimestampUnix = Int(ts)
326
+ if ts > 1_000_000_000, ts < 4_000_000_000 {
327
+ let d = Date(timeIntervalSince1970: TimeInterval(ts))
328
+ let f = ISO8601DateFormatter()
329
+ f.formatOptions = [.withInternetDateTime]
330
+ readingTimestampIso = f.string(from: d)
331
+ }
332
+ }
333
+ if a3.count >= 18 {
334
+ let pbase = UInt16(a3[14]) << 8 | UInt16(a3[15])
335
+ let pfact = UInt16(a3[16]) << 8 | UInt16(a3[17])
336
+ periodBaseSeconds = Int(pbase)
337
+ periodFactor = Int(pfact)
338
+ }
339
+ }
340
+
341
+ if let a4 = adv04, a4.count >= 5, a4[0] == 0x04 {
342
+ var i = 1
343
+ while i + 4 <= a4.count - 2 {
344
+ let mtype = a4[i]
345
+ if mtype == 0 { break }
346
+ let raw24 = UInt32(a4[i + 1]) << 16 | UInt32(a4[i + 2]) << 8 | UInt32(a4[i + 3])
347
+ let text = decodeSlot(mtype: mtype, raw24: raw24)
348
+ summaryParts.append(text)
349
+ readings.append(
350
+ SensorBeaconMeasurement(
351
+ typeHex: String(format: "0x%02X", mtype),
352
+ raw24: Int(raw24 & 0xFFFFFF),
353
+ text: text
354
+ )
355
+ )
356
+ i += 4
357
+ }
358
+ }
359
+
360
+ let summary: String
361
+ if summaryParts.isEmpty {
362
+ if adv03 != nil {
363
+ summary = readingTimestampIso ?? "Adv OK. Wait for scan response (measurements)"
364
+ } else {
365
+ summary = ""
366
+ }
367
+ } else {
368
+ summary = summaryParts.prefix(4).joined(separator: " · ")
369
+ }
370
+
371
+ if summary.isEmpty && adv03 == nil && adv04 == nil { return nil }
372
+
373
+ let summaryOut = summary.isEmpty ? (readingTimestampIso ?? "") : summary
374
+
375
+ return SensorBeaconReading(
376
+ serialNumber: serialNumber,
377
+ bluetoothMacAddress: bluetoothMacAddress,
378
+ firmware: firmware,
379
+ batteryOk: batteryOk,
380
+ encryptionEnabled: encryptionEnabled,
381
+ cellularStatus: cellularStatus,
382
+ measurementCounter: measurementCounter,
383
+ readingTimestampUnix: readingTimestampUnix,
384
+ readingTimestampIso: readingTimestampIso,
385
+ periodBaseSeconds: periodBaseSeconds,
386
+ periodFactor: periodFactor,
387
+ readings: readings,
388
+ readingsCount: summaryParts.count,
389
+ summary: summaryOut
390
+ )
391
+ }
392
+ }
393
+
394
+ private extension SensorBeaconReading {
395
+ func withIdentity(serialNumber: String?, bluetoothMacAddress: String?) -> SensorBeaconReading {
396
+ SensorBeaconReading(
397
+ serialNumber: serialNumber ?? self.serialNumber,
398
+ bluetoothMacAddress: bluetoothMacAddress ?? self.bluetoothMacAddress,
399
+ firmware: firmware,
400
+ batteryOk: batteryOk,
401
+ encryptionEnabled: encryptionEnabled,
402
+ cellularStatus: cellularStatus,
403
+ measurementCounter: measurementCounter,
404
+ readingTimestampUnix: readingTimestampUnix,
405
+ readingTimestampIso: readingTimestampIso,
406
+ periodBaseSeconds: periodBaseSeconds,
407
+ periodFactor: periodFactor,
408
+ readings: readings,
409
+ readingsCount: readingsCount,
410
+ summary: summary
411
+ )
412
+ }
413
+ }
@@ -0,0 +1,159 @@
1
+ import CoreBluetooth
2
+ import Foundation
3
+
4
+ public protocol TempivoSensorBeaconScannerDelegate: AnyObject {
5
+ func sensorBeaconScanner(_ scanner: TempivoSensorBeaconScanner, didDiscover discovery: SensorBeaconDiscovery)
6
+ func sensorBeaconScanner(_ scanner: TempivoSensorBeaconScanner, didUpdateState state: CBManagerState)
7
+ }
8
+
9
+ public extension TempivoSensorBeaconScannerDelegate {
10
+ func sensorBeaconScanner(_ scanner: TempivoSensorBeaconScanner, didUpdateState state: CBManagerState) {}
11
+ }
12
+
13
+ /// Active BLE scan for Tempivo sensor beacons (manufacturer `0x026C`). Advertising only. No GATT or connect.
14
+ public final class TempivoSensorBeaconScanner: NSObject {
15
+
16
+ public weak var delegate: TempivoSensorBeaconScannerDelegate?
17
+
18
+ private var central: CBCentralManager!
19
+ private var frameCache = SensorBeaconFrameCache()
20
+ private var isScanning = false
21
+ private var pendingStart = false
22
+
23
+ public override init() {
24
+ super.init()
25
+ central = CBCentralManager(delegate: self, queue: .main)
26
+ }
27
+
28
+ public var bluetoothState: CBManagerState {
29
+ central.state
30
+ }
31
+
32
+ public var scanning: Bool {
33
+ isScanning
34
+ }
35
+
36
+ /// Clears cached `0x03` / `0x04` frames. Does not stop an active scan.
37
+ public func clearCache() {
38
+ frameCache.clear()
39
+ }
40
+
41
+ /// Starts an active scan (duplicate advertisements allowed). Requires Bluetooth powered on.
42
+ public func startScanning() {
43
+ pendingStart = true
44
+ if central.state == .poweredOn {
45
+ beginScanningIfNeeded()
46
+ }
47
+ }
48
+
49
+ /// Stops scanning and clears pending start.
50
+ public func stopScanning() {
51
+ pendingStart = false
52
+ guard isScanning else { return }
53
+ central.stopScan()
54
+ isScanning = false
55
+ }
56
+
57
+ private func beginScanningIfNeeded() {
58
+ guard pendingStart, central.state == .poweredOn, !isScanning else { return }
59
+ frameCache.clear()
60
+ central.scanForPeripherals(
61
+ withServices: nil,
62
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
63
+ )
64
+ isScanning = true
65
+ }
66
+
67
+ private func isTempivoManufacturerData(_ advertisementData: [String: Any]) -> Bool {
68
+ guard let mdata = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data, mdata.count >= 2 else {
69
+ return false
70
+ }
71
+ let b = [UInt8](mdata.prefix(2))
72
+ let company = UInt16(b[0]) | (UInt16(b[1]) << 8)
73
+ return company == TEMPVO_SENSOR_MANUFACTURER_ID
74
+ }
75
+
76
+ private func cacheKeys(
77
+ serial: String?,
78
+ macWithColons: String?,
79
+ deviceId: String
80
+ ) -> [String] {
81
+ var set = Set<String>()
82
+ let sn = serial?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
83
+ if !sn.isEmpty {
84
+ for k in TempivoSensorBeaconDecoder.lookupKeys(primaryMacKey: sn) { set.insert(k) }
85
+ }
86
+ let mac = macWithColons?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
87
+ if !mac.isEmpty {
88
+ let mk = TempivoSensorBeaconDecoder.macKey(mac)
89
+ for k in TempivoSensorBeaconDecoder.lookupKeys(primaryMacKey: mk) { set.insert(k) }
90
+ }
91
+ let id = deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
92
+ if !id.isEmpty { set.insert(id.uppercased()) }
93
+ return Array(set)
94
+ }
95
+
96
+ private func handleDiscovery(
97
+ peripheral: CBPeripheral,
98
+ advertisementData: [String: Any],
99
+ rssi: NSNumber
100
+ ) {
101
+ guard isTempivoManufacturerData(advertisementData) else { return }
102
+
103
+ let deviceId = peripheral.identifier.uuidString
104
+
105
+ var serialNumber: String?
106
+ var bluetoothMacAddress: String?
107
+
108
+ if let mdata = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data {
109
+ if let mac = TempivoSensorBeaconDecoder.macFromManufacturerData(mdata) {
110
+ bluetoothMacAddress = mac
111
+ serialNumber = TempivoSensorBeaconDecoder.macKey(mac)
112
+ }
113
+ let primaryKey = serialNumber ?? deviceId
114
+ _ = TempivoSensorBeaconDecoder.ingestManufacturerData(
115
+ mdata,
116
+ primaryMacKey: primaryKey,
117
+ cache: &frameCache
118
+ )
119
+ }
120
+
121
+ let keys = cacheKeys(serial: serialNumber, macWithColons: bluetoothMacAddress, deviceId: deviceId)
122
+ let reading = TempivoSensorBeaconDecoder.buildSensorBeaconReading(
123
+ cacheKeys: keys,
124
+ cache: frameCache,
125
+ serialNumber: serialNumber,
126
+ bluetoothMacAddress: bluetoothMacAddress
127
+ )
128
+
129
+ let discovery = SensorBeaconDiscovery(
130
+ deviceId: deviceId,
131
+ rssi: rssi.intValue,
132
+ serialNumber: serialNumber,
133
+ bluetoothMacAddress: bluetoothMacAddress,
134
+ reading: reading
135
+ )
136
+ delegate?.sensorBeaconScanner(self, didDiscover: discovery)
137
+ }
138
+ }
139
+
140
+ extension TempivoSensorBeaconScanner: CBCentralManagerDelegate {
141
+ public func centralManagerDidUpdateState(_ central: CBCentralManager) {
142
+ delegate?.sensorBeaconScanner(self, didUpdateState: central.state)
143
+ if central.state == .poweredOn {
144
+ beginScanningIfNeeded()
145
+ } else if central.state != .unknown {
146
+ pendingStart = false
147
+ isScanning = false
148
+ }
149
+ }
150
+
151
+ public func centralManager(
152
+ _ central: CBCentralManager,
153
+ didDiscover peripheral: CBPeripheral,
154
+ advertisementData: [String: Any],
155
+ rssi RSSI: NSNumber
156
+ ) {
157
+ handleDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI)
158
+ }
159
+ }
@@ -0,0 +1,106 @@
1
+ import Foundation
2
+
3
+ /// Tempivo sensor manufacturer company identifier in BLE advertisement data (little-endian on the wire).
4
+ public let TEMPVO_SENSOR_MANUFACTURER_ID: UInt16 = 0x026C
5
+
6
+ /// One decoded measurement slot from scan response frame `0x04`.
7
+ public struct SensorBeaconMeasurement: Codable, Sendable, Equatable {
8
+ public let typeHex: String
9
+ public let raw24: Int
10
+ public let text: String
11
+
12
+ public init(typeHex: String, raw24: Int, text: String) {
13
+ self.typeHex = typeHex
14
+ self.raw24 = raw24
15
+ self.text = text
16
+ }
17
+ }
18
+
19
+ /// Decoded telemetry from merged advertisement frames `0x03` and `0x04`.
20
+ public struct SensorBeaconReading: Codable, Sendable, Equatable {
21
+ public let serialNumber: String?
22
+ public let bluetoothMacAddress: String?
23
+ public let firmware: String?
24
+ public let batteryOk: Bool?
25
+ public let encryptionEnabled: Bool?
26
+ public let cellularStatus: String?
27
+ public let measurementCounter: Int?
28
+ public let readingTimestampUnix: Int?
29
+ public let readingTimestampIso: String?
30
+ public let periodBaseSeconds: Int?
31
+ public let periodFactor: Int?
32
+ public let readings: [SensorBeaconMeasurement]
33
+ public let readingsCount: Int
34
+ public let summary: String
35
+
36
+ public init(
37
+ serialNumber: String? = nil,
38
+ bluetoothMacAddress: String? = nil,
39
+ firmware: String? = nil,
40
+ batteryOk: Bool? = nil,
41
+ encryptionEnabled: Bool? = nil,
42
+ cellularStatus: String? = nil,
43
+ measurementCounter: Int? = nil,
44
+ readingTimestampUnix: Int? = nil,
45
+ readingTimestampIso: String? = nil,
46
+ periodBaseSeconds: Int? = nil,
47
+ periodFactor: Int? = nil,
48
+ readings: [SensorBeaconMeasurement] = [],
49
+ readingsCount: Int = 0,
50
+ summary: String = ""
51
+ ) {
52
+ self.serialNumber = serialNumber
53
+ self.bluetoothMacAddress = bluetoothMacAddress
54
+ self.firmware = firmware
55
+ self.batteryOk = batteryOk
56
+ self.encryptionEnabled = encryptionEnabled
57
+ self.cellularStatus = cellularStatus
58
+ self.measurementCounter = measurementCounter
59
+ self.readingTimestampUnix = readingTimestampUnix
60
+ self.readingTimestampIso = readingTimestampIso
61
+ self.periodBaseSeconds = periodBaseSeconds
62
+ self.periodFactor = periodFactor
63
+ self.readings = readings
64
+ self.readingsCount = readingsCount
65
+ self.summary = summary
66
+ }
67
+ }
68
+
69
+ /// Latest raw `0x03` / `0x04` frames keyed by 12-hex serial or MAC.
70
+ public struct SensorBeaconFrameCache: Sendable {
71
+ public var last03: [String: Data]
72
+ public var last04: [String: Data]
73
+
74
+ public init(last03: [String: Data] = [:], last04: [String: Data] = [:]) {
75
+ self.last03 = last03
76
+ self.last04 = last04
77
+ }
78
+
79
+ public mutating func clear() {
80
+ last03.removeAll(keepingCapacity: false)
81
+ last04.removeAll(keepingCapacity: false)
82
+ }
83
+ }
84
+
85
+ /// A sensor discovered during an active BLE scan (advertising only, no connection).
86
+ public struct SensorBeaconDiscovery: Sendable, Equatable {
87
+ public let deviceId: String
88
+ public let rssi: Int
89
+ public let serialNumber: String?
90
+ public let bluetoothMacAddress: String?
91
+ public let reading: SensorBeaconReading?
92
+
93
+ public init(
94
+ deviceId: String,
95
+ rssi: Int,
96
+ serialNumber: String?,
97
+ bluetoothMacAddress: String?,
98
+ reading: SensorBeaconReading?
99
+ ) {
100
+ self.deviceId = deviceId
101
+ self.rssi = rssi
102
+ self.serialNumber = serialNumber
103
+ self.bluetoothMacAddress = bluetoothMacAddress
104
+ self.reading = reading
105
+ }
106
+ }