|
| 1 | +# BLE Parser Plugin SDK (Embedded Swift) |
| 2 | + |
| 3 | +Write BluetoothExplorer parser plugins in **Embedded Swift**, compiled to a small wasm32 module the |
| 4 | +app loads at runtime. A plugin decodes one kind of Bluetooth payload — manufacturer data, service |
| 5 | +data, a characteristic or a descriptor value — into labelled fields the app displays. |
| 6 | + |
| 7 | +- `BLEPluginSDK/` — the guest SDK: envelope decoding, a payload cursor, and a CBOR field builder. |
| 8 | +- `Examples/BatteryLevel/` — a complete plugin (GATT Battery Level, `0x2A19`) you can copy. |
| 9 | +- `../Documentation/PluginABI.md` — the normative wire contract. |
| 10 | + |
| 11 | +## Prerequisites |
| 12 | + |
| 13 | +```sh |
| 14 | +swift sdk list # need swift-<version>_wasm-embedded |
| 15 | +brew install binaryen # for wasm-opt |
| 16 | +``` |
| 17 | + |
| 18 | +If the embedded SDK is missing, install the `wasm-embedded` Swift SDK artifact bundle matching your |
| 19 | +toolchain with `swift sdk install <url>`. |
| 20 | + |
| 21 | +## Writing a plugin |
| 22 | + |
| 23 | +The only file you write is a pure function from `ParseInput` to `Fields?`. Return `nil` for input |
| 24 | +your plugin does not recognize — that is not an error, the app simply falls back. |
| 25 | + |
| 26 | +```swift |
| 27 | +import BLEPluginSDK |
| 28 | + |
| 29 | +func parseCharacteristic(_ input: ParseInput) -> Fields? { |
| 30 | + guard input.uuid?.assignedNumber16 == 0x2A19 else { return nil } |
| 31 | + var payload = input.payload |
| 32 | + guard let level = payload.readUInt8() else { return nil } |
| 33 | + |
| 34 | + var fields = Fields(summary: "Battery") |
| 35 | + fields.uint("level", label: "Battery Level", UInt64(level), unit: "%") |
| 36 | + return fields |
| 37 | +} |
| 38 | +``` |
| 39 | + |
| 40 | +`Exports.swift` in the example is boilerplate you copy unchanged — it declares the ABI exports and |
| 41 | +forwards them to `PluginRuntime`. It lives in *your* module rather than the SDK because |
| 42 | +`@_expose(wasm)` symbols are dropped when they come from a dependency module |
| 43 | +(swiftlang/swift#77812). Delete the capability shims your plugin does not implement; the host only |
| 44 | +invokes exports that are present. |
| 45 | + |
| 46 | +### Reading the payload |
| 47 | + |
| 48 | +`PayloadReader` is an allocation-free cursor: `readUInt8`, `readInt8`, `readUInt16BigEndian`, |
| 49 | +`readUInt16LittleEndian`, `readUInt32LittleEndian`, `readUUID`, `readBytes(_:_:)`, plus `remaining` |
| 50 | +and `remainingBytes(_:)`. |
| 51 | + |
| 52 | +### Emitting fields |
| 53 | + |
| 54 | +`Fields` builds the CBOR result incrementally: `uint`, `int`, `double`, `bool`, `string` |
| 55 | +(compile-time literal), `text` (UTF-8 from the payload), `bytes` (rendered as hex by the app), and |
| 56 | +`uuid`. Keys, labels and units are `StaticString` so they cost no allocation. |
| 57 | + |
| 58 | +## Building |
| 59 | + |
| 60 | +```sh |
| 61 | +cd Examples/BatteryLevel |
| 62 | +make # build only |
| 63 | +make install # build, wasm-opt -Oz, copy into the app, refresh the manifest sha256 |
| 64 | +``` |
| 65 | + |
| 66 | +`make install` writes the module into |
| 67 | +`Sources/BluetoothExplorerPluginEngine/Plugins/` and updates the manifest hash, so the app picks it |
| 68 | +up on next launch. Expect roughly 90–110 KB per plugin after `wasm-opt -Oz`. |
| 69 | + |
| 70 | +## The manifest |
| 71 | + |
| 72 | +Every module needs a `<name>.bleplugin.json` sidecar telling the host what to route to it. The host |
| 73 | +never runs a plugin for data it did not declare: |
| 74 | + |
| 75 | +```json |
| 76 | +{ |
| 77 | + "manifestVersion": 1, |
| 78 | + "id": "org.example.plugin.battery-level", |
| 79 | + "name": "Battery Level", |
| 80 | + "version": "1.0.0", |
| 81 | + "abi": 1, |
| 82 | + "module": "battery-level.wasm", |
| 83 | + "sha256": "<filled in by make install>", |
| 84 | + "matches": { "characteristicUUIDs": ["2A19"] }, |
| 85 | + "limits": { "maxMemoryPages": 16, "maxOutputBytes": 256 } |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +## What plugins can and cannot do |
| 90 | + |
| 91 | +Plugins are pure decoders. The host runs them under a WebAssembly interpreter with: |
| 92 | + |
| 93 | +- no filesystem, network, environment or clock access — the only host capability provided is |
| 94 | + randomness (`random_get`), which the Embedded Swift runtime requires; |
| 95 | +- a linear-memory cap from `limits.maxMemoryPages`; |
| 96 | +- a wall-clock deadline per call — a plugin that hangs is quarantined for the session; |
| 97 | +- output validated against a strict CBOR subset and `limits.maxOutputBytes`. |
| 98 | + |
| 99 | +Your parse function must be a **pure function of its input**: the host may cache results for |
| 100 | +identical payloads and may recycle your instance between calls. |
0 commit comments