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}},
}
Routing

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 calls action(code, "").
  • input_fields — text label → code. Entering text calls action(code, text) with the text unchanged.
  • combo_fields — label → {option: code}. Selecting calls action(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.