Skip to content

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 extending BasePlugin, 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": [],
}

# A language model for the assistant, nothing camera-shaped
{
    "name": "Apple LLM",
    "role": PluginRole.Service,
    "provides": [],
    "consumes": [],
    "interfaces": [PluginInterface.AssistantModels],
}

PluginRole.Service is the role for a plugin that serves camera.ui itself instead of cameras: it provides and consumes no sensors and never shows up in a camera's plugin list. Subclass ServicePlugin instead of BasePlugin and the camera lifecycle hooks are already there as no-ops.

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:

  • configureCameras runs once at startup with the cameras already assigned to the plugin. A raised exception aborts plugin startup.
  • onCameraAdded runs whenever the user assigns a new camera at runtime. Set up the same per-camera state as configureCameras.
  • onCameraReleased runs when a camera is removed or reassigned. Drop timers, close vendor sessions.
  • A dict[str, X] keyed by camera.id is the conventional pattern for per-camera state — cheap to look up, trivial to clean up.
  • API_EVENT.SHUTDOWN fires 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 also API_EVENT.FINISH_LAUNCHING, fired once after configureCameras returns — 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. Where a sensor comes from decides who owns it:

  • 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. 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.
  • Standalone sensors (smart plugs, hubs, smart-home entities) — the plugin never registers these. One exists once the user adopted it from what the plugin offers, and the host hands it back to the plugin on every start through the SensorDiscoveryProvider hooks (see 6.4). The user assigns it to zero or more cameras in the UI.

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:

sensor = MyMotionSensor()
await camera.addSensor(sensor)

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.

The host may send up to three frames to detectObjects at once, so the method must not keep per-call state on the sensor. A detector that needs one call at a time sets _rpc_concurrency = {} in its class.

A standalone sensor works exactly the same once it runs, only nobody registers it: you build it from the adopted record the host hands you and return it, and the host binds it to the record by nativeId:

from camera_ui_sdk import AdoptedSensor, LockControl

async def onSensorAdopted(self, sensor: AdoptedSensor) -> LockControl:
    return LockControl(sensor["name"], native_id=sensor["nativeId"], address=sensor.get("address"))

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 optional minimum, maximum, step.
  • string — text input; add "enum": [...] for a dropdown, "format": "password" to mask, "format": "image" / "qrCode" for media display.
  • boolean — toggle.
  • button — fires onSet() 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.

6.4 SensorDiscoveryProvider

Let users pick sensors from an inventory the plugin faces (Home Assistant entities, a vendor account) instead of importing all of it. List PluginInterface.SensorDiscovery in the contract. The host owns the adoption: it lists what you discover on the Sensors page, creates the record when the user adopts, hands you your adopted sensors on every start and tells you when the user deletes one. The plugin keeps no list of its own and persists nothing about adoption.

Four methods:

  • onDiscoverSensors returns everything the source currently offers. Don't drop what is already adopted, the host filters.
  • configureAdoptedSensors runs once at startup, right after configureCameras, with every record the user adopted from this plugin. Return one sensor per record, always, built from the record's type and name with native_id=record["nativeId"]; that is what the host binds by. A record the source no longer knows still gets its sensor, marked setSourceState("removed").
  • onSensorAdopted runs when the user adopts a discovered sensor. Same as one entry of configureAdoptedSensors.
  • onSensorUnadopted runs after the user deleted the sensor. The host has already unbound it, drop what you still hold.

Identity is the source's stable id, never its address. DiscoveredSensor["id"] becomes the record's nativeId, and the sensor keeps its record, assignments, automations and history for as long as that id stays the same. A Home Assistant entity-registry id or an MQTT unique_id qualifies; an entity_id does not, it changes on rename and would leave an orphan plus a new sensor behind. Carry the mutable address in address, it is shown next to the name.

The sensor reports its source on itself: setSourceState("connected" | "unavailable" | "removed"), and setAddress(address) after a rename. Neither deletes anything. An adopted sensor is deleted by the user only.

from camera_ui_sdk import (
    AdoptedSensor, BasePlugin, ContactSensor, DeviceStorage, DiscoveredSensor,
    LockControl, LoggerService, PluginAPI, Sensor, SensorDiscoveryProvider, SensorType,
)


class SmartHome(BasePlugin, SensorDiscoveryProvider):
    def __init__(self, logger: LoggerService, api: PluginAPI, storage: DeviceStorage) -> None:
        super().__init__(logger, api, storage)
        self.sensors: dict[str, Sensor] = {}

    async def onDiscoverSensors(self) -> list[DiscoveredSensor]:
        entities = await self.hub.list_entities()
        return [
            {
                "id": e.registry_id,  # stable, survives a rename
                "address": e.entity_id,  # mutable, display only
                "name": e.name,
                "type": SensorType.Lock if e.domain == "lock" else SensorType.Contact,
                "room": e.area,
            }
            for e in entities
        ]

    async def configureAdoptedSensors(self, sensors: list[AdoptedSensor]) -> list[Sensor]:
        return [self._build(record) for record in sensors]

    async def onSensorAdopted(self, sensor: AdoptedSensor) -> Sensor:
        return self._build(sensor)

    async def onSensorUnadopted(self, nativeId: str) -> None:
        self.sensors.pop(nativeId, None)

    def _build(self, record: AdoptedSensor) -> Sensor:
        cls = LockControl if record["type"] == SensorType.Lock else ContactSensor
        sensor = cls(record["name"], native_id=record["nativeId"], address=record.get("address"))
        self.sensors[record["nativeId"]] = sensor
        self.hub.watch(record["nativeId"], lambda entity: self._sync(sensor, entity))
        return sensor

    def _sync(self, sensor: Sensor, entity: HubEntity | None) -> None:
        if entity is None:
            sensor.setSourceState("removed")
            return
        sensor.setAddress(entity.entity_id)
        sensor.setSourceState("connected" if entity.available else "unavailable")

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:

async def onCameraAdded(self, camera: CameraDevice) -> None:
    camera.logger.log("attached")

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; watch sensor.onConnectedChanged for 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 in onSensorReleased. subscribe() keeps the subscription alive until you call .dispose().
  • The bind happens in both configureSensors AND onSensorAdded for 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. A plugin in that list can be installed but not started; skip it when its running is false. A call to a method no process serves is retried for about 3.5 s, because the plugin may be starting. To probe an optional method without that wait, connect with connectToPlugin(name, {"noRetry": True}); the call then raises at once, so wrap it in try. Read an optional method with getattr(plugin, "methodName", None): once the proxy knows the plugin's methods, a missing one is None.

To use one of the language models configured in camera.ui, call self.api.coreManager.assistantAsk(). The admin allows the plugin under Settings, Assistant, Plugin access and picks the model; camera.ui runs the request, so the plugin never holds a key. assistantAccess() tells you whether the plugin is allowed, and the assistantModelChanged core event fires when that changes:

access = await self.api.coreManager.assistantAccess()
if access["allowed"]:
    answer = await self.api.coreManager.assistantAsk({
        "system": "Answer with one word.",
        "prompt": "Is there a person in this picture?",
        "images": [{"data": jpeg_bytes, "mimeType": "image/jpeg"}],
    })
    text = answer["text"] if answer["ok"] else None

A plugin can also go the other way and bring a model: list PluginInterface.AssistantModels in the contract and implement AssistantModelProvider. Its models then appear in the provider list of the assistant settings, with no key and no address to fill in. Worth it for a model camera.ui cannot reach itself: one without an HTTP API, one behind an unusual authentication, or one that only runs on the machine the plugin runs on.

async def assistantModels(self) -> list[AssistantModelSpec]:
    return [{"id": "local", "name": "Local model", "contextTokens": 8192,
             "vision": True, "toolCalling": True, "structuredOutput": True}]

async def assistantGenerate(self, request: AssistantModelRequest, ctx: AssistantModelContext):
    async for delta in self.session.stream(request, ctx["timeoutMs"]):
        yield {"type": "text", "delta": delta}
    yield {"type": "done", "finish": "stop"}

Every request carries the whole conversation, the plugin keeps no state. contextTokens is not decoration: camera.ui plans prompt, tools and history with it. A tool call is yielded once its arguments are complete, the host runs the tool and asks again with the result in messages. A small model that calls the tools it sees but gets lost in a long catalog sets toolRouting to True: camera.ui then picks the tools for each question with a short call of its own and offers only those. The user can switch it per model.

A model that has to be downloaded or loaded first implements assistantModelStatus(). camera.ui asks before it offers the models and while the settings page is open, so the entry shows what the plugin is waiting for instead of disappearing:

async def assistantModelStatus(self) -> AssistantModelStatus:
    if self.session.ready:
        return {"ready": True}
    return {"ready": False, "message": "Loading the model", "progress": self.session.loaded / self.session.total}

9. Common pitfalls

  • Always release per-camera state in onCameraReleased. Timers, vendor sessions, RTP sockets, Disposables from subscribe() — 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 in API_EVENT.FINISH_LAUNCHING instead.
  • Don't import from camera_ui_sdk.internal. The internal subpackage 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 up api / storage until super().__init__() returns and configureCameras is 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.