@tempivo/sensor-beacon 1.1.2 → 1.1.4

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +35 -22
  3. package/android/build.gradle +2 -2
  4. package/android/src/main/java/expo/modules/tempivosensorbeacon/SensorBeaconNative.java +8 -0
  5. package/android/src/main/java/expo/modules/tempivosensorbeacon/TempivoSensorBeaconModule.kt +8 -0
  6. package/android-aar/library/build.gradle +5 -0
  7. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/EfentoRawConfigTransfer.kt +672 -0
  8. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +124 -10
  9. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +19 -8
  10. package/android-aar/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +142 -11
  11. package/android-aar/library/src/test/java/com/tempivo/sensor/beacon/PartnerBleRulesTest.kt +209 -0
  12. package/dist/native-module.d.ts +10 -0
  13. package/dist/native-module.js +46 -3
  14. package/dist/partner-dual-range-fixture.d.ts +28 -0
  15. package/dist/partner-dual-range-fixture.js +29 -0
  16. package/dist/profile.js +33 -19
  17. package/dist/proto/decode-proto-config.d.ts +3 -0
  18. package/dist/proto/decode-proto-config.js +46 -0
  19. package/dist/proto/efento-protobuf-descriptor.d.ts +964 -0
  20. package/dist/proto/efento-protobuf-descriptor.js +727 -0
  21. package/dist/proto/partner-from-proto.d.ts +9 -0
  22. package/dist/proto/partner-from-proto.js +187 -0
  23. package/dist/proto/partner-to-proto.d.ts +12 -0
  24. package/dist/proto/partner-to-proto.js +211 -0
  25. package/dist/tempivo-sensor-beacon.aar +0 -0
  26. package/ios/Frameworks/TempivoSensorBridge.xcframework/ios-arm64/TempivoSensorBridge.framework/TempivoSensorBridge +0 -0
  27. package/ios/Sources/TempivoSensorBeacon/EfentoRawConfigTransfer.swift +601 -0
  28. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +99 -29
  29. package/ios/TempivoSensorBeaconModule.swift +9 -0
  30. package/package.json +6 -2
