@tempivo/sensor-beacon 0.1.1 → 0.2.0

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 (37) hide show
  1. package/README.md +195 -79
  2. package/android/library/build.gradle +3 -0
  3. package/android/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +279 -0
  4. package/android/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +168 -0
  5. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +6 -23
  6. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +0 -2
  7. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +2 -4
  8. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorCalibration.kt +36 -0
  9. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +18 -0
  10. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +171 -0
  11. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +82 -0
  12. package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +114 -0
  13. package/android/settings.gradle +1 -0
  14. package/dist/calibration.d.ts +7 -0
  15. package/dist/calibration.js +42 -0
  16. package/dist/decoder.js +4 -12
  17. package/dist/errors.d.ts +6 -0
  18. package/dist/errors.js +8 -0
  19. package/dist/index.d.ts +6 -1
  20. package/dist/index.js +5 -1
  21. package/dist/profile.d.ts +10 -0
  22. package/dist/profile.js +226 -0
  23. package/dist/qr.d.ts +16 -0
  24. package/dist/qr.js +90 -0
  25. package/dist/session-types.d.ts +64 -0
  26. package/dist/session-types.js +1 -0
  27. package/dist/tempivo-sensor-beacon.aar +0 -0
  28. package/dist/types.d.ts +3 -3
  29. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +4 -8
  30. package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +3 -6
  31. package/ios/Sources/TempivoSensorBeacon/TempivoSensorCalibration.swift +22 -0
  32. package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +24 -0
  33. package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +123 -0
  34. package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +78 -0
  35. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +161 -0
  36. package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +93 -0
  37. package/package.json +10 -3
package/dist/types.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface SensorBeaconMeasurement {
7
7
  humidityPct?: number;
8
8
  }
9
9
  export interface SensorBeaconReading {
10
+ /** 12 hex chars from frame 0x03 (uppercase, no colons). */
10
11
  serialMac: string;
11
12
  firmware: string;
12
13
  batteryOk: boolean;
@@ -15,9 +16,8 @@ export interface SensorBeaconReading {
15
16
  measurementCounter: number | null;
16
17
  readingTimestampUnix: number | null;
17
18
  readingTimestampIso: string | null;
18
- periodBaseSeconds: number | null;
19
- periodFactor: number | null;
20
- periodLabel: string;
19
+ /** Sample interval from the advertisement, in seconds. */
20
+ measurementIntervalSeconds: number | null;
21
21
  measurements: SensorBeaconMeasurement[];
22
22
  summary: string;
23
23
  temperatures: number[];
@@ -293,8 +293,7 @@ public enum TempivoSensorBeaconDecoder {
293
293
  var measurementCounter: Int?
294
294
  var readingTimestampUnix: Int?
295
295
  var readingTimestampIso: String?
296
- var periodBaseSeconds: Int?
297
- var periodFactor: Int?
296
+ var measurementIntervalSeconds: Int?
298
297
  var serialNumber: String?
299
298
  var bluetoothMacAddress: String?
300
299
  var summaryParts: [String] = []
@@ -333,8 +332,7 @@ public enum TempivoSensorBeaconDecoder {
333
332
  if a3.count >= 18 {
334
333
  let pbase = UInt16(a3[14]) << 8 | UInt16(a3[15])
335
334
  let pfact = UInt16(a3[16]) << 8 | UInt16(a3[17])
336
- periodBaseSeconds = Int(pbase)
337
- periodFactor = Int(pfact)
335
+ measurementIntervalSeconds = Int(pbase) * Int(pfact)
338
336
  }
339
337
  }
340
338
 
@@ -382,8 +380,7 @@ public enum TempivoSensorBeaconDecoder {
382
380
  measurementCounter: measurementCounter,
383
381
  readingTimestampUnix: readingTimestampUnix,
384
382
  readingTimestampIso: readingTimestampIso,
385
- periodBaseSeconds: periodBaseSeconds,
386
- periodFactor: periodFactor,
383
+ measurementIntervalSeconds: measurementIntervalSeconds,
387
384
  readings: readings,
388
385
  readingsCount: summaryParts.count,
389
386
  summary: summaryOut
@@ -403,8 +400,7 @@ private extension SensorBeaconReading {
403
400
  measurementCounter: measurementCounter,
404
401
  readingTimestampUnix: readingTimestampUnix,
405
402
  readingTimestampIso: readingTimestampIso,
406
- periodBaseSeconds: periodBaseSeconds,
407
- periodFactor: periodFactor,
403
+ measurementIntervalSeconds: measurementIntervalSeconds,
408
404
  readings: readings,
409
405
  readingsCount: readingsCount,
410
406
  summary: summary
@@ -27,8 +27,7 @@ public struct SensorBeaconReading: Codable, Sendable, Equatable {
27
27
  public let measurementCounter: Int?
28
28
  public let readingTimestampUnix: Int?
29
29
  public let readingTimestampIso: String?
30
- public let periodBaseSeconds: Int?
31
- public let periodFactor: Int?
30
+ public let measurementIntervalSeconds: Int?
32
31
  public let readings: [SensorBeaconMeasurement]
33
32
  public let readingsCount: Int
34
33
  public let summary: String
@@ -43,8 +42,7 @@ public struct SensorBeaconReading: Codable, Sendable, Equatable {
43
42
  measurementCounter: Int? = nil,
44
43
  readingTimestampUnix: Int? = nil,
45
44
  readingTimestampIso: String? = nil,
46
- periodBaseSeconds: Int? = nil,
47
- periodFactor: Int? = nil,
45
+ measurementIntervalSeconds: Int? = nil,
48
46
  readings: [SensorBeaconMeasurement] = [],
49
47
  readingsCount: Int = 0,
50
48
  summary: String = ""
@@ -58,8 +56,7 @@ public struct SensorBeaconReading: Codable, Sendable, Equatable {
58
56
  self.measurementCounter = measurementCounter
59
57
  self.readingTimestampUnix = readingTimestampUnix
60
58
  self.readingTimestampIso = readingTimestampIso
61
- self.periodBaseSeconds = periodBaseSeconds
62
- self.periodFactor = periodFactor
59
+ self.measurementIntervalSeconds = measurementIntervalSeconds
63
60
  self.readings = readings
64
61
  self.readingsCount = readingsCount
65
62
  self.summary = summary
@@ -0,0 +1,22 @@
1
+ import Foundation
2
+
3
+ public enum TempivoSensorCalibrationDecoder {
4
+ private static let secondsPerDay: Int64 = 86_400
5
+
6
+ public static func decode(_ raw: Int64?) -> TempivoSensorCalibration {
7
+ guard let raw, raw > 0 else {
8
+ return TempivoSensorCalibration(laboratoryCalibrationDate: nil, laboratoryCalibrationTimestamp: nil)
9
+ }
10
+ let seconds = raw < 100_000 ? raw * secondsPerDay : raw
11
+ let date = Date(timeIntervalSince1970: TimeInterval(seconds))
12
+ let fmt = DateFormatter()
13
+ fmt.calendar = Calendar(identifier: .gregorian)
14
+ fmt.timeZone = TimeZone(secondsFromGMT: 0)
15
+ fmt.locale = Locale(identifier: "en_US_POSIX")
16
+ fmt.dateFormat = "yyyy-MM-dd"
17
+ return TempivoSensorCalibration(
18
+ laboratoryCalibrationDate: fmt.string(from: date),
19
+ laboratoryCalibrationTimestamp: raw
20
+ )
21
+ }
22
+ }
@@ -0,0 +1,24 @@
1
+ import Foundation
2
+
3
+ public enum TempivoSensorErrorCode: String, Sendable {
4
+ case invalidPin
5
+ case invalidQr
6
+ case invalidConfig
7
+ case notConnected
8
+ case unsupportedCommand
9
+ case runtimeUnavailable
10
+ case connectFailed
11
+ case unknown
12
+ }
13
+
14
+ public struct TempivoSensorError: Error, LocalizedError, Sendable {
15
+ public let code: TempivoSensorErrorCode
16
+ public let message: String
17
+
18
+ public init(_ code: TempivoSensorErrorCode, _ message: String) {
19
+ self.code = code
20
+ self.message = message
21
+ }
22
+
23
+ public var errorDescription: String? { message }
24
+ }
@@ -0,0 +1,123 @@
1
+ import Foundation
2
+
3
+ public enum TempivoSensorProfile {
4
+ public static func parse(json: String) throws -> TempivoSensorConfiguration {
5
+ guard let data = json.data(using: .utf8),
6
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
7
+ else {
8
+ throw TempivoSensorError(.invalidConfig, "Configuration is not valid JSON.")
9
+ }
10
+ return try parse(obj)
11
+ }
12
+
13
+ public static func parse(_ obj: [String: Any]) throws -> TempivoSensorConfiguration {
14
+ if obj["profiles"] is [Any] {
15
+ throw TempivoSensorError(.invalidConfig, "Pass one profile from GET /devices/config-profiles/{slug}, not the list.")
16
+ }
17
+ let body: [String: Any]
18
+ if let nested = obj["profile"] as? [String: Any],
19
+ nested["temperatureAlerts"] != nil || nested["schedule"] != nil || nested["slug"] != nil {
20
+ body = nested
21
+ } else {
22
+ body = obj
23
+ }
24
+ if body["temperatureAlerts"] == nil && body["alarmRules"] != nil {
25
+ throw TempivoSensorError(.invalidConfig, "Use a config profile with temperatureAlerts (GET /devices/config-profiles/{slug}), not alarmRules.")
26
+ }
27
+ let rawAlerts = body["temperatureAlerts"] as? [Any] ?? []
28
+ let alerts = try rawAlerts.enumerated().map { try parseAlert($0.element, index: $0.offset) }
29
+ return TempivoSensorConfiguration(temperatureAlerts: alerts, schedule: try parseSchedule(body["schedule"] as? [String: Any]))
30
+ }
31
+
32
+ public static func jsonString(_ cfg: TempivoSensorConfiguration) throws -> String {
33
+ var alerts: [[String: Any]] = []
34
+ for a in cfg.temperatureAlerts {
35
+ var o: [String: Any] = [
36
+ "type": a.type,
37
+ "channel": a.channel.rawValue,
38
+ "transmitOnBreach": a.transmitOnBreach,
39
+ ]
40
+ if let v = a.lowC { o["lowC"] = v }
41
+ if let v = a.highC { o["highC"] = v }
42
+ if let v = a.minC { o["minC"] = v }
43
+ if let v = a.maxC { o["maxC"] = v }
44
+ if let v = a.hysteresisC { o["hysteresisC"] = v }
45
+ if a.type == "range" { o["transmitOnReturn"] = a.transmitOnReturn }
46
+ alerts.append(o)
47
+ }
48
+ var schedule: [String: Any]
49
+ if cfg.schedule.always {
50
+ schedule = ["always": true]
51
+ } else {
52
+ schedule = [
53
+ "weekdays": cfg.schedule.weekdays,
54
+ "from": cfg.schedule.from ?? "00:00",
55
+ "to": cfg.schedule.to ?? "23:59",
56
+ "utcOffsetMinutes": cfg.schedule.utcOffsetMinutes,
57
+ ]
58
+ }
59
+ let root: [String: Any] = ["temperatureAlerts": alerts, "schedule": schedule]
60
+ let data = try JSONSerialization.data(withJSONObject: root, options: [])
61
+ return String(data: data, encoding: .utf8) ?? "{}"
62
+ }
63
+
64
+ private static func parseAlert(_ raw: Any, index: Int) throws -> TempivoTemperatureAlert {
65
+ guard let item = raw as? [String: Any] else {
66
+ throw TempivoSensorError(.invalidConfig, "temperatureAlerts[\(index)] must be an object.")
67
+ }
68
+ guard let channelRaw = item["channel"] as? String,
69
+ let channel = TempivoTemperatureChannel(rawValue: channelRaw)
70
+ else {
71
+ throw TempivoSensorError(.invalidConfig, "temperatureAlerts[\(index)].channel must be ambient or probe.")
72
+ }
73
+ let hyst = number(item["hysteresisC"]) ?? 1
74
+ let transmit = (item["transmitOnBreach"] as? Bool) ?? true
75
+ let transmitOnReturn = (item["transmitOnReturn"] as? Bool) ?? true
76
+ switch item["type"] as? String {
77
+ case "range":
78
+ guard let low = number(item["lowC"]), let high = number(item["highC"]) else {
79
+ throw TempivoSensorError(.invalidConfig, "temperatureAlerts[\(index)] needs lowC and highC.")
80
+ }
81
+ guard high > low else {
82
+ throw TempivoSensorError(.invalidConfig, "temperatureAlerts[\(index)]: highC must be greater than lowC.")
83
+ }
84
+ return TempivoTemperatureAlert(type: "range", channel: channel, lowC: low, highC: high, hysteresisC: hyst, transmitOnBreach: transmit, transmitOnReturn: transmitOnReturn)
85
+ case "min":
86
+ guard let minC = number(item["minC"]) else {
87
+ throw TempivoSensorError(.invalidConfig, "temperatureAlerts[\(index)] needs minC.")
88
+ }
89
+ return TempivoTemperatureAlert(type: "min", channel: channel, minC: minC, hysteresisC: hyst, transmitOnBreach: transmit)
90
+ case "max":
91
+ guard let maxC = number(item["maxC"]) else {
92
+ throw TempivoSensorError(.invalidConfig, "temperatureAlerts[\(index)] needs maxC.")
93
+ }
94
+ return TempivoTemperatureAlert(type: "max", channel: channel, maxC: maxC, hysteresisC: hyst, transmitOnBreach: transmit)
95
+ default:
96
+ throw TempivoSensorError(.invalidConfig, "temperatureAlerts[\(index)].type must be range, min, or max.")
97
+ }
98
+ }
99
+
100
+ private static func parseSchedule(_ raw: [String: Any]?) throws -> TempivoSchedule {
101
+ guard let raw else { return .alwaysOn }
102
+ if raw["always"] as? Bool == true { return .alwaysOn }
103
+ guard let weekdays = raw["weekdays"] as? [Int], !weekdays.isEmpty, weekdays.allSatisfy({ (0...6).contains($0) }) else {
104
+ throw TempivoSensorError(.invalidConfig, "schedule.weekdays is required.")
105
+ }
106
+ guard let from = raw["from"] as? String, let to = raw["to"] as? String,
107
+ from.range(of: "^([01]\\d|2[0-3]):([0-5]\\d)$", options: .regularExpression) != nil,
108
+ to.range(of: "^([01]\\d|2[0-3]):([0-5]\\d)$", options: .regularExpression) != nil
109
+ else {
110
+ throw TempivoSensorError(.invalidConfig, "schedule.from and schedule.to must be HH:MM.")
111
+ }
112
+ guard let utc = raw["utcOffsetMinutes"] as? Int else {
113
+ throw TempivoSensorError(.invalidConfig, "schedule.utcOffsetMinutes is required.")
114
+ }
115
+ return TempivoSchedule(always: false, weekdays: weekdays, from: from, to: to, utcOffsetMinutes: utc)
116
+ }
117
+
118
+ private static func number(_ value: Any?) -> Double? {
119
+ if let d = value as? Double { return d }
120
+ if let n = value as? NSNumber { return n.doubleValue }
121
+ return nil
122
+ }
123
+ }
@@ -0,0 +1,78 @@
1
+ import Foundation
2
+
3
+ public struct TempivoSensorQr: Sendable, Equatable {
4
+ public let serial: String
5
+ public let pin: String
6
+ public let model: String?
7
+ public let sessionType: TempivoSensorSessionType
8
+ public let bluetoothMac: String
9
+ }
10
+
11
+ public enum TempivoSensorQrParser {
12
+ public static func normalizeSerial(_ value: String) -> String {
13
+ value.trimmingCharacters(in: .whitespacesAndNewlines)
14
+ .uppercased()
15
+ .replacingOccurrences(of: "[^A-Z0-9]", with: "", options: .regularExpression)
16
+ }
17
+
18
+ public static func bluetoothMacFromSerial(_ serial: String) throws -> String {
19
+ let key = normalizeSerial(serial)
20
+ guard key.count == 12, key.range(of: "^[0-9A-F]{12}$", options: .regularExpression) != nil else {
21
+ throw TempivoSensorError(.invalidQr, "Serial must be 12 hex characters.")
22
+ }
23
+ return stride(from: 0, to: 12, by: 2).map { i in
24
+ let start = key.index(key.startIndex, offsetBy: i)
25
+ let end = key.index(start, offsetBy: 2)
26
+ return String(key[start..<end])
27
+ }.joined(separator: ":")
28
+ }
29
+
30
+ public static let defaultModel = "HC7"
31
+
32
+ public static func sessionType(fromModel model: String?) -> TempivoSensorSessionType {
33
+ guard let model, !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
34
+ return .modern
35
+ }
36
+ let m = model.trimmingCharacters(in: .whitespacesAndNewlines).uppercased().replacingOccurrences(of: "-", with: "")
37
+ if m == "HC5" || m.hasPrefix("6") { return .legacy }
38
+ return .modern
39
+ }
40
+
41
+ public static func parse(_ json: String) throws -> TempivoSensorQr {
42
+ guard let data = json.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8),
43
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
44
+ else {
45
+ throw TempivoSensorError(.invalidQr, "QR is not valid JSON.")
46
+ }
47
+ let snRaw: String = {
48
+ if let s = obj["sn"] as? String { return s }
49
+ if let n = obj["sn"] as? NSNumber { return n.stringValue }
50
+ return ""
51
+ }()
52
+ let serial = normalizeSerial(snRaw)
53
+ let pin = pinFrom(obj)
54
+ guard serial.count >= 8 else {
55
+ throw TempivoSensorError(.invalidQr, "QR is missing serial (sn).")
56
+ }
57
+ guard !pin.isEmpty else {
58
+ throw TempivoSensorError(.invalidQr, "QR is missing PIN.")
59
+ }
60
+ let rawModel = (obj["model"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
61
+ let model = rawModel.isEmpty ? defaultModel : rawModel
62
+ let hex = serial.count == 12 ? serial : String(serial.suffix(12))
63
+ return TempivoSensorQr(
64
+ serial: serial,
65
+ pin: pin,
66
+ model: model,
67
+ sessionType: sessionType(fromModel: model),
68
+ bluetoothMac: try bluetoothMacFromSerial(hex)
69
+ )
70
+ }
71
+
72
+ private static func pinFrom(_ obj: [String: Any]) -> String {
73
+ let raw = obj["pin"] ?? obj["resetCode"]
74
+ if let s = raw as? String { return s.trimmingCharacters(in: .whitespacesAndNewlines) }
75
+ if let n = raw as? NSNumber { return n.stringValue }
76
+ return ""
77
+ }
78
+ }
@@ -0,0 +1,161 @@
1
+ import Foundation
2
+
3
+ #if canImport(TempivoSensorBridge)
4
+ import TempivoSensorBridge
5
+ #endif
6
+
7
+ /// GATT session: connect, read/write alert rules and schedule, trigger uplink, read lab calibration date.
8
+ public final class TempivoSensorSession: @unchecked Sendable {
9
+ private let lock = NSLock()
10
+ private var connected = false
11
+
12
+ #if canImport(TempivoSensorBridge)
13
+ private let bridge = TempivoSensorBridge()
14
+ #endif
15
+
16
+ public init() {}
17
+
18
+ public func connect(serial: String, bluetoothMac: String, pin: String, sessionType: TempivoSensorSessionType = .modern) throws {
19
+ guard let pinInt = Int32(pin.trimmingCharacters(in: .whitespacesAndNewlines)) else {
20
+ throw TempivoSensorError(.invalidPin, "PIN must be numeric.")
21
+ }
22
+ #if canImport(TempivoSensorBridge)
23
+ lock.lock()
24
+ defer { lock.unlock() }
25
+ bridge.initialize()
26
+ let result = parseBridgeJson(
27
+ bridge.connectPayloadJson(
28
+ serial: TempivoSensorQrParser.normalizeSerial(serial),
29
+ bluetoothMac: bluetoothMac,
30
+ pin: pinInt,
31
+ legacy: sessionType == .legacy
32
+ )
33
+ )
34
+ try throwIfBridgeError(result)
35
+ connected = true
36
+ #else
37
+ throw TempivoSensorError(.runtimeUnavailable, "Sensor GATT requires a physical device with the native runtime.")
38
+ #endif
39
+ }
40
+
41
+ public func connect(qr: TempivoSensorQr) throws {
42
+ try connect(serial: qr.serial, bluetoothMac: qr.bluetoothMac, pin: qr.pin, sessionType: qr.sessionType)
43
+ }
44
+
45
+ public func disconnect() {
46
+ #if canImport(TempivoSensorBridge)
47
+ lock.lock()
48
+ defer { lock.unlock() }
49
+ bridge.disconnect()
50
+ connected = false
51
+ #else
52
+ lock.lock()
53
+ connected = false
54
+ lock.unlock()
55
+ #endif
56
+ }
57
+
58
+ public func getConfiguration() throws -> TempivoSensorConfiguration {
59
+ try TempivoSensorProfile.parse(json: getConfigurationJson())
60
+ }
61
+
62
+ public func getConfigurationJson() throws -> String {
63
+ #if canImport(TempivoSensorBridge)
64
+ try requireConnected()
65
+ let result = parseBridgeJson(bridge.getConfigurationPayloadJson())
66
+ try throwIfBridgeError(result)
67
+ guard let json = result["configurationJson"] as? String else {
68
+ throw TempivoSensorError(.unknown, "Could not read configuration.")
69
+ }
70
+ return json
71
+ #else
72
+ throw TempivoSensorError(.runtimeUnavailable, "Sensor GATT requires a physical device with the native runtime.")
73
+ #endif
74
+ }
75
+
76
+ public func setConfiguration(_ configuration: TempivoSensorConfiguration) throws {
77
+ try setConfigurationJson(TempivoSensorProfile.jsonString(configuration))
78
+ }
79
+
80
+ public func setConfigurationJson(_ json: String) throws {
81
+ _ = try TempivoSensorProfile.parse(json: json)
82
+ #if canImport(TempivoSensorBridge)
83
+ try requireConnected()
84
+ let result = parseBridgeJson(bridge.setConfigurationPayloadJson(json: json))
85
+ try throwIfBridgeError(result)
86
+ #else
87
+ throw TempivoSensorError(.runtimeUnavailable, "Sensor GATT requires a physical device with the native runtime.")
88
+ #endif
89
+ }
90
+
91
+ public func triggerTransmission() throws -> TempivoTriggerTransmissionResult {
92
+ #if canImport(TempivoSensorBridge)
93
+ try requireConnected()
94
+ let result = parseBridgeJson(bridge.triggerTransmissionPayloadJson())
95
+ if let code = intValue(result["errorCode"]), code != 0 {
96
+ try throwIfBridgeError(result)
97
+ }
98
+ return TempivoTriggerTransmissionResult(
99
+ ok: (result["ok"] as? Bool) ?? false,
100
+ supported: (result["supported"] as? Bool) ?? false
101
+ )
102
+ #else
103
+ throw TempivoSensorError(.runtimeUnavailable, "Sensor GATT requires a physical device with the native runtime.")
104
+ #endif
105
+ }
106
+
107
+ public func getCalibration() throws -> TempivoSensorCalibration {
108
+ #if canImport(TempivoSensorBridge)
109
+ try requireConnected()
110
+ let result = parseBridgeJson(bridge.getCalibrationPayloadJson())
111
+ try throwIfBridgeError(result)
112
+ let ts = int64Value(result["laboratoryCalibrationTimestamp"])
113
+ return TempivoSensorCalibrationDecoder.decode(ts)
114
+ #else
115
+ throw TempivoSensorError(.runtimeUnavailable, "Sensor GATT requires a physical device with the native runtime.")
116
+ #endif
117
+ }
118
+
119
+ private func requireConnected() throws {
120
+ lock.lock()
121
+ let ok = connected
122
+ lock.unlock()
123
+ if !ok {
124
+ throw TempivoSensorError(.notConnected, "Not connected.")
125
+ }
126
+ }
127
+
128
+ private func parseBridgeJson(_ raw: String) -> [String: Any] {
129
+ guard let data = raw.data(using: .utf8),
130
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
131
+ else {
132
+ return ["errorCode": 7]
133
+ }
134
+ return obj
135
+ }
136
+
137
+ private func intValue(_ raw: Any?) -> Int? {
138
+ if let i = raw as? Int { return i }
139
+ if let n = raw as? NSNumber { return n.intValue }
140
+ return nil
141
+ }
142
+
143
+ private func int64Value(_ raw: Any?) -> Int64? {
144
+ if let i = raw as? Int64 { return i }
145
+ if let n = raw as? NSNumber { return n.int64Value }
146
+ return nil
147
+ }
148
+
149
+ private func throwIfBridgeError(_ result: [String: Any]) throws {
150
+ guard let code = intValue(result["errorCode"]), code != 0 else { return }
151
+ switch code {
152
+ case 1: throw TempivoSensorError(.invalidPin, "Invalid PIN.")
153
+ case 2: throw TempivoSensorError(.notConnected, "Not connected.")
154
+ case 3: throw TempivoSensorError(.unsupportedCommand, "Unsupported command.")
155
+ case 4: throw TempivoSensorError(.runtimeUnavailable, "Sensor GATT is not available on this device.")
156
+ case 5: throw TempivoSensorError(.connectFailed, "Could not connect.")
157
+ case 6: throw TempivoSensorError(.invalidConfig, (result["message"] as? String) ?? "Invalid configuration.")
158
+ default: throw TempivoSensorError(.unknown, "Sensor request failed.")
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,93 @@
1
+ import Foundation
2
+
3
+ public enum TempivoSensorSessionType: String, Sendable {
4
+ case modern
5
+ case legacy
6
+ }
7
+
8
+ public enum TempivoTemperatureChannel: String, Sendable {
9
+ case ambient
10
+ case probe
11
+ }
12
+
13
+ public struct TempivoTemperatureAlert: Sendable, Equatable {
14
+ public var type: String
15
+ public var channel: TempivoTemperatureChannel
16
+ public var lowC: Double?
17
+ public var highC: Double?
18
+ public var minC: Double?
19
+ public var maxC: Double?
20
+ public var hysteresisC: Double?
21
+ public var transmitOnBreach: Bool
22
+ public var transmitOnReturn: Bool
23
+
24
+ public init(
25
+ type: String,
26
+ channel: TempivoTemperatureChannel,
27
+ lowC: Double? = nil,
28
+ highC: Double? = nil,
29
+ minC: Double? = nil,
30
+ maxC: Double? = nil,
31
+ hysteresisC: Double? = nil,
32
+ transmitOnBreach: Bool = true,
33
+ transmitOnReturn: Bool = true
34
+ ) {
35
+ self.type = type
36
+ self.channel = channel
37
+ self.lowC = lowC
38
+ self.highC = highC
39
+ self.minC = minC
40
+ self.maxC = maxC
41
+ self.hysteresisC = hysteresisC
42
+ self.transmitOnBreach = transmitOnBreach
43
+ self.transmitOnReturn = transmitOnReturn
44
+ }
45
+ }
46
+
47
+ public struct TempivoSchedule: Sendable, Equatable {
48
+ public var always: Bool
49
+ public var weekdays: [Int]
50
+ public var from: String?
51
+ public var to: String?
52
+ public var utcOffsetMinutes: Int
53
+
54
+ public static let alwaysOn = TempivoSchedule(always: true, weekdays: [], from: nil, to: nil, utcOffsetMinutes: 0)
55
+
56
+ public init(always: Bool, weekdays: [Int] = [], from: String? = nil, to: String? = nil, utcOffsetMinutes: Int = 0) {
57
+ self.always = always
58
+ self.weekdays = weekdays
59
+ self.from = from
60
+ self.to = to
61
+ self.utcOffsetMinutes = utcOffsetMinutes
62
+ }
63
+ }
64
+
65
+ public struct TempivoSensorConfiguration: Sendable, Equatable {
66
+ public var temperatureAlerts: [TempivoTemperatureAlert]
67
+ public var schedule: TempivoSchedule
68
+
69
+ public init(temperatureAlerts: [TempivoTemperatureAlert], schedule: TempivoSchedule) {
70
+ self.temperatureAlerts = temperatureAlerts
71
+ self.schedule = schedule
72
+ }
73
+ }
74
+
75
+ public struct TempivoSensorCalibration: Sendable, Equatable {
76
+ public var laboratoryCalibrationDate: String?
77
+ public var laboratoryCalibrationTimestamp: Int64?
78
+
79
+ public init(laboratoryCalibrationDate: String?, laboratoryCalibrationTimestamp: Int64?) {
80
+ self.laboratoryCalibrationDate = laboratoryCalibrationDate
81
+ self.laboratoryCalibrationTimestamp = laboratoryCalibrationTimestamp
82
+ }
83
+ }
84
+
85
+ public struct TempivoTriggerTransmissionResult: Sendable, Equatable {
86
+ public var ok: Bool
87
+ public var supported: Bool
88
+
89
+ public init(ok: Bool, supported: Bool) {
90
+ self.ok = ok
91
+ self.supported = supported
92
+ }
93
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tempivo/sensor-beacon",
3
- "version": "0.1.1",
4
- "description": "Tempivo sensor BLE beacon SDK: TypeScript decoder plus iOS and Android scan libraries. Advertising only, no GATT.",
3
+ "version": "0.2.0",
4
+ "description": "Tempivo sensor BLE SDK: advertisement decode plus GATT session (config rules, transmission trigger, lab calibration date).",
5
5
  "homepage": "https://app.tempivo.com/integrations",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -29,8 +29,14 @@
29
29
  "build": "npm run build:ts",
30
30
  "build:ts": "tsc",
31
31
  "build:android": "node scripts/build-aar.mjs",
32
+ "build:ios-bridge": "bash scripts/build-ios-bridge.sh",
32
33
  "build:all": "npm run build:ts && npm run build:android",
33
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.mjs",
34
+ "prepare": "npm run build:ts",
35
+ "test": "npm run test:all",
36
+ "test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.mjs",
37
+ "test:python": "PYTHONPATH=python python3 -m pytest python/tests -q",
38
+ "test:all": "npm run test:unit && npm run test:python",
39
+ "test:smoke": "node scripts/smoke-test.mjs",
34
40
  "prepublishOnly": "npm run build:all"
35
41
  },
36
42
  "keywords": [
@@ -38,6 +44,7 @@
38
44
  "ble",
39
45
  "bluetooth",
40
46
  "beacon",
47
+ "gatt",
41
48
  "sensor",
42
49
  "manufacturer-data",
43
50
  "android",