@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.
- package/README.md +195 -79
- package/android/library/build.gradle +3 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/PartnerBleRules.kt +279 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/SensorBleRuntime.kt +168 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconDecoder.kt +6 -23
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconScanner.kt +0 -2
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorBeaconTypes.kt +2 -4
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorCalibration.kt +36 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorError.kt +18 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorProfile.kt +171 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorQr.kt +82 -0
- package/android/library/src/main/java/com/tempivo/sensor/beacon/TempivoSensorSession.kt +114 -0
- package/android/settings.gradle +1 -0
- package/dist/calibration.d.ts +7 -0
- package/dist/calibration.js +42 -0
- package/dist/decoder.js +4 -12
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +8 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -1
- package/dist/profile.d.ts +10 -0
- package/dist/profile.js +226 -0
- package/dist/qr.d.ts +16 -0
- package/dist/qr.js +90 -0
- package/dist/session-types.d.ts +64 -0
- package/dist/session-types.js +1 -0
- package/dist/tempivo-sensor-beacon.aar +0 -0
- package/dist/types.d.ts +3 -3
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconDecoder.swift +4 -8
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorBeaconTypes.swift +3 -6
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorCalibration.swift +22 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorError.swift +24 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorProfile.swift +123 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorQr.swift +78 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSession.swift +161 -0
- package/ios/Sources/TempivoSensorBeacon/TempivoSensorSessionTypes.swift +93 -0
- 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
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Tempivo sensor BLE
|
|
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
|
-
"
|
|
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",
|