@@ -0,0 +1,601 @@
1
+ import Foundation
2
+ import CoreBluetooth
3
+
4
+ /// Wire-level ProtoConfig transfer (get 0x0C / set 0x0D).
5
+ ///
6
+ /// One persistent central/peripheral link is reused across commands. Creating a new
7
+ /// `CBCentralManager` per command made the second command race the previous teardown,
8
+ /// so notifications never arrived and the transfer timed out.
9
+ ///
10
+ /// Disconnect the vendor SDK session before calling: two CB stacks cannot share one peripheral.
11
+ enum EfentoRawConfigTransfer {
12
+ fileprivate static let transferService = CBUUID(string: "6E20F700-6126-4FCD-99C1-EB8A182501A2")
13
+ fileprivate static let transferWrite = CBUUID(string: "6E20F701-6126-4FCD-99C1-EB8A182501A2")
14
+ fileprivate static let transferNotify = CBUUID(string: "6E20F702-6126-4FCD-99C1-EB8A182501A2")
15
+ fileprivate static let primaryService = CBUUID(string: "69E1F600-BC3C-4DC0-8127-CFCDD295C7F7")
16
+ fileprivate static let primaryWrite = CBUUID(string: "69E1F601-BC3C-4DC0-8127-CFCDD295C7F7")
17
+ fileprivate static let primaryNotify = CBUUID(string: "69E1F602-BC3C-4DC0-8127-CFCDD295C7F7")
18
+
19
+ fileprivate static let hdrReq: UInt8 = 0xAA
20
+ fileprivate static let hdrRsp: UInt8 = 0x55
21
+ fileprivate static let cmdGetConfig: UInt8 = 0x0C
22
+ fileprivate static let cmdSetConfig: UInt8 = 0x0D
23
+ fileprivate static let notifyConfiguration: UInt8 = 0x03
24
+
25
+ private static let link = Link()
26
+
27
+ static func readProtoConfig(
28
+ peripheralId: UUID,
29
+ bluetoothMac: String,
30
+ pin: String,
31
+ timeoutSeconds: TimeInterval = 25
32
+ ) throws -> Data {
33
+ try link.runCommand(
34
+ peripheralId: peripheralId,
35
+ bluetoothMac: bluetoothMac,
36
+ pin: pin,
37
+ writeProto: nil,
38
+ timeoutSeconds: timeoutSeconds
39
+ )
40
+ }
41
+
42
+ /// Write full ProtoConfig (command 0x0D). Payload = be3 PIN + proto bytes (SDK / Python layout).
43
+ static func writeProtoConfig(
44
+ peripheralId: UUID,
45
+ bluetoothMac: String,
46
+ pin: String,
47
+ proto: Data,
48
+ timeoutSeconds: TimeInterval = 25
49
+ ) throws {
50
+ _ = try link.runCommand(
51
+ peripheralId: peripheralId,
52
+ bluetoothMac: bluetoothMac,
53
+ pin: pin,
54
+ writeProto: proto,
55
+ timeoutSeconds: timeoutSeconds
56
+ )
57
+ }
58
+
59
+ /// Drop the persistent link (call when the JS session disconnects).
60
+ static func closeLink() {
61
+ link.close()
62
+ }
63
+
64
+ // MARK: - Persistent link
65
+
66
+ private final class Link: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
67
+ private let bleQueue = DispatchQueue(label: "com.tempivo.sensor.rawConfig")
68
+
69
+ private var central: CBCentralManager?
70
+ private var peripheral: CBPeripheral?
71
+ private var writeChar: CBCharacteristic?
72
+ private var notifyChar: CBCharacteristic?
73
+ private var usingTransfer = true
74
+ private var discoveryStarted = false
75
+
76
+ // Per-command state.
77
+ private var targetId: UUID?
78
+ private var bluetoothMac = ""
79
+ private var pin = ""
80
+ private var writeProto: Data?
81
+ private var notifyChunks: [Data] = []
82
+ private var startResponse: Data?
83
+ private var commandSent = false
84
+ private var finished = true
85
+ private var commandToken = UUID()
86
+ private var onDone: ((Result<Data, Error>) -> Void)?
87
+
88
+ private var cmd: UInt8 {
89
+ writeProto == nil ? EfentoRawConfigTransfer.cmdGetConfig : EfentoRawConfigTransfer.cmdSetConfig
90
+ }
91
+
92
+ func runCommand(
93
+ peripheralId: UUID,
94
+ bluetoothMac: String,
95
+ pin: String,
96
+ writeProto: Data?,
97
+ timeoutSeconds: TimeInterval
98
+ ) throws -> Data {
99
+ var lastError: Error?
100
+ for attempt in 0..<2 {
101
+ do {
102
+ return try attemptCommand(
103
+ peripheralId: peripheralId,
104
+ bluetoothMac: bluetoothMac,
105
+ pin: pin,
106
+ writeProto: writeProto,
107
+ timeoutSeconds: timeoutSeconds
108
+ )
109
+ } catch {
110
+ lastError = error
111
+ // Drop the link so the retry starts from a clean connect.
112
+ close()
113
+ if attempt == 0 { Thread.sleep(forTimeInterval: 1.0) }
114
+ }
115
+ }
116
+ throw lastError ?? TempivoSensorError(.unknown, "Raw config transfer failed.")
117
+ }
118
+
119
+ func close() {
120
+ bleQueue.sync {
121
+ if let central, let peripheral {
122
+ central.cancelPeripheralConnection(peripheral)
123
+ }
124
+ self.peripheral = nil
125
+ self.writeChar = nil
126
+ self.notifyChar = nil
127
+ self.discoveryStarted = false
128
+ self.central = nil
129
+ self.finished = true
130
+ self.onDone = nil
131
+ }
132
+ }
133
+
134
+ private func attemptCommand(
135
+ peripheralId: UUID,
136
+ bluetoothMac: String,
137
+ pin: String,
138
+ writeProto: Data?,
139
+ timeoutSeconds: TimeInterval
140
+ ) throws -> Data {
141
+ let sem = DispatchSemaphore(value: 0)
142
+ var result: Result<Data, Error>!
143
+ let token = UUID()
144
+
145
+ bleQueue.async {
146
+ self.commandToken = token
147
+ self.targetId = peripheralId
148
+ self.bluetoothMac = bluetoothMac
149
+ self.pin = pin
150
+ self.writeProto = writeProto
151
+ self.notifyChunks = []
152
+ self.startResponse = nil
153
+ self.commandSent = false
154
+ self.finished = false
155
+ self.onDone = { r in
156
+ result = r
157
+ sem.signal()
158
+ }
159
+ if self.central == nil {
160
+ self.central = CBCentralManager(delegate: self, queue: self.bleQueue)
161
+ } else {
162
+ self.advance()
163
+ }
164
+ self.bleQueue.asyncAfter(deadline: .now() + timeoutSeconds) {
165
+ guard self.commandToken == token else { return }
166
+ self.fail(TempivoSensorError(.unknown, "Raw config BLE timeout."))
167
+ }
168
+ }
169
+
170
+ sem.wait()
171
+ return try result.get()
172
+ }
173
+
174
+ /// Drive connect → discover → notify → command. Safe to call repeatedly.
175
+ private func advance() {
176
+ guard !finished, let central, let targetId else { return }
177
+ guard central.state == .poweredOn else { return }
178
+
179
+ if peripheral == nil || peripheral?.identifier != targetId {
180
+ guard let found = central.retrievePeripherals(withIdentifiers: [targetId]).first else {
181
+ fail(TempivoSensorError(.connectFailed, "Peripheral not in cache. Scan first, then connect."))
182
+ return
183
+ }
184
+ peripheral = found
185
+ found.delegate = self
186
+ writeChar = nil
187
+ notifyChar = nil
188
+ discoveryStarted = false
189
+ }
190
+ guard let peripheral else { return }
191
+
192
+ switch peripheral.state {
193
+ case .connected:
194
+ break
195
+ case .connecting:
196
+ return
197
+ default:
198
+ central.connect(peripheral, options: nil)
199
+ return
200
+ }
201
+
202
+ guard let writeChar, let notifyChar else {
203
+ if !discoveryStarted {
204
+ discoveryStarted = true
205
+ peripheral.discoverServices([
206
+ EfentoRawConfigTransfer.transferService,
207
+ EfentoRawConfigTransfer.primaryService,
208
+ ])
209
+ }
210
+ return
211
+ }
212
+ guard notifyChar.isNotifying else {
213
+ peripheral.setNotifyValue(true, for: notifyChar)
214
+ return
215
+ }
216
+ if !commandSent {
217
+ commandSent = true
218
+ sendCommand(peripheral: peripheral, writeChar: writeChar)
219
+ }
220
+ }
221
+
222
+ private func sendCommand(peripheral: CBPeripheral, writeChar: CBCharacteristic) {
223
+ guard let pinBytes = EfentoRawConfigTransfer.pinPayloadBe3(pin) else {
224
+ fail(TempivoSensorError(.invalidPin, "PIN must be numeric (be3)."))
225
+ return
226
+ }
227
+ let payload: Data
228
+ if let writeProto {
229
+ // Set 0x0D requires be3 PIN + proto (SDK / Python).
230
+ payload = pinBytes + writeProto
231
+ } else {
232
+ // Get 0x0C: empty PIN payload after SDK pairing (Python --pin-payload empty).
233
+ payload = Data()
234
+ }
235
+ let frame = EfentoRawConfigTransfer.buildCommandFrame(
236
+ mac: bluetoothMac,
237
+ cmd: cmd,
238
+ payload: payload
239
+ )
240
+ let maxWrite = peripheral.maximumWriteValueLength(for: .withResponse)
241
+ if frame.count > maxWrite {
242
+ fail(TempivoSensorError(
243
+ .invalidConfig,
244
+ "Config frame is \(frame.count) B but this link accepts \(maxWrite) B per write."
245
+ ))
246
+ return
247
+ }
248
+ peripheral.writeValue(frame, for: writeChar, type: .withResponse)
249
+ let token = commandToken
250
+ bleQueue.asyncAfter(deadline: .now() + 0.35) { [weak self] in
251
+ guard let self, self.commandToken == token, !self.finished,
252
+ let writeChar = self.writeChar else { return }
253
+ peripheral.readValue(for: writeChar)
254
+ }
255
+ if writeProto != nil {
256
+ bleQueue.asyncAfter(deadline: .now() + 0.8) { [weak self] in
257
+ guard let self, self.commandToken == token else { return }
258
+ self.finishWriteIfReady()
259
+ }
260
+ } else {
261
+ bleQueue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
262
+ guard let self, self.commandToken == token else { return }
263
+ self.tryAssemble()
264
+ }
265
+ }
266
+ }
267
+
268
+ /// Keep the link connected so the next command reuses it.
269
+ private func succeed(_ data: Data) {
270
+ guard !finished else { return }
271
+ finished = true
272
+ onDone?(.success(data))
273
+ onDone = nil
274
+ }
275
+
276
+ private func fail(_ error: Error) {
277
+ guard !finished else { return }
278
+ finished = true
279
+ onDone?(.failure(error))
280
+ onDone = nil
281
+ }
282
+
283
+ // MARK: CBCentralManagerDelegate
284
+
285
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
286
+ guard central.state == .poweredOn else {
287
+ if central.state != .unknown && central.state != .resetting {
288
+ fail(TempivoSensorError(.runtimeUnavailable, "Bluetooth is not powered on."))
289
+ }
290
+ return
291
+ }
292
+ advance()
293
+ }
294
+
295
+ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
296
+ writeChar = nil
297
+ notifyChar = nil
298
+ discoveryStarted = false
299
+ advance()
300
+ }
301
+
302
+ func centralManager(
303
+ _ central: CBCentralManager,
304
+ didFailToConnect peripheral: CBPeripheral,
305
+ error: Error?
306
+ ) {
307
+ fail(error ?? TempivoSensorError(.connectFailed, "Raw BLE connect failed."))
308
+ }
309
+
310
+ func centralManager(
311
+ _ central: CBCentralManager,
312
+ didDisconnectPeripheral peripheral: CBPeripheral,
313
+ error: Error?
314
+ ) {
315
+ writeChar = nil
316
+ notifyChar = nil
317
+ discoveryStarted = false
318
+ if !finished {
319
+ fail(error ?? TempivoSensorError(.connectFailed, "Sensor dropped the config link."))
320
+ }
321
+ }
322
+
323
+ // MARK: CBPeripheralDelegate
324
+
325
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
326
+ if let error {
327
+ fail(error)
328
+ return
329
+ }
330
+ let services = peripheral.services ?? []
331
+ if let transfer = services.first(where: { $0.uuid == EfentoRawConfigTransfer.transferService }) {
332
+ usingTransfer = true
333
+ peripheral.discoverCharacteristics(
334
+ [EfentoRawConfigTransfer.transferWrite, EfentoRawConfigTransfer.transferNotify],
335
+ for: transfer
336
+ )
337
+ return
338
+ }
339
+ if let primary = services.first(where: { $0.uuid == EfentoRawConfigTransfer.primaryService }) {
340
+ usingTransfer = false
341
+ peripheral.discoverCharacteristics(
342
+ [EfentoRawConfigTransfer.primaryWrite, EfentoRawConfigTransfer.primaryNotify],
343
+ for: primary
344
+ )
345
+ return
346
+ }
347
+ fail(TempivoSensorError(.unsupportedCommand, "No sensor config GATT service."))
348
+ }
349
+
350
+ func peripheral(
351
+ _ peripheral: CBPeripheral,
352
+ didDiscoverCharacteristicsFor service: CBService,
353
+ error: Error?
354
+ ) {
355
+ if let error {
356
+ fail(error)
357
+ return
358
+ }
359
+ let chars = service.characteristics ?? []
360
+ if usingTransfer {
361
+ writeChar = chars.first(where: { $0.uuid == EfentoRawConfigTransfer.transferWrite })
362
+ notifyChar = chars.first(where: { $0.uuid == EfentoRawConfigTransfer.transferNotify })
363
+ } else {
364
+ writeChar = chars.first(where: { $0.uuid == EfentoRawConfigTransfer.primaryWrite })
365
+ notifyChar = chars.first(where: { $0.uuid == EfentoRawConfigTransfer.primaryNotify })
366
+ }
367
+ guard writeChar != nil, notifyChar != nil else {
368
+ fail(TempivoSensorError(.unsupportedCommand, "Missing write/notify characteristics."))
369
+ return
370
+ }
371
+ advance()
372
+ }
373
+
374
+ func peripheral(
375
+ _ peripheral: CBPeripheral,
376
+ didUpdateNotificationStateFor characteristic: CBCharacteristic,
377
+ error: Error?
378
+ ) {
379
+ if let error {
380
+ fail(error)
381
+ return
382
+ }
383
+ advance()
384
+ }
385
+
386
+ func peripheral(
387
+ _ peripheral: CBPeripheral,
388
+ didWriteValueFor characteristic: CBCharacteristic,
389
+ error: Error?
390
+ ) {
391
+ if let error {
392
+ fail(error)
393
+ }
394
+ }
395
+
396
+ func peripheral(
397
+ _ peripheral: CBPeripheral,
398
+ didUpdateValueFor characteristic: CBCharacteristic,
399
+ error: Error?
400
+ ) {
401
+ if let error {
402
+ fail(error)
403
+ return
404
+ }
405
+ guard !finished, let value = characteristic.value, !value.isEmpty else { return }
406
+ if characteristic.uuid == writeChar?.uuid {
407
+ startResponse = value
408
+ if writeProto != nil {
409
+ finishWriteIfReady()
410
+ }
411
+ return
412
+ }
413
+ if writeProto == nil, characteristic.uuid == notifyChar?.uuid {
414
+ notifyChunks.append(value)
415
+ tryAssemble()
416
+ }
417
+ }
418
+
419
+ // MARK: Frame handling
420
+
421
+ private func finishWriteIfReady() {
422
+ guard writeProto != nil, let startResponse else { return }
423
+ do {
424
+ _ = try EfentoRawConfigTransfer.parseStartResponse(
425
+ mac: bluetoothMac,
426
+ cmd: cmd,
427
+ frame: startResponse
428
+ )
429
+ succeed(Data())
430
+ } catch {
431
+ fail(error)
432
+ }
433
+ }
434
+
435
+ private func tryAssemble() {
436
+ if let startResponse {
437
+ do {
438
+ _ = try EfentoRawConfigTransfer.parseStartResponse(
439
+ mac: bluetoothMac,
440
+ cmd: cmd,
441
+ frame: startResponse
442
+ )
443
+ } catch {
444
+ // Some FW still streams notify after a soft status; keep assembling.
445
+ }
446
+ }
447
+ do {
448
+ let merged = try EfentoRawConfigTransfer.assemble(
449
+ mac: bluetoothMac,
450
+ chunks: notifyChunks,
451
+ preferCode03: usingTransfer
452
+ )
453
+ if !merged.isEmpty {
454
+ succeed(merged)
455
+ }
456
+ } catch {
457
+ // Wait for more chunks until timeout.
458
+ }
459
+ }
460
+ }
461
+
462
+ // MARK: - Wire helpers
463
+
464
+ fileprivate static func pinPayloadBe3(_ pin: String) -> Data? {
465
+ let digits = pin.trimmingCharacters(in: .whitespacesAndNewlines)
466
+ guard let n = UInt64(digits), n <= 0xFFFFFF else { return nil }
467
+ return Data([
468
+ UInt8((n >> 16) & 0xFF),
469
+ UInt8((n >> 8) & 0xFF),
470
+ UInt8(n & 0xFF),
471
+ ])
472
+ }
473
+
474
+ fileprivate static func macBytes(_ mac: String) throws -> Data {
475
+ let h = mac.replacingOccurrences(of: ":", with: "")
476
+ .replacingOccurrences(of: "-", with: "")
477
+ .lowercased()
478
+ guard h.count == 12, h.allSatisfy({ $0.isHexDigit }) else {
479
+ throw TempivoSensorError(.invalidQr, "Invalid Bluetooth MAC.")
480
+ }
481
+ var out = Data()
482
+ var i = h.startIndex
483
+ while i < h.endIndex {
484
+ let j = h.index(i, offsetBy: 2)
485
+ out.append(UInt8(h[i..<j], radix: 16)!)
486
+ i = j
487
+ }
488
+ return out
489
+ }
490
+
491
+ fileprivate static func crc16Ccitt(_ data: Data) -> UInt16 {
492
+ var crc: UInt16 = 0xFFFF
493
+ for byte in data {
494
+ for bit in 0..<8 {
495
+ let inp = ((byte >> (7 - bit)) & 1) == 1
496
+ let cbit = ((crc >> 15) & 1) == 1
497
+ crc = (crc << 1) & 0xFFFF
498
+ if cbit != inp { crc ^= 0x1021 }
499
+ }
500
+ }
501
+ return crc
502
+ }
503
+
504
+ fileprivate static func buildCommandFrame(mac: String, cmd: UInt8, payload: Data) -> Data {
505
+ let m = (try? macBytes(mac)) ?? Data(count: 6)
506
+ var body = Data([hdrReq, cmd])
507
+ body.append(payload)
508
+ let crc = crc16Ccitt(m + body)
509
+ body.append(UInt8((crc >> 8) & 0xFF))
510
+ body.append(UInt8(crc & 0xFF))
511
+ return body
512
+ }
513
+
514
+ fileprivate static func parseStartResponse(mac: String, cmd: UInt8, frame: Data) throws -> Data {
515
+ guard frame.count >= 5 else {
516
+ throw TempivoSensorError(.unknown, "Short config response.")
517
+ }
518
+ if frame[0] == 0x66 {
519
+ throw TempivoSensorError(.unknown, "Encrypted config response (key required).")
520
+ }
521
+ guard frame[0] == hdrRsp else {
522
+ throw TempivoSensorError(.unknown, "Unexpected config response header.")
523
+ }
524
+ guard frame[1] == cmd else {
525
+ throw TempivoSensorError(.unknown, "Config command echo mismatch.")
526
+ }
527
+ let m = try macBytes(mac)
528
+ let expect = crc16Ccitt(m + frame.dropLast(2))
529
+ let got = (UInt16(frame[frame.count - 2]) << 8) | UInt16(frame[frame.count - 1])
530
+ guard expect == got else {
531
+ throw TempivoSensorError(.unknown, "Config response CRC mismatch.")
532
+ }
533
+ let st = frame[2]
534
+ guard st == 0 else {
535
+ throw TempivoSensorError(.invalidPin, "Config command status \(st).")
536
+ }
537
+ return Data(frame[3..<(frame.count - 2)])
538
+ }
539
+
540
+ fileprivate static func assemble(mac: String, chunks: [Data], preferCode03: Bool) throws -> Data {
541
+ let m = try macBytes(mac)
542
+ var buf = Data()
543
+ var merged = Data()
544
+ var complete = false
545
+
546
+ func crcValid(_ frame: Data) -> Bool {
547
+ guard frame.count >= 4 else { return false }
548
+ let expect = crc16Ccitt(m + frame.dropLast(2))
549
+ let got = (UInt16(frame[frame.count - 2]) << 8) | UInt16(frame[frame.count - 1])
550
+ return expect == got
551
+ }
552
+
553
+ func feed(_ payload: Data) {
554
+ var off = 0
555
+ let inc = payload
556
+ while off < inc.count || (buf.count >= 2 && !complete) {
557
+ if buf.count < 2 {
558
+ let need = 2 - buf.count
559
+ let take = min(need, inc.count - off)
560
+ if take <= 0 { break }
561
+ buf.append(inc.subdata(in: off..<(off + take)))
562
+ off += take
563
+ if buf.count < 2 { break }
564
+ }
565
+ let flen = (Int(buf[0]) << 8) | Int(buf[1])
566
+ if flen == 0 {
567
+ complete = true
568
+ buf.removeAll()
569
+ return
570
+ }
571
+ let missing = flen - buf.count
572
+ if missing > 0 {
573
+ let take = min(missing, inc.count - off)
574
+ if take <= 0 { break }
575
+ buf.append(inc.subdata(in: off..<(off + take)))
576
+ off += take
577
+ if buf.count < flen { continue }
578
+ }
579
+ let frame = buf.subdata(in: 0..<flen)
580
+ buf = buf.count > flen ? buf.subdata(in: flen..<buf.count) : Data()
581
+ guard crcValid(frame) else { continue }
582
+ if flen >= 4 {
583
+ merged.append(frame.subdata(in: 2..<(flen - 2)))
584
+ }
585
+ }
586
+ }
587
+
588
+ for raw in chunks {
589
+ if raw.count >= 2, raw[0] == notifyConfiguration {
590
+ feed(raw.subdata(in: 1..<raw.count))
591
+ } else if raw.count >= 4 {
592
+ feed(raw)
593
+ }
594
+ if complete && !merged.isEmpty { break }
595
+ }
596
+ guard complete, !merged.isEmpty else {
597
+ throw TempivoSensorError(.unknown, "Incomplete config transfer.")
598
+ }
599
+ return merged
600
+ }
601
+ }