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": [],
}
The SDK also carries a plugin protocol level (PROTOCOL_LEVEL). The CLI stamps the level your plugin was built against into the bundle, and the server refuses to start a plugin whose level it does not support, telling the user whether the plugin or the server needs an update. You never set it yourself: rebuilding against the current SDK is all it takes.
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. Sensors¶
A sensor is the smallest smart-home unit in camera.ui. Detection sensors push results from analyzing video; control sensors expose user-toggleable hardware (lights, sirens, locks); event sensors fire one-shot triggers (doorbell).
Every sensor is a persisted entity of its own. The plugin supplies the durable identity, everything else belongs to the user: camera assignments, display name and whether the sensor is exported to consumers. There are two ways to register one:
camera.addSensor(sensor)— the sensor belongs to this camera's hardware (spotlight, siren, battery, PTZ). The host locks the assignment to the camera; users cannot re-assign it.api.sensorManager.addSensor(sensor)— standalone device (smart plug, hub, imported smart-home device). The user assigns it to zero or more cameras in the UI.
Either way, pass a native_id (e.g. the upstream device id) in the constructor so the host can reconcile the sensor across restarts by (pluginId, nativeId). Without it, identity falls back to (type, name) and a rename creates a new sensor.
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, register them, and then call those methods when your hardware reports a change.
A standalone sensor works exactly the same, it just goes through the sensor manager instead of a camera:
from camera_ui_sdk import LockControl
lock = LockControl("Front Door", native_id="lock.front_door")
await self.api.sensorManager.addSensor(lock)
Every sensor also has a lifecycle pair: on_start() runs once the sensor is registered and its storage is ready — start pollers, subscriptions, timers there. on_stop() is the counterpart and runs on removal, plugin shutdown and cleanup. Both may be plain def or async def:
class PollingContact(ContactSensor):
def on_start(self) -> None:
self._task = asyncio.create_task(self._poll_loop())
def on_stop(self) -> None:
self._task.cancel()
removeSensor only unregisters the runtime instance — the persisted entity stays and shows as disconnected until the user deletes it in the UI. The host removes camera-bound sensors automatically when a camera is released; your onCameraReleased hook just needs to drop your reference.
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 (e.g. "motion:cam-1"). The host uses it to replace an older entry with the same tag in the in-app notification list. Delivery is not throttled: every publish reaches your notifier, so map the tag to a platform collapse-id if you want the same behavior on the device. 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. Consuming sensors from other plugins¶
Bridge plugins (HomeKit, MQTT, automations) don't look at cameras to find sensors — they receive a consumable view: every sensor whose type is listed in contract["consumes"] and that the user exported. The host delivers it through three optional BasePlugin hooks:
configureSensors(sensors)— once at startup with the view known at that point.onSensorAdded(sensor)— a sensor entered the view at runtime: it was created, became exposed, or its type became consumable.onSensorReleased(sensorId)— a sensor permanently left the view: deleted or unexposed. Plugin connectivity does NOT fire this; watchsensor.onConnectedChangedfor that.
Each sensor arrives as a SensorLike: a read-only view with type, displayName, connected, assignedCameraIds, assignmentLocked, plus getValue() / getValues() and the observables onPropertyChanged, onConnectedChanged, onAssignmentChanged. Consumers decide rendering purely from that data — e.g. a light with assignmentLocked is camera hardware, an unassigned contact sensor is a standalone device.
A complete Hub consumer that reacts to motion state and connectivity:
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: ...
async def onCameraAdded(self, camera: CameraDevice) -> None: ...
async def onCameraReleased(self, cameraId: str) -> None: ...
async def configureSensors(self, sensors: list[SensorLike]) -> None:
for sensor in sensors:
self._bind(sensor)
async def onSensorAdded(self, sensor: SensorLike) -> None:
self._bind(sensor)
async def onSensorReleased(self, sensorId: str) -> None:
for sub in self.subs.pop(sensorId, []):
sub.dispose()
def _bind(self, sensor: SensorLike) -> None:
def on_property(event: dict[str, Any]) -> None:
if sensor.type == SensorType.Motion and event["property"] == "detected" and event["value"]:
self.logger.log(f"{sensor.displayName}: motion started")
def on_connected(connected: bool) -> None:
self.logger.log(f"{sensor.displayName}: {'online' if connected else 'offline'}")
props = sensor.onPropertyChanged.subscribe(on_property)
conn = sensor.onConnectedChanged.subscribe(on_connected)
self.subs[sensor.id] = [props, conn]
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 sensor and disposes it inonSensorReleased.subscribe()keeps the subscription alive until you call.dispose(). - The bind happens in both
configureSensorsANDonSensorAddedfor sensors that show up after startup. Same shape as the camera lifecycle.
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 fromsubscribe()— 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.