Plugin Guide¶
A camera.ui plugin is a Python package the host loads at runtime to extend cameras with new capabilities — a detection model, a vendor camera integration, a notifier, a smart-home bridge. This guide is the single reference for shipping one. You should be comfortable with modern Python (3.11+) and have the SDK installed (pip install camera-ui-sdk).
1. Plugin anatomy¶
A plugin is a folder with two files the host expects:
contract.py— the manifest. A static object describing what the plugin is.main.py— the runtime. A class extendingBasePlugin, returned by a__main__()factory.
The minimal runnable plugin:
# contract.py
from camera_ui_sdk import PluginContract, PluginRole
contract: PluginContract = {
"name": "My Plugin",
"role": PluginRole.SensorProvider,
"provides": [],
"consumes": [],
"interfaces": [],
}
# main.py
from camera_ui_sdk import BasePlugin, CameraDevice
class MyPlugin(BasePlugin):
async def configureCameras(self, cameraDevices: list[CameraDevice]) -> None: ...
async def onCameraAdded(self, camera: CameraDevice) -> None: ...
async def onCameraReleased(self, cameraId: str) -> None: ...
def __main__() -> type[MyPlugin]:
return MyPlugin
The host instantiates the class with three arguments: a LoggerService, a PluginAPI, and a typed DeviceStorage. Everything else — sensors, discovery, schemas — is opt-in.
2. The contract¶
PluginContract is the static manifest the host reads before starting the plugin. The fields:
name— stable identifier; doubles as log prefix and storage namespace.role— what the plugin does at the highest level (see table below).provides— sensor types the plugin attaches to cameras (e.g.[SensorType.Motion]).consumes— sensor types the plugin reads from other plugins.interfaces— capability flags (DiscoveryProvider,Notifier, detection types, …).pythonVersion(optional) —"3.11"or"3.12". The host picks a matching interpreter from its venv pool. Ignored by Node / Go plugins.dependencies(optional) — extra PyPI packages installed into the plugin's venv at adoption time.
| Role | Use when |
|---|---|
SensorProvider |
You add detection or smart-home sensors to existing cameras (motion plugin, classifier, contact sensor). |
CameraController |
You bring your own cameras and own their streams (RTSP, ONVIF, vendor SDK), but produce no sensors. |
CameraAndSensorProvider |
You bring your own cameras AND want to attach sensors to them. Most vendor integrations land here. |
Hub |
Cloud-service integration that owns its cameras end-to-end via a vendor account, OR a bridge plugin that consumes other plugins' sensors and forwards them to an external system (HomeKit, MQTT, automations) or implements a Notifier. |
Examples:
from camera_ui_sdk import PluginInterface, PluginRole, SensorType
# Detection plugin attaching motion sensors
{
"name": "My Motion",
"role": PluginRole.SensorProvider,
"provides": [SensorType.Motion],
"consumes": [],
"interfaces": [PluginInterface.MotionDetection],
}
# ONVIF-style integration with discovery
{
"name": "ACME Cameras",
"role": PluginRole.CameraAndSensorProvider,
"provides": [SensorType.Motion],
"consumes": [],
"interfaces": [PluginInterface.DiscoveryProvider],
}
# HomeKit bridge consuming sensor state from other plugins
{
"name": "HomeKit Bridge",
"role": PluginRole.Hub,
"provides": [],
"consumes": [SensorType.Motion, SensorType.Doorbell],
"interfaces": [],
}
3. The plugin class¶
BasePlugin[StorageT] is generic over your storage shape so self.storage.values["X"] is typed. Its constructor takes the three host-injected dependencies in order: logger, api, storage.
from typing import TypedDict
from camera_ui_sdk import API_EVENT, BasePlugin, CameraDevice, DeviceStorage, LoggerService, PluginAPI
class MyStorage(TypedDict):
pollIntervalSec: int
class MyPlugin(BasePlugin[MyStorage]):
def __init__(self, logger: LoggerService, api: PluginAPI, storage: DeviceStorage[MyStorage]) -> None:
super().__init__(logger, api, storage)
self.state: dict[str, MyCameraState] = {}
self.api.on(API_EVENT.SHUTDOWN, self._shutdown)
async def configureCameras(self, cameraDevices: list[CameraDevice]) -> None:
for camera in cameraDevices:
await self._attach(camera)
async def onCameraAdded(self, camera: CameraDevice) -> None:
await self._attach(camera)
async def onCameraReleased(self, cameraId: str) -> None:
if state := self.state.pop(cameraId, None):
state.dispose()
async def _attach(self, camera: CameraDevice) -> None: ...
def _shutdown(self) -> None: ... # drop timers, close sockets
Things to internalize:
configureCamerasruns once at startup with the cameras already assigned to the plugin. A raised exception aborts plugin startup.onCameraAddedruns whenever the user assigns a new camera at runtime. Set up the same per-camera state asconfigureCameras.onCameraReleasedruns when a camera is removed or reassigned. Drop timers, close vendor sessions.- A
dict[str, X]keyed bycamera.idis the conventional pattern for per-camera state — cheap to look up, trivial to clean up. API_EVENT.SHUTDOWNfires when the host tears the plugin down (reload, server stop). Listeners must release everything synchronously enough for the host to stop the process. There is alsoAPI_EVENT.FINISH_LAUNCHING, fired once afterconfigureCamerasreturns — useful for kicking off background work that should wait until the camera set is stable.
4. Adding sensors to cameras¶
A sensor is the unit of state the host (and other plugins) sees on a camera. Detection sensors push results from analyzing video; control sensors expose user-toggleable hardware (lights, sirens, locks); event sensors fire one-shot triggers (doorbell).
For detection, subclass the matching *DetectorSensor and implement the detect method. The host pushes one frame at the configured rate:
from camera_ui_sdk import MotionDetectorSensor, MotionResult, VideoFrameData
class MyMotionSensor(MotionDetectorSensor):
def __init__(self) -> None:
super().__init__("My Motion")
async def detectMotion(self, frame: VideoFrameData) -> MotionResult:
# analyze frame["data"], return detections
return {"detected": False, "detections": []}
Then attach it from the plugin's _attach() helper:
Other detector base classes follow the same shape. ObjectDetectorSensor.detectObjects(frame) takes a single frame; FaceDetectorSensor.detectFaces(frames), LicensePlateDetectorSensor.detectLicensePlates(frames), ClassifierDetectorSensor.detectClassifications(frames), ClipDetectorSensor.detectEmbeddings(frames) all take a batch (list[VideoFrameData]); AudioDetectorSensor.detectAudio(audio) takes one AudioFrameData. The frame-based detectors also expose an abstract modelSpec property — return the input dimensions and (for classifier) the trigger labels. Smart-home sensors expose semantic methods instead — LightControl gives you setOn() / setOff() / setBrightness(value), ContactSensor gives you setDetected(value), DoorbellTrigger gives you trigger(). You construct them, call camera.addSensor, and then call those methods when your hardware reports a change.
The host removes sensors automatically when a camera is released. Your onCameraReleased hook just needs to drop your reference to it.
5. Storage and configuration schema¶
User-facing settings live in storage. Storage is split into two scopes — plugin-level and sensor-level — each with its own storage_schema property that returns a list of JSON schema dicts. The host renders the schemas as form fields and persists the values; you read them via self.storage.values["X"] (or await self.storage.getValue("X", default)).
Plugin-level schemas appear on the plugin's settings page. Override the property on your BasePlugin subclass.
from camera_ui_sdk import JsonSchema
class MyPlugin(BasePlugin[MyStorage]):
@property
def storage_schema(self) -> list[JsonSchema]:
return [
{
"type": "number",
"key": "pollIntervalSec",
"title": "Poll interval (seconds)",
"description": "How often background work runs",
"defaultValue": 30,
"minimum": 5,
"maximum": 300,
"step": 5,
"store": True,
"required": True,
"onSet": self._on_interval_changed,
},
]
async def _on_interval_changed(self, new_value: object, old_value: object) -> None:
self.logger.log(f"Poll interval {old_value} -> {new_value}")
self._reschedule()
Sensor-level schemas appear on the camera detail page next to that one sensor. Override the property on your Sensor subclass.
from typing import Literal, TypedDict
from camera_ui_sdk import JsonSchema, MotionDetectorSensor, MotionResult, VideoFrameData
class ConfigurableMotionStorage(TypedDict):
sensitivity: int
mode: Literal["fast", "accurate"]
class ConfigurableMotion(MotionDetectorSensor[ConfigurableMotionStorage]):
@property
def storage_schema(self) -> list[JsonSchema]:
return [
{
"type": "number",
"key": "sensitivity",
"title": "Sensitivity",
"description": "Higher = trigger on smaller motion",
"defaultValue": 50,
"minimum": 0, "maximum": 100, "step": 1,
"store": True,
"onSet": lambda *_: self._reconfigure(),
},
{
"type": "string",
"key": "mode",
"title": "Mode",
"description": "Trade-off between speed and accuracy",
"defaultValue": "fast",
"enum": ["fast", "accurate"],
"store": True,
},
{
"type": "button",
"key": "reset",
"title": "Reset to defaults",
"description": "Restore sensitivity / mode",
"color": "danger",
"onSet": self._reset_defaults,
},
]
async def _reset_defaults(self) -> None:
await self.storage.setValue("sensitivity", 50)
await self.storage.setValue("mode", "fast")
async def detectMotion(self, frame: VideoFrameData) -> MotionResult:
sensitivity = self.storage.values.get("sensitivity", 50)
_ = sensitivity
return {"detected": False, "detections": []}
def _reconfigure(self) -> None: ... # re-warm caches
Field types:
number— slider/input with optionalminimum,maximum,step.string— text input; add"enum": [...]for a dropdown,"format": "password"to mask,"format": "image"/"qrCode"for media display.boolean— toggle.button— firesonSet()on click; stores no value. Useful for actions like "Test connection" or "Reset".
onSet(new_value, old_value) runs after the host has persisted the new value. Use it to re-warm caches, restart sessions, or anything else that depends on the changed setting. It can be sync or async, but the host doesn't block UI on it — keep work scoped to the plugin.
For the full schema reference (conditional visibility, submit handlers with toast feedback, array fields), see camera_ui_sdk/storage/__init__.py in the SDK.
6. Optional interfaces¶
BasePlugin covers the lifecycle. Specific capabilities are unlocked by implementing one of the optional interfaces and listing it in contract["interfaces"]. The rest of this section shows each one with a working snippet.
6.1 DiscoveryProvider¶
Let users scan and adopt cameras. Three methods: onDiscoverCameras returns adoption candidates, onGetCameraSettings returns the schema for the adoption form, onAdoptCamera resolves the form values into a CameraConfig for the host to persist. Available only for camera-controlling roles (CameraController, CameraAndSensorProvider).
from urllib.parse import quote
from typing import TypedDict
from camera_ui_sdk import (
API_EVENT, BasePlugin, CameraConfig, CameraDevice, DeviceStorage,
DiscoveredCamera, DiscoveryProvider, JsonSchemaWithoutCallbacks,
LoggerService, PluginAPI,
)
class FakeDevice(TypedDict):
id: str
name: str
manufacturer: str
model: str
host: str
FAKE_DEVICES: list[FakeDevice] = [
{"id": "fake-001", "name": "Front Door", "manufacturer": "ACME", "model": "X1", "host": "192.0.2.10"},
]
class CameraProvider(BasePlugin, DiscoveryProvider):
def __init__(self, logger: LoggerService, api: PluginAPI, storage: DeviceStorage) -> None:
super().__init__(logger, api, storage)
self.cameras: dict[str, CameraDevice] = {}
self.api.on(API_EVENT.SHUTDOWN, self._on_shutdown)
async def configureCameras(self, cameraDevices: list[CameraDevice]) -> None:
for c in cameraDevices:
self.cameras[c.id] = c
async def onCameraAdded(self, camera: CameraDevice) -> None:
self.cameras[camera.id] = camera
async def onCameraReleased(self, cameraId: str) -> None:
self.cameras.pop(cameraId, None)
def _on_shutdown(self) -> None:
self.cameras.clear()
async def onDiscoverCameras(self) -> list[DiscoveredCamera]:
adopted = {c.nativeId for c in self.cameras.values()}
return [
{"id": d["id"], "name": d["name"], "manufacturer": d["manufacturer"], "model": d["model"]}
for d in FAKE_DEVICES if d["id"] not in adopted
]
async def onGetCameraSettings(self, camera: DiscoveredCamera) -> list[JsonSchemaWithoutCallbacks]:
return [
{"type": "string", "key": "username", "title": "Username", "description": "", "required": True},
{"type": "string", "key": "password", "title": "Password", "description": "",
"format": "password", "required": True},
]
async def onAdoptCamera(self, camera: DiscoveredCamera, cameraSettings: dict[str, object]) -> CameraConfig:
device = next((d for d in FAKE_DEVICES if d["id"] == camera["id"]), None)
if device is None:
raise ValueError(f"Unknown device: {camera['id']}")
u = quote(str(cameraSettings.get("username", "")))
p = quote(str(cameraSettings.get("password", "")))
return {
"name": device["name"],
"nativeId": device["id"],
"info": {"manufacturer": device["manufacturer"], "model": device["model"]},
"sources": [{
"name": "main",
"role": "high-resolution",
"urls": [f"rtsp://{u}:{p}@{device['host']}/stream0"],
"useForSnapshot": True,
"hotMode": True,
"preload": True,
}],
}
For asynchronous discovery (cloud OAuth callbacks, mDNS bursts), you can also push candidates directly into the UI without waiting for the next poll: await self.api.deviceManager.pushDiscoveredCameras([...]).
6.2 NotifierInterface¶
Register as a notification target so the host's NotificationManager can dispatch through you. The plugin owns its device list — the manager queries through these methods rather than maintaining a shared registry.
import uuid
from datetime import datetime, timezone
from typing import Any
from camera_ui_sdk import (
API_EVENT, BasePlugin, CameraDevice, DeviceStorage, LoggerService,
Notification, NotifierDevice, NotifierInterface, PluginAPI,
)
class MyNotifier(BasePlugin, NotifierInterface):
def __init__(self, logger: LoggerService, api: PluginAPI, storage: DeviceStorage) -> None:
super().__init__(logger, api, storage)
self.devices: list[NotifierDevice] = []
self.api.on(API_EVENT.SHUTDOWN, lambda: self.devices.clear())
async def configureCameras(self, cameraDevices: list[CameraDevice]) -> None: ...
async def onCameraAdded(self, camera: CameraDevice) -> None: ...
async def onCameraReleased(self, cameraId: str) -> None: ...
async def get_devices(self, owner_user_id: str) -> list[NotifierDevice]:
return [d for d in self.devices if d["ownerUserId"] == owner_user_id]
async def get_device(self, device_id: str) -> NotifierDevice | None:
return next((d for d in self.devices if d["id"] == device_id), None)
async def send_notification(self, device_id: str, n: Notification) -> None:
device = next((d for d in self.devices if d["id"] == device_id), None)
if not device or not device["active"]:
return
self.logger.log(f"[{device['name']}] {n['title']}: {n.get('body', '')}")
async def register_device(self, owner_user_id: str, input: dict[str, Any]) -> NotifierDevice:
device: NotifierDevice = {
"id": str(uuid.uuid4()),
"ownerUserId": owner_user_id,
"type": str(input.get("type", "mobile")),
"name": str(input.get("name", "Unnamed device")),
"active": True,
}
self.devices.append(device)
return device
async def revoke_device(self, device_id: str) -> None:
self.devices = [d for d in self.devices if d["id"] != device_id]
async def update_device(self, device_id: str, patch: dict[str, Any]) -> NotifierDevice | None:
device = next((d for d in self.devices if d["id"] == device_id), None)
if device is None:
return None
if isinstance(patch.get("name"), str):
device["name"] = patch["name"]
if isinstance(patch.get("active"), bool):
device["active"] = patch["active"]
return device
Notification.tag is a collapse key for dedup at both the manager and notifier level — multiple events with the same tag inside the throttle window collapse into one notification. Notification.severity is Severity.Info | Warn | Error | Critical; map Critical to whatever DND-bypass mechanism your platform offers.
Note: notifier methods are snake_case (get_devices, send_notification, …) — the rest of the SDK uses camelCase for parity with the Node and Go SDKs, but the notifier surface is intentionally Pythonic.
6.3 Detection interfaces¶
The seven detection capabilities follow a single pattern. Each interface has:
- A required
test*method invoked by the UI when the user uploads a clip/image and clicks "Test" — it accepts raw media bytes plus metadata and returns the same result shape the per-frame detector would. - An optional pre-processed
detect*method invoked by automations and benchmarks — it accepts an already-decoded frame. - An optional
*Settings()method that returns a schema for the detection configuration UI.
All seven (MotionDetectionInterface, ObjectDetectionInterface, AudioDetectionInterface, FaceDetectionInterface, LicensePlateDetectionInterface, ClassifierDetectionInterface, ClipDetectionInterface) share that shape. ClipDetectionInterface additionally requires getTextEmbedding(text) for semantic search.
Motion as a worked example:
from typing import Any
from camera_ui_sdk import (
BasePlugin, JsonSchema, MotionDetectionInterface,
MotionDetectionPluginResponse, VideoFrameData,
)
class MotionPlugin(BasePlugin, MotionDetectionInterface):
# ... lifecycle methods omitted ...
async def testMotionDetection(
self, video_data: bytes, config: dict[str, Any],
) -> MotionDetectionPluginResponse | None:
detections = await self._run_on_encoded_clip(video_data)
return {"detected": len(detections) > 0, "detections": detections}
async def detectMotion(
self, frames: list[VideoFrameData], config: dict[str, Any] | None = None,
) -> MotionDetectionPluginResponse | None:
detections = await self._run_on_frames(frames)
return {"detected": len(detections) > 0, "detections": detections}
async def motionDetectionSettings(self) -> list[JsonSchema] | None:
return [
{"type": "number", "key": "minArea", "title": "Min area (%)", "description": "",
"defaultValue": 1, "minimum": 0, "maximum": 100, "step": 1, "store": True},
]
async def _run_on_encoded_clip(self, v: bytes) -> list: return []
async def _run_on_frames(self, f: list[VideoFrameData]) -> list: return []
The image-based detection interfaces (ObjectDetectionInterface, FaceDetectionInterface, LicensePlateDetectionInterface, ClassifierDetectionInterface, ClipDetectionInterface) take an extra metadata: ImageMetadata argument with width / height on the test* method. The audio interface takes metadata: AudioMetadata with the mimeType. Otherwise the wiring is identical to the motion example above — add the matching PluginInterface.X flag to the contract and implement the test* / optional detect* / optional settings trio.
A detection plugin almost always implements both halves: the appropriate *DetectorSensor subclass (Section 4) for the live pipeline, AND the matching *DetectionInterface here for UI test dialogs and ad-hoc benchmarks.
7. Logging¶
self.logger is a LoggerService. The methods are log, warn, error, success, debug, trace, and attention — each accepts a list of arguments joined with spaces by the host. debug and trace are gated by host log level.
self.logger.log("Plugin started")
self.logger.success(f"Connected to vendor cloud as {user}")
self.logger.warn("Falling back to substream")
self.logger.error("Adopt failed:", err)
Every CameraDevice exposes camera.logger — same interface, but the output is prefixed with the camera name. Prefer it over self.logger whenever the message is about a specific camera:
8. Inter-plugin communication¶
The cleanest way for one plugin to react to another's sensors is camera.onSensorProperty(sensor_type, property, callback). It auto-subscribes when a sensor of the requested type appears (now or later), unsubscribes when it goes away, and tears down everything when you dispose the returned handle. The callback receives (value, timestamp_ms, sensor). This is the pattern Hub plugins (HomeKit, automations) use.
A complete Hub consumer that listens to motion AND doorbell on every assigned camera:
from typing import Any
from camera_ui_sdk import (
API_EVENT, BasePlugin, CameraDevice, DeviceStorage, Disposable,
LoggerService, PluginAPI, SensorLike, SensorType,
)
class HubConsumer(BasePlugin):
def __init__(self, logger: LoggerService, api: PluginAPI, storage: DeviceStorage) -> None:
super().__init__(logger, api, storage)
self.subs: dict[str, list[Disposable]] = {}
self.api.on(API_EVENT.SHUTDOWN, self._dispose_all)
async def configureCameras(self, cameraDevices: list[CameraDevice]) -> None:
for camera in cameraDevices:
self._bind(camera)
async def onCameraAdded(self, camera: CameraDevice) -> None:
self._bind(camera)
async def onCameraReleased(self, cameraId: str) -> None:
for sub in self.subs.pop(cameraId, []):
sub.dispose()
def _bind(self, camera: CameraDevice) -> None:
def on_motion(detected: Any, _ts: int, _sensor: SensorLike) -> None:
if detected:
camera.logger.log("motion started")
def on_doorbell(ring: Any, _ts: int, _sensor: SensorLike) -> None:
if ring:
camera.logger.log("doorbell rang")
motion = camera.onSensorProperty(SensorType.Motion, "detected", on_motion)
doorbell = camera.onSensorProperty(SensorType.Doorbell, "ring", on_doorbell)
self.subs[camera.id] = [motion, doorbell]
def _dispose_all(self) -> None:
for subs in self.subs.values():
for sub in subs:
sub.dispose()
self.subs.clear()
Two things to notice:
- The bridge keeps one
list[Disposable]per camera and disposes it inonCameraReleased. This is critical —onSensorPropertykeeps an internal subscription alive until you call.dispose(). - The bind happens in both
configureCamerasANDonCameraAddedfor cameras that show up after startup. Same shape as for sensor-providing plugins.
For direct plugin-to-plugin RPC (e.g. asking a face plugin to compute embeddings on demand), use api.coreManager.connectToPlugin(name). It returns a typed proxy of the target plugin including any optional interfaces it implements:
face = await self.api.coreManager.connectToPlugin("Face Plugin")
if face is not None:
result = await face.testFaceDetection(jpeg_bytes, {"width": 640, "height": 480}, {})
Use await self.api.coreManager.getPluginsByInterface(PluginInterface.FaceDetection) to discover candidate plugins by capability rather than by name.
9. Common pitfalls¶
- Always release per-camera state in
onCameraReleased. Timers, vendor sessions, RTP sockets,Disposables fromonSensorProperty— drop them all. Leaking them keeps the camera object alive forever and prevents reassignment from working. - Don't block in
configureCameras. It runs on the host's startup path; a slow vendor handshake delays every other plugin. Do the network work inAPI_EVENT.FINISH_LAUNCHINGinstead. - Don't import from
camera_ui_sdk.internal. Theinternalsubpackage exposes types the host uses to talk to plugins via RPC. The shapes there are not part of the stable public surface and may change without notice. - Don't construct sensors in
__init__. The host hasn't finished wiring upapi/storageuntilsuper().__init__()returns andconfigureCamerasis called. Construct sensors inside the lifecycle hooks. - Don't log frame data. Detection paths run dozens of times per second per camera. Use
logger.debug/logger.trace(host-gated) for anything per-frame, and prefer aggregated counters over per-event logs.
10. Next steps¶
For complete production plugins to read alongside this guide, see plugins/ in the camera.ui repo. They cover everything documented above — discovery, notifier, detection, hub bridges — wired into a real UI.
For the full module-by-module surface, browse the API Reference — it's auto-generated from the SDK source and stays in sync with whatever version of camera-ui-sdk you have installed.