@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.
- package/README.md +82 -39
- package/android/build.gradle +4 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/android/gradle.properties +3 -0
- package/android/gradlew +251 -0
- package/android/library/build.gradle +27 -0
- package/android/library/consumer-rules.pro +1 -0
- package/android/library/src/main/AndroidManifest.xml +2 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +406 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +160 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +42 -0
- package/android/settings.gradle +18 -0
- package/dist/decoder.js +6 -6
- package/dist/tempivo-sensor-beacon.aar +0 -0
- package/ios/Package.swift +22 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +413 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconScanner.swift +159 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +106 -0
- package/package.json +12 -5
|
@@ -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
|
+
}
|