Skip to content

Commit 3e61b60

Browse files
authored
Merge pull request #20 from MillerTechnologyPeru/feature/gatt-characteristic-plugins
Add GATT characteristic plugins for all 71 BluetoothGATT types
2 parents 24ae33c + 95a9062 commit 3e61b60

78 files changed

Lines changed: 3715 additions & 26 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Package.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ let package = Package(
8686
name: "BluetoothExplorerPluginEngineTests",
8787
dependencies: [
8888
"BluetoothExplorerPluginEngine",
89-
.product(name: "WAT", package: "WasmKit")
89+
.product(name: "WAT", package: "WasmKit"),
90+
// Oracle for the GATT characteristic plugins: the same bytes are run through
91+
// BluetoothGATT's own parsers and the plugins must agree on accept/reject.
92+
.product(name: "BluetoothGATT", package: "Bluetooth")
9093
]
9194
)
9295
]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Build the Battery Level plugin and install it into the app's bundled plugins.
2+
#
3+
# Requires the Embedded Swift WASM SDK:
4+
# swift sdk install <swift-<version>_wasm-embedded artifact bundle URL>
5+
# and binaryen for wasm-opt (brew install binaryen).
6+
7+
SWIFT_SDK ?= swift-6.3.3-RELEASE_wasm-embedded
8+
PRODUCT := GATTAlertNotification
9+
BUILT := .build/wasm32-unknown-wasip1/release/$(PRODUCT).wasm
10+
INSTALL := ../../../Sources/BluetoothExplorerPluginEngine/Plugins
11+
MODULE := gatt-alert-notification.wasm
12+
MANIFEST := $(INSTALL)/gatt-alert-notification.bleplugin.json
13+
14+
.PHONY: build opt install clean
15+
16+
build:
17+
swift build -c release --swift-sdk $(SWIFT_SDK) --product $(PRODUCT)
18+
19+
opt: build
20+
wasm-opt -Oz --enable-bulk-memory --enable-sign-ext $(BUILT) -o $(MODULE)
21+
22+
# Install the optimized module and refresh the manifest's sha256.
23+
install: opt
24+
cp $(MODULE) $(INSTALL)/$(MODULE)
25+
@HASH=$$(shasum -a 256 $(INSTALL)/$(MODULE) | awk '{print $$1}'); \
26+
/usr/bin/sed -i '' -E "s/\"sha256\" : \"[0-9a-f]*\"/\"sha256\" : \"$$HASH\"/; s/\"sha256\": \"[0-9a-f]*\"/\"sha256\": \"$$HASH\"/" $(MANIFEST); \
27+
echo "installed $(MODULE) sha256=$$HASH"
28+
29+
clean:
30+
rm -rf .build $(MODULE)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// swift-tools-version: 6.2
2+
// Example BluetoothExplorer parser plugin, written in Embedded Swift and compiled to wasm32.
3+
//
4+
// swift build -c release --swift-sdk swift-6.3.3-RELEASE_wasm-embedded --product GATTAlertNotification
5+
//
6+
import PackageDescription
7+
8+
let package = Package(
9+
name: "GATTAlertNotification",
10+
products: [
11+
.executable(name: "GATTAlertNotification", targets: ["GATTAlertNotification"])
12+
],
13+
dependencies: [
14+
.package(path: "../../BLEPluginSDK")
15+
],
16+
targets: [
17+
.executableTarget(
18+
name: "GATTAlertNotification",
19+
dependencies: [
20+
.product(name: "BLEPluginSDK", package: "BLEPluginSDK")
21+
],
22+
swiftSettings: [
23+
.enableExperimentalFeature("Embedded"),
24+
.enableExperimentalFeature("Extern"),
25+
.unsafeFlags(["-wmo"])
26+
],
27+
linkerSettings: [
28+
// Reactor model: no main(); the host calls _initialize then the exports.
29+
.unsafeFlags(["-Xclang-linker", "-mexec-model=reactor"])
30+
]
31+
)
32+
]
33+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// Exports.swift
3+
// GATTAlertNotification
4+
//
5+
// ABI export shims. These live in the plugin's own module because `@_expose(wasm)` symbols are
6+
// dropped when they come from a dependency module (swiftlang/swift#77812).
7+
//
8+
9+
import BLEPluginSDK
10+
11+
@_expose(wasm, "bleplug_abi_1")
12+
@_cdecl("bleplug_abi_1")
13+
func bleplugABIMarker() {}
14+
15+
@_expose(wasm, "bleplug_alloc")
16+
@_cdecl("bleplug_alloc")
17+
func bleplugAlloc(_ size: UInt32) -> UInt32 { PluginRuntime.allocate(size) }
18+
19+
@_expose(wasm, "bleplug_free")
20+
@_cdecl("bleplug_free")
21+
func bleplugFree(_ pointer: UInt32, _ size: UInt32) { PluginRuntime.deallocate(pointer) }
22+
23+
@_expose(wasm, "bleplug_parse_characteristic")
24+
@_cdecl("bleplug_parse_characteristic")
25+
func bleplugParseCharacteristic(_ pointer: UInt32, _ length: UInt32) -> UInt64 {
26+
PluginRuntime.handle(pointer: pointer, length: length, parse: parseCharacteristic)
27+
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
//
2+
// Plugin.swift
3+
// GATTAlertNotification
4+
//
5+
// Alert Notification service characteristics, ported from BluetoothGATT.
6+
//
7+
// 0x2A06 Alert Level 0x2A45 Unread Alert Status
8+
// 0x2A3F Alert Status 0x2A46 New Alert
9+
// 0x2A42 Alert Category ID Bit Mask 0x2A47 Supported New Alert Category
10+
// 0x2A43 Alert Category ID 0x2A48 Supported Unread Alert Category
11+
// 0x2A44 Alert Notification Control Point
12+
//
13+
14+
import BLEPluginSDK
15+
16+
func parseCharacteristic(_ input: ParseInput) -> Fields? {
17+
guard let uuid = input.uuid else { return nil }
18+
switch uuid.assignedNumber16 {
19+
case 0x2A06: return alertLevel(input)
20+
case 0x2A3F: return alertStatus(input)
21+
case 0x2A42: return alertCategoryBitMask(input, summary: "Alert Category ID Bit Mask")
22+
case 0x2A43: return alertCategory(input)
23+
case 0x2A44: return alertNotificationControlPoint(input)
24+
case 0x2A45: return unreadAlertStatus(input)
25+
case 0x2A46: return newAlert(input)
26+
case 0x2A47: return alertCategoryBitMask(input, summary: "Supported New Alert Category")
27+
case 0x2A48: return alertCategoryBitMask(input, summary: "Supported Unread Alert Category")
28+
default: return nil
29+
}
30+
}
31+
32+
// MARK: - Shared helpers
33+
34+
/// The `GATTAlertCategory` case name for a raw value, or `nil` for an undefined case.
35+
private func alertCategoryName(_ rawValue: UInt8) -> StaticString? {
36+
switch rawValue {
37+
case 0: return "Simple Alert"
38+
case 1: return "Email"
39+
case 2: return "News"
40+
case 3: return "Call"
41+
case 4: return "Missed Call"
42+
case 5: return "SMS/MMS"
43+
case 6: return "Voice Mail"
44+
case 7: return "Schedule"
45+
case 8: return "High Prioritized Alert"
46+
case 9: return "Instant Message"
47+
default: return nil
48+
}
49+
}
50+
51+
/// Port of `UInt64.init?(bitmaskArray:)`: little-endian, widening to the largest
52+
/// power-of-two prefix that fits, and `nil` for an empty value.
53+
private func bitmaskValue(_ payload: inout PayloadReader) -> UInt64? {
54+
let count = payload.remaining
55+
let width: Int
56+
if count == 8 {
57+
width = 8
58+
} else if count >= 4 {
59+
width = 4
60+
} else if count >= 2 {
61+
width = 2
62+
} else if count >= 1 {
63+
width = 1
64+
} else {
65+
return nil
66+
}
67+
var value: UInt64 = 0
68+
for index in 0..<width {
69+
guard let byte = payload.byte(at: index) else { return nil }
70+
value |= UInt64(byte) << (8 * UInt64(index))
71+
}
72+
return value
73+
}
74+
75+
/// Validate a UTF-8 byte sequence, matching `String(utf8:)`'s rejection of malformed input.
76+
private func isValidUTF8(_ buffer: UnsafeBufferPointer<UInt8>) -> Bool {
77+
var index = 0
78+
while index < buffer.count {
79+
let byte = buffer[index]
80+
var continuations = 0
81+
var minimum: UInt32 = 0
82+
var scalar: UInt32 = 0
83+
if byte < 0x80 {
84+
index += 1
85+
continue
86+
} else if byte >= 0xC2 && byte <= 0xDF {
87+
continuations = 1
88+
minimum = 0x80
89+
scalar = UInt32(byte & 0x1F)
90+
} else if byte >= 0xE0 && byte <= 0xEF {
91+
continuations = 2
92+
minimum = 0x800
93+
scalar = UInt32(byte & 0x0F)
94+
} else if byte >= 0xF0 && byte <= 0xF4 {
95+
continuations = 3
96+
minimum = 0x1_0000
97+
scalar = UInt32(byte & 0x07)
98+
} else {
99+
return false
100+
}
101+
guard index + continuations < buffer.count else { return false }
102+
for offset in 1...continuations {
103+
let next = buffer[index + offset]
104+
guard next >= 0x80, next <= 0xBF else { return false }
105+
scalar = (scalar << 6) | UInt32(next & 0x3F)
106+
}
107+
// Reject overlong encodings, surrogates and out-of-range scalars.
108+
guard scalar >= minimum, scalar <= 0x10_FFFF else { return false }
109+
guard scalar < 0xD800 || scalar > 0xDFFF else { return false }
110+
index += continuations + 1
111+
}
112+
return true
113+
}
114+
115+
// MARK: - Characteristics
116+
117+
/// Alert Level (0x2A06): one byte, `none` / `mild` / `high`.
118+
private func alertLevel(_ input: ParseInput) -> Fields? {
119+
var payload = input.payload
120+
guard payload.remaining == 1, let rawValue = payload.readUInt8() else { return nil }
121+
let name: StaticString
122+
switch rawValue {
123+
case 0x00: name = "No Alert"
124+
case 0x01: name = "Mild Alert"
125+
case 0x02: name = "High Alert"
126+
default: return nil
127+
}
128+
var fields = Fields(summary: "Alert Level")
129+
fields.string("alert_level", label: "Alert Level", name)
130+
return fields
131+
}
132+
133+
/// Alert Status (0x2A3F): one byte of state flags.
134+
private func alertStatus(_ input: ParseInput) -> Fields? {
135+
var payload = input.payload
136+
guard payload.remaining == 1, let rawValue = payload.readUInt8() else { return nil }
137+
var fields = Fields(summary: "Alert Status")
138+
fields.bool("ringer", label: "Ringer State", rawValue & 0b001 != 0)
139+
fields.bool("vibrate", label: "Vibrate State", rawValue & 0b010 != 0)
140+
fields.bool("display_alert", label: "Display Alert State", rawValue & 0b100 != 0)
141+
return fields
142+
}
143+
144+
/// Alert Category ID Bit Mask (0x2A42) and the two supported-category characteristics
145+
/// (0x2A47, 0x2A48), which reuse the same bit mask format.
146+
private func alertCategoryBitMask(_ input: ParseInput, summary: StaticString) -> Fields? {
147+
var payload = input.payload
148+
guard let bitmask = bitmaskValue(&payload) else { return nil }
149+
var fields = Fields(summary: summary)
150+
fields.bool("simple_alert", label: "Simple Alert", bitmask & 0b1 != 0)
151+
fields.bool("email", label: "Email", bitmask & 0b10 != 0)
152+
fields.bool("news", label: "News", bitmask & 0b100 != 0)
153+
fields.bool("call", label: "Call", bitmask & 0b1000 != 0)
154+
fields.bool("missed_call", label: "Missed Call", bitmask & 0b1_0000 != 0)
155+
fields.bool("sms", label: "SMS/MMS", bitmask & 0b10_0000 != 0)
156+
fields.bool("voice_mail", label: "Voice Mail", bitmask & 0b100_0000 != 0)
157+
fields.bool("schedule", label: "Schedule", bitmask & 0b1000_0000 != 0)
158+
fields.bool("high_prioritized", label: "High Prioritized Alert", bitmask & 0b1_0000_0000 != 0)
159+
fields.bool("instant_message", label: "Instant Message", bitmask & 0b10_0000_0000 != 0)
160+
return fields
161+
}
162+
163+
/// Alert Category ID (0x2A43): one byte enumerating the alert category.
164+
private func alertCategory(_ input: ParseInput) -> Fields? {
165+
var payload = input.payload
166+
guard payload.remaining == 1,
167+
let rawValue = payload.readUInt8(),
168+
let name = alertCategoryName(rawValue)
169+
else { return nil }
170+
var fields = Fields(summary: "Alert Category ID")
171+
fields.string("category", label: "Category", name)
172+
return fields
173+
}
174+
175+
/// Alert Notification Control Point (0x2A44): command id followed by category id.
176+
private func alertNotificationControlPoint(_ input: ParseInput) -> Fields? {
177+
var payload = input.payload
178+
guard payload.remaining == 2,
179+
let commandRawValue = payload.readUInt8(),
180+
let categoryRawValue = payload.readUInt8()
181+
else { return nil }
182+
let command: StaticString
183+
switch commandRawValue {
184+
case 0: command = "Enable New Incoming Alert Notification"
185+
case 1: command = "Enable Unread Category Status Notification"
186+
case 2: command = "Disable New Incoming Alert Notification"
187+
case 3: command = "Disable Unread Category Status Notification"
188+
case 4: command = "Notify New Incoming Alert Immediately"
189+
case 5: command = "Notify Unread Category Status Immediately"
190+
default: return nil
191+
}
192+
guard let category = alertCategoryName(categoryRawValue) else { return nil }
193+
var fields = Fields(summary: "Alert Notification Control Point")
194+
fields.string("command", label: "Command", command)
195+
fields.string("category", label: "Category", category)
196+
return fields
197+
}
198+
199+
/// Unread Alert Status (0x2A45): category id followed by an unread count.
200+
private func unreadAlertStatus(_ input: ParseInput) -> Fields? {
201+
var payload = input.payload
202+
guard payload.remaining == 2,
203+
let categoryRawValue = payload.readUInt8(),
204+
let unreadCount = payload.readUInt8(),
205+
let category = alertCategoryName(categoryRawValue)
206+
else { return nil }
207+
var fields = Fields(summary: "Unread Alert Status")
208+
fields.string("category", label: "Category", category)
209+
fields.uint("unread_count", label: "Unread Count", UInt64(unreadCount))
210+
return fields
211+
}
212+
213+
/// New Alert (0x2A46): category id, new alert count, then up to 18 bytes of UTF-8 text.
214+
private func newAlert(_ input: ParseInput) -> Fields? {
215+
var payload = input.payload
216+
guard payload.remaining >= 2,
217+
let categoryRawValue = payload.readUInt8(),
218+
let newAlertsCount = payload.readUInt8(),
219+
let category = alertCategoryName(categoryRawValue)
220+
else { return nil }
221+
// `GATTNewAlert.Information` accepts 0...18 UTF-8 bytes and rejects malformed text.
222+
guard payload.remaining <= 18 else { return nil }
223+
guard payload.remainingBytes({ isValidUTF8($0) }) else { return nil }
224+
225+
var fields = Fields(summary: "New Alert")
226+
fields.string("category", label: "Category", category)
227+
fields.uint("new_alerts_count", label: "New Alerts Count", UInt64(newAlertsCount))
228+
payload.remainingBytes { buffer in
229+
fields.text("information", label: "Information", utf8: buffer)
230+
}
231+
return fields
232+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Build the Battery Level plugin and install it into the app's bundled plugins.
2+
#
3+
# Requires the Embedded Swift WASM SDK:
4+
# swift sdk install <swift-<version>_wasm-embedded artifact bundle URL>
5+
# and binaryen for wasm-opt (brew install binaryen).
6+
7+
SWIFT_SDK ?= swift-6.3.3-RELEASE_wasm-embedded
8+
PRODUCT := GATTBattery
9+
BUILT := .build/wasm32-unknown-wasip1/release/$(PRODUCT).wasm
10+
INSTALL := ../../../Sources/BluetoothExplorerPluginEngine/Plugins
11+
MODULE := gatt-battery.wasm
12+
MANIFEST := $(INSTALL)/gatt-battery.bleplugin.json
13+
14+
.PHONY: build opt install clean
15+
16+
build:
17+
swift build -c release --swift-sdk $(SWIFT_SDK) --product $(PRODUCT)
18+
19+
opt: build
20+
wasm-opt -Oz --enable-bulk-memory --enable-sign-ext $(BUILT) -o $(MODULE)
21+
22+
# Install the optimized module and refresh the manifest's sha256.
23+
install: opt
24+
cp $(MODULE) $(INSTALL)/$(MODULE)
25+
@HASH=$$(shasum -a 256 $(INSTALL)/$(MODULE) | awk '{print $$1}'); \
26+
/usr/bin/sed -i '' -E "s/\"sha256\" : \"[0-9a-f]*\"/\"sha256\" : \"$$HASH\"/; s/\"sha256\": \"[0-9a-f]*\"/\"sha256\": \"$$HASH\"/" $(MANIFEST); \
27+
echo "installed $(MODULE) sha256=$$HASH"
28+
29+
clean:
30+
rm -rf .build $(MODULE)

0 commit comments

Comments
 (0)