Authoring BLE Plugins
Model any Bluetooth device
A plugin is a single Python file that models one BLE
device. It consumes BLE frames and emits named
values, and can declare device metadata and command
hooks. The only requirement is
python3 on your PATH.
Where plugins live
Drop a .py file into the plugin
directory —
Settings › Device Plugins
shows the path (default
~/Library/Application
Support/com.pouroverconcepts.poseLab/plugins). It runs automatically; there is
no activate step. Every plugin appears as one device
in the Devices panel, ready to use.
-
Files whose name starts with
_are skipped — use them for shared helpers. - Source edits hot-reload automatically; Reload forces a retry.
Minimal plugin
def process(frame):
# frame = {"device_id", "characteristic", "timestamp_ms", "payload": [bytes...]}
return frame["timestamp_ms"] / 1000.0
Metadata (META)
Declare a module-level META dict to
give the device an identity and tell PoseLab which
BLE service and characteristics to scan for and
route. Every key is optional.
META = {
"display_name": "Cadence Sensor",
"version": "1.0.0",
"description": "Shows cycling cadence from connected sensor.",
"service_uuid": "2a48455b-6dc0-4433-84cc-615b5d63e2fe", # identity + scan filter
"data_char": "dee4d92a-1207-4a51-bc43-3b02e7c0f20d", # notify — the routing key
"command_char": "6f73a5b1-0107-4436-9f45-5b9920334bc4", # write target for actions
"stream_on_connect": True,
"is_pose_provider": False,
"actions": {"Start": 1, "Stop": 0},
"input_fields": {"Athlete tag": 20},
"combo_fields": {"Sample rate": {"10 Hz": 30, "20 Hz": 31, "50 Hz": 32}},
}
Frames are delivered only to plugins whose
data_char matches the frame's
characteristic (a plugin with no
data_char receives every
frame). UUIDs compare case- and
format-insensitively, and a 16-bit id like
"2a5b"
matches its full 128-bit form.
Return values from process
process(frame) may return any of:
- a scalar →
{"value": x} -
a dict →
{name: value, ...} - a list/tuple of scalars → indexed values
- a list of value dicts with display hints (preferred for rich output)
return [
{"name": "Cadence", "value": cadence, "color": "#FF0000", "unit": "°"},
]
Return None to emit nothing for a
frame. These named values are also the plugin's
explicit contract for custom metric
scripts: the last five seconds are available as
customDeviceData[device_id][timestamp_ms][name]. Raw BLE bytes, pose joints, and viz packets are
never exposed automatically.
Session-drawer controls
Declare controls that appear under
Session drawer › Tools › Device
Inputs; all dispatch through
action(code, value):
-
actions— button label → code. Clicking callsaction(code, ""). -
input_fields— text label → code. Entering text callsaction(code, text)with the text unchanged. -
combo_fields— label →{option: code}. Selecting callsaction(code, option_label).
Command hooks (action /
on_connect)
The plugin owns its command logic. Return a command
—
{"char": uuid, "payload": "<hex>"}
(or a list) — and PoseLab writes it to the
device. char defaults to
META["command_char"].
def action(code, value):
if code == 1: # Start
return {"payload": "20"}
if code == 0: # Stop
return {"payload": "00"}
return None
def on_connect():
return action(1, "") # stream_on_connect: begin streaming on connect
Timestamps
Every emitted sample carries a wall-clock timestamp (ms) so sensor data can be matched to camera frames later. By default it's the frame's capture time — free, no code needed. If your device reports its own more accurate time, override it with the wrapper form:
return {"t": device_time_ms, "values": [{"name": "Pitch", "value": pitch}]}
Runtime paths
The host injects four absolute-path variables as module globals, so a plugin can locate app directories to read companion data or write output. Reference them directly — no import:
| Variable | Points at |
|---|---|
app_data_dir |
the app-data root |
app_plugins_dir |
the plugin directory (where this file lives) |
app_metrics_dir |
the custom-metric-script directory |
session_dir |
the active session's media dir, or
"" if none
|
session_dir is "" until a
capture session is active and updates live, so read
it inside process, not at import time.
3D pose providers
A plugin with
"is_pose_provider": True may return a
full-skeleton 3D sample instead of named values
— a 17-entry list in COCO-17 order, each
[x, y, z] in metres (y-up), or
None for an untracked joint:
IMPORTANT: If "is_pose_provider" is used the video pose estimation pass will be bypassed in favor of the plugins approach so that there is always a single source of truth for pose information
return {"pose": joints, "t": frame["timestamp_ms"]} # t optional
The latest sample renders in the 3D viewport as a
sphere-and-bone skeleton. A pose provider can also
expose selected measurements at the same time by
adding a values field — only
those reach custom metric scripts.
BETA - Vizualization providers (shader-rendered data)
A plugin with
"is_viz_provider": True may return an
opaque byte packet that PoseLab
hands to a fragment shader you author — the
app never interprets the bytes, so a pressure grid,
a waveform, or a point list all work the same way:
return {
"viz": {"data": packet, # bytes, hex string, or list of 0-255 ints
"dims": [32, 32]}, # optional hint, passed to your shader untouched
"values": [{"name": "Peak", "value": peak}], # optional spreadsheet rows
}
Your shader is <plugin>.frag next
to the .py (Qt Quick 3D CustomMaterial
dialect). With no shader file PoseLab renders a
built-in heatmap. Packets are capped at 256 KB;
larger frames arrive as multiple BLE notifications
— give each chunk a small header and
reassemble in process, returning
None until the frame completes. See
the pressure-map example
for the full pattern.
Testing without hardware
Use the Devices panel's
Run Mock toggle to feed synthetic
frames matched to your active plugins'
data_char.
Output and errors route to Settings › Device Plugins › Developer Console. Scan BLE connects to real devices instead.