Skip to content

Commit b3a251d

Browse files
authored
Merge pull request #17 from MillerTechnologyPeru/feature/ibeacon-plugin
Add iBeacon advertisement plugin in Embedded Swift
2 parents d43573e + 704fa5f commit b3a251d

11 files changed

Lines changed: 379 additions & 1 deletion

File tree

Documentation/PluginABI.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ Definite-length, integer-keyed CBOR:
9797
```
9898

9999
- Byte fields use a CBOR byte string; UUID fields use tag 37 over a 16-byte string.
100+
- **Integers carry no signedness.** CBOR major type 0 encodes any non-negative integer and major
101+
type 1 any negative one, so a semantically signed field (say a dBm power level) arrives as
102+
`uint` whenever its value is non-negative. The host's `DecodedValue.int` / `.uint` cases
103+
therefore mean "negative" / "non-negative", and they compare and hash numerically — a native
104+
parser emitting `.int(0)` equals a plugin emitting `.uint(0)`. Do not rely on the case to
105+
recover the guest's original type.
100106
- The host decoder is strict: max depth 8, max 64 items per collection, max 1 KiB per string, and
101107
the total output must not exceed the manifest's `maxOutputBytes`.
102108

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 := IBeacon
9+
BUILT := .build/wasm32-unknown-wasip1/release/$(PRODUCT).wasm
10+
INSTALL := ../../../Sources/BluetoothExplorerPluginEngine/Plugins
11+
MODULE := ibeacon.wasm
12+
MANIFEST := $(INSTALL)/ibeacon.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 IBeacon
5+
//
6+
import PackageDescription
7+
8+
let package = Package(
9+
name: "IBeacon",
10+
products: [
11+
.executable(name: "IBeacon", targets: ["IBeacon"])
12+
],
13+
dependencies: [
14+
.package(path: "../../BLEPluginSDK")
15+
],
16+
targets: [
17+
.executableTarget(
18+
name: "IBeacon",
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: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// Exports.swift
3+
// IBeacon
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). Copied unchanged from
7+
// the SDK template, with only the capability shims this plugin implements.
8+
//
9+
10+
import BLEPluginSDK
11+
12+
@_expose(wasm, "bleplug_abi_1")
13+
@_cdecl("bleplug_abi_1")
14+
func bleplugABIMarker() {}
15+
16+
@_expose(wasm, "bleplug_alloc")
17+
@_cdecl("bleplug_alloc")
18+
func bleplugAlloc(_ size: UInt32) -> UInt32 {
19+
PluginRuntime.allocate(size)
20+
}
21+
22+
@_expose(wasm, "bleplug_free")
23+
@_cdecl("bleplug_free")
24+
func bleplugFree(_ pointer: UInt32, _ size: UInt32) {
25+
PluginRuntime.deallocate(pointer)
26+
}
27+
28+
@_expose(wasm, "bleplug_parse_manufacturer")
29+
@_cdecl("bleplug_parse_manufacturer")
30+
func bleplugParseManufacturer(_ pointer: UInt32, _ length: UInt32) -> UInt64 {
31+
PluginRuntime.handle(pointer: pointer, length: length, parse: parseManufacturerData)
32+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// Plugin.swift
3+
// IBeacon
4+
//
5+
// Decodes Apple iBeacon advertisements from manufacturer-specific data.
6+
//
7+
// Payload layout (after the company identifier, which the host strips into the envelope header):
8+
// [0] type = 0x02
9+
// [1] length = 0x15 (21)
10+
// [2..17] proximity UUID, big-endian
11+
// [18..19] major, big-endian
12+
// [20..21] minor, big-endian
13+
// [22] measured power at 1m, signed
14+
//
15+
// Field keys and labels intentionally match NativeIBeaconParser so the two can be diffed.
16+
//
17+
18+
import BLEPluginSDK
19+
20+
private let appleCompanyIdentifier: UInt16 = 0x004C
21+
private let iBeaconType: UInt8 = 0x02
22+
private let iBeaconLength: UInt8 = 0x15
23+
24+
func parseManufacturerData(_ input: ParseInput) -> Fields? {
25+
guard input.companyIdentifier == appleCompanyIdentifier else { return nil }
26+
27+
var payload = input.payload
28+
// Apple ships several manufacturer-data formats under this company id; only 0x02/0x15 is
29+
// an iBeacon. Anything else is declined so other parsers (or the raw view) can handle it.
30+
guard payload.remaining == 23,
31+
payload.readUInt8() == iBeaconType,
32+
payload.readUInt8() == iBeaconLength,
33+
let uuid = payload.readUUID(),
34+
let major = payload.readUInt16BigEndian(),
35+
let minor = payload.readUInt16BigEndian(),
36+
let measuredPower = payload.readInt8()
37+
else { return nil }
38+
39+
var fields = Fields(summary: "iBeacon")
40+
fields.uuid("uuid", label: "Proximity UUID", uuid)
41+
fields.uint("major", label: "Major", UInt64(major))
42+
fields.uint("minor", label: "Minor", UInt64(minor))
43+
fields.int("tx_power", label: "Measured Power", Int64(measuredPower), unit: "dBm")
44+
return fields
45+
}

PluginSDK/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ and `remainingBytes(_:)`.
5555
(compile-time literal), `text` (UTF-8 from the payload), `bytes` (rendered as hex by the app), and
5656
`uuid`. Keys, labels and units are `StaticString` so they cost no allocation.
5757

58+
Note that CBOR does not carry signedness: a value written with `int` arrives on the host as an
59+
unsigned value whenever it is non-negative. The host compares integers numerically, so this does
60+
not affect correctness or display — but do not expect the signed/unsigned distinction to survive
61+
the boundary.
62+
5863
## Building
5964

6065
```sh

Sources/BluetoothExplorerPluginEngine/DecodedField.swift

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public struct DecodedField: Equatable, Hashable, Sendable, Identifiable {
3434
}
3535

3636
/// A typed decoded value. Mirrors the value types carried across the WASM ABI (CBOR).
37-
public enum DecodedValue: Equatable, Hashable, Sendable {
37+
public enum DecodedValue: Sendable {
3838
case string(String)
3939
case int(Int64)
4040
case uint(UInt64)
@@ -45,6 +45,53 @@ public enum DecodedValue: Equatable, Hashable, Sendable {
4545
case uuid(UUID)
4646
}
4747

48+
extension DecodedValue: Equatable, Hashable {
49+
50+
/// Integers compare numerically across `int` and `uint`.
51+
///
52+
/// CBOR has no notion of signedness: major type 0 encodes *any* non-negative integer, so a
53+
/// plugin field that is semantically signed arrives as `uint` whenever its value happens to be
54+
/// non-negative. The cases therefore mean "negative" and "non-negative", not "signed" and
55+
/// "unsigned", and two producers of the same number — a native parser and a WASM plugin — must
56+
/// compare equal. The UI renders both identically.
57+
public static func == (lhs: DecodedValue, rhs: DecodedValue) -> Bool {
58+
switch (lhs, rhs) {
59+
case let (.string(a), .string(b)): return a == b
60+
case let (.int(a), .int(b)): return a == b
61+
case let (.uint(a), .uint(b)): return a == b
62+
case let (.int(a), .uint(b)): return a >= 0 && UInt64(a) == b
63+
case let (.uint(a), .int(b)): return b >= 0 && a == UInt64(b)
64+
case let (.double(a), .double(b)): return a == b
65+
case let (.bool(a), .bool(b)): return a == b
66+
case let (.bytes(a), .bytes(b)): return a == b
67+
case let (.uuid(a), .uuid(b)): return a == b
68+
default: return false
69+
}
70+
}
71+
72+
public func hash(into hasher: inout Hasher) {
73+
switch self {
74+
case let .string(value):
75+
hasher.combine(0); hasher.combine(value)
76+
// Both integer cases share a discriminator, and non-negative values hash through the same
77+
// UInt64 path, so numerically equal values hash equally as Hashable requires.
78+
case let .int(value):
79+
hasher.combine(1)
80+
if value >= 0 { hasher.combine(UInt64(value)) } else { hasher.combine(value) }
81+
case let .uint(value):
82+
hasher.combine(1); hasher.combine(value)
83+
case let .double(value):
84+
hasher.combine(2); hasher.combine(value)
85+
case let .bool(value):
86+
hasher.combine(3); hasher.combine(value)
87+
case let .bytes(value):
88+
hasher.combine(4); hasher.combine(value)
89+
case let .uuid(value):
90+
hasher.combine(5); hasher.combine(value)
91+
}
92+
}
93+
}
94+
4895
/// The result of a plugin decoding one advertisement field or attribute value.
4996
public struct DecodedResult: Equatable, Hashable, Sendable {
5097

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"manifestVersion": 1,
3+
"id": "org.pureswift.plugin.ibeacon",
4+
"name": "iBeacon",
5+
"version": "1.0.0",
6+
"abi": 1,
7+
"module": "ibeacon.wasm",
8+
"sha256": "fb1d79bee5f78713ce8d0c9df4528508f7dcbc4bf73fc1cec0b2825b87c8aa4a",
9+
"matches": {
10+
"companyIdentifiers": [76]
11+
},
12+
"limits": { "maxMemoryPages": 16, "maxOutputBytes": 512 }
13+
}
94.9 KB
Binary file not shown.

Tests/BluetoothExplorerPluginEngineTests/CoreTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,42 @@ struct RegistryTests {
146146
#expect(battery?.fields.first?.value == .uint(50))
147147
}
148148
}
149+
150+
@Suite("DecodedValue integer semantics")
151+
struct DecodedValueTests {
152+
153+
// CBOR cannot distinguish a non-negative signed integer from an unsigned one, so a plugin's
154+
// signed field arrives as `uint` whenever its value is non-negative. Native and WASM producers
155+
// of the same number must still compare equal.
156+
157+
@Test("Non-negative int and uint compare equal")
158+
func numericEquality() {
159+
#expect(DecodedValue.int(0) == DecodedValue.uint(0))
160+
#expect(DecodedValue.int(127) == DecodedValue.uint(127))
161+
#expect(DecodedValue.uint(42) == DecodedValue.int(42))
162+
#expect(DecodedValue.int(Int64.max) == DecodedValue.uint(UInt64(Int64.max)))
163+
}
164+
165+
@Test("Negative values never equal an unsigned value")
166+
func negativeInequality() {
167+
#expect(DecodedValue.int(-1) != DecodedValue.uint(1))
168+
#expect(DecodedValue.int(-59) != DecodedValue.uint(UInt64(bitPattern: -59)))
169+
// A uint beyond Int64.max has no signed counterpart.
170+
#expect(DecodedValue.uint(UInt64(Int64.max) + 1) != DecodedValue.int(Int64.min))
171+
}
172+
173+
@Test("Equal values hash equally")
174+
func hashing() {
175+
#expect(DecodedValue.int(7).hashValue == DecodedValue.uint(7).hashValue)
176+
// Hashable's contract: equal values must collapse in a Set.
177+
#expect(Set([DecodedValue.int(7), DecodedValue.uint(7)]).count == 1)
178+
#expect(Set([DecodedValue.int(-7), DecodedValue.uint(7)]).count == 2)
179+
}
180+
181+
@Test("Other cases stay distinct")
182+
func otherCases() {
183+
#expect(DecodedValue.int(1) != DecodedValue.double(1))
184+
#expect(DecodedValue.uint(1) != DecodedValue.bool(true))
185+
#expect(DecodedValue.string("1") != DecodedValue.uint(1))
186+
}
187+
}

0 commit comments

Comments
 (0)