Example Integration¶
This page is a narrative walkthrough of the example controller that ships in the
integration-template repository.
The template is the source of truth for the actual, CI-verified code — start there with
Use this template, then read this page to understand why each piece looks the way it does.
The example isn't real protocol code — there's no such thing as "AcmeProtocol" or "AcmeClient".
Every piece that would normally come from a real device library is marked # pseudo so you know
it's made up. Everything else — module layout, method names, signatures, how the pieces call into
each other — matches the real SDK interfaces described in
Implementing a Controller, Storing Data, and
Data Models Reference. Read this page alongside those, not instead of them.
If this is your first integration, don't worry about understanding every line the first time
through. Scaffold from the template, rename Acme to your protocol, and start replacing the
# pseudo bits with calls into your actual device library one at a time.
More examples
This page is a pedagogical walkthrough. For real, production integrations to learn from — HomeKit, Zigbee, Matter, and more — browse the official MajorDom integrations at github.com/orgs/MajorDom-Systems/repositories?q=integration-.
The controller at a glance¶
Before the details, here's the whole shape of a finished controller — every method you implement,
in one place. Bodies are elided (...); the sections group them by direction of data flow. If
you're new to OOP or SDKs, read this as the "table of contents" for the rest of the page.
class MyController(AbstractController[MyDevice, MyParameter]):
name = "My Protocol" # optional — auto-derived from the class name otherwise
# ── Lifecycle ────────────────────────────────────────────────────────────
async def start(self) -> None: ... # connect, start discovery, restore already-paired devices
async def stop(self) -> None: ... # cancel tasks, stop discovery, close connections
# ── Discovery (Device → Hub) ────────────────────────────────────────────
@property
def discoveries(self) -> dict[UUID, Discovery]: ... # cached snapshot of unpaired devices
# when a device appears, call:
# await self.dependencies.output.controller_did_receive_discovery(self, discovery)
# ── Pairing ──────────────────────────────────────────────────────────────
async def start_pairing_window(self, duration_sec: int) -> None: ... # optional (e.g. Zigbee)
async def pair_device(self, discovery, credentials) -> UUID: ... # commission + map schema
async def unpair(self, device: MyDevice) -> None: ...
# ── Control & read (Hub → Device) ───────────────────────────────────────
async def send_command(self, command, device, parameter) -> None: ... # set a value / run command
async def fetch(self, device: MyDevice) -> None: ... # re-read all parameters
async def identify(self, device: MyDevice) -> None: ... # blink / beep the device
# ── Events (Device → Hub) ───────────────────────────────────────────────
# on an incoming device update, call:
# await self.dependencies.output.controller_did_receive_events(self, [DeviceParameterChange(...)])
Module Layout¶
Your integration is a standalone package (majordom_<protocol>/), scaffolded from the template:
majordom_acme/
├── __init__.py # exports AcmeController
├── controller.py # AbstractController subclass — the only file the Hub actually calls into
├── models.py # typed integration_data schemas — what MajorDom persists to disk for you
└── mapper.py # protocol <-> MajorDom conversions — pure functions, no I/O
models.py — typed integration_data¶
integration_data is a free-form field MajorDom lets every integration attach to its own
Device/Parameter records, for protocol-specific bookkeeping (an auth token, an internal id,
etc.). Subclass the SDK schemas to give it a typed shape — the Hub then (de)serializes it for you.
Here we remember the device's network hostname (not IP — see the discovery note below) and an
auth token the Acme protocol handed us during pairing:
from majordom_integration_sdk.schemas.base import Base
from majordom_integration_sdk.schemas.device import Device, DeviceState
from majordom_integration_sdk.schemas.parameter import Parameter, ParameterState
class AcmeDeviceIntegrationData(Base):
device_hostname: str # a hostname, resolved to the current IP by the OS
auth_token: str | None = None
class AcmeParameterIntegrationData(Base):
acme_property_name: str # the name Acme's own API uses for this property
# Device/DeviceState and Parameter/ParameterState are pairs: the "State" version carries the
# same fields plus the actual live value(s). You need both — see Storing Data for why.
class AcmeDevice(Device):
integration_data: AcmeDeviceIntegrationData
class AcmeDeviceState(DeviceState):
integration_data: AcmeDeviceIntegrationData
class AcmeParameter(Parameter):
integration_data: AcmeParameterIntegrationData
class AcmeParameterState(ParameterState):
integration_data: AcmeParameterIntegrationData
To make the Hub hand you these subclasses (with integration_data already parsed), the controller
overrides device_type/parameter_type — see Storing Data.
mapper.py — pure conversions¶
Keep protocol↔domain conversions here: pure functions that turn "whatever your device library
gives you" into MajorDom's Parameter/Device types. No I/O, no self.dependencies, nothing that
touches the network or database — so you (or a test) can call them directly without a real device.
A property's kind maps to a ParameterDataType; whether it's writable maps to
ParameterRole.control vs ParameterRole.sensor:
from majordom_integration_sdk.schemas.parameter import ParameterDataType, ParameterRole, ParameterVisibility
from .models import AcmeParameter, AcmeParameterIntegrationData
class AcmeMapper:
def parameter_from_acme_property(self, parameter_id, acme_property: dict) -> AcmeParameter:
data_type = ParameterDataType.bool if acme_property["kind"] == "bool" else ParameterDataType.integer
role = ParameterRole.control if acme_property["writable"] else ParameterRole.sensor
return AcmeParameter(
id=parameter_id,
name=acme_property["name"],
data_type=data_type,
role=role,
visibility=ParameterVisibility.user, # show on the device's main screen; see Data Models for setting/system
integration_data=AcmeParameterIntegrationData(acme_property_name=acme_property["name"]),
)
device_uuid/parameter_uuid are helpers on AbstractController
(Helper Methods) — call them from the controller and pass the
resulting ids into the mapper, since the mapper has no access to self.
controller.py — the walkthrough¶
The controller is the only file the Hub instantiates. It declares its identity as a class
attribute and subclasses the generic AbstractController:
from majordom_integration_sdk.controller import AbstractController
from .models import AcmeDevice, AcmeParameter
class AcmeController(AbstractController[AcmeDevice, AcmeParameter]):
name = "Acme" # class-level identity — the Hub reads it before constructing you
_mapper = AcmeMapper() # stateless, so one shared instance is fine
Lifecycle: start / stop¶
start() runs once when the Hub boots (or when the integration is enabled); stop() on shutdown.
Two things happen in start(): register for discovery (keep the returned cancel closure for
stop), and — crucially — reconnect to already-paired devices. On every Hub restart, start()
is your only chance to do that; skip it and paired devices look "stuck" until re-paired.
controller_did_connect_device isn't just for "just finished pairing" — call it any time a device
becomes reachable, including here at boot, to flip device.available back to True:
async def start(self):
self._discoveries = {}
self._client = AcmeClient() # pseudo
self._cancel_discovery = self.dependencies.zeroconf_discovery_service.register(self, {"_acme._tcp.local."})
async with self.dependencies.make_device_repository() as repo:
for device in await repo.get_all(self.name, AcmeDevice):
self._subscribe(device)
await self.dependencies.output.controller_did_connect_device(self, device.id)
async def stop(self):
self._cancel_discovery()
await self._client.close() # pseudo
Discovery callbacks: identify by hostname, never by IP¶
The Zeroconf service calls three listener methods (zeroconf_did_discover_service,
..._update_service, ..._remove_service) because you passed self to register(). Derive the
stable discovery id from info.name (the mDNS instance name), which stays constant across
discover/update/remove for the same physical device — not from its IP address:
If you keyed the id off the IP, a routine DHCP lease renewal would change the address and the Hub would think a brand-new device appeared instead of recognizing the one it already knew. Storing a hostname in
integration_data(rather than an IP) sidesteps this entirely: the OS resolves the hostname to the current IP for you, so a paired device changing IP requires no work at all. If you must store an IP, you'd have to update it and re-verify reachability on every update event.
_update_service only reports a device still in your discoveries dict (an unpaired device whose
advertisement changed); _remove_service gives you only name, so pop by the same derived id and
call controller_did_lose_discovery.
pair_device: validate credentials, then transition the id¶
Always validate that the credentials actually sent match the discovery's advertised options — never trust your own discovery-time guess:
async def pair_device(self, discovery: Discovery, credentials: ProvidedCredentials | None):
if not credentials or credentials.type not in discovery.expected_credentials_options:
raise ValueError("Unsupported or missing credentials for this discovery")
acme_device = await self._client.pair(discovery.id, code=credentials.value) # pseudo
device_id = self.device_uuid(acme_device.serial) # permanent, hardware-level id
...
The device now has a permanent id, distinct from its (temporary) discovery id. Fetch the placeholder
row the Hub created under discovery.id, set the real device.id and integration_data, map its
parameters, then await repo.save(device, discovery.id) — passing the old id renames the row
from the discovery id to the new device id in one call. Finally drop it from discoveries,
_subscribe, and report controller_did_connect_device.
unpair / identify / fetch¶
Thin wrappers over your client: unpair tells the device/your bookkeeping to forget the pairing
(the Hub removes its own DB row); identify makes the device blink/beep so the user can find it;
fetch pulls a fresh snapshot of every parameter and reports them together as a batch of
DeviceParameterChange events via controller_did_receive_events.
send_command: Hub → device¶
Called when the user changes something in the app. command.value is the new value; parameter
tells you which property — look up the protocol's own name from your integration_data:
async def send_command(self, command: DeviceCommand, device: AcmeDevice, parameter: AcmeParameter):
await self._client.set_property(
device.integration_data.device_hostname,
parameter.integration_data.acme_property_name,
command.value,
) # pseudo
_subscribe: device → Hub¶
Called once per device — after pairing and once per already-paired device at boot. Whenever the device's own state changes, report it. Because protocol callbacks are usually plain (non-async), you schedule the async report as a task:
from majordom_integration_sdk.schemas.event import DeviceParameterChange
def _subscribe(self, device: AcmeDevice):
def on_property_changed(acme_property_name: str, value):
event = DeviceParameterChange(
device_id=device.id,
parameter_id=self.parameter_uuid(device.id, acme_property_name),
value=value,
)
asyncio.create_task(self.dependencies.output.controller_did_receive_events(self, [event]))
def on_availability_changed(is_available: bool):
# Optional mid-session availability: report only if your protocol can detect a *paired*
# device dropping/returning while the Hub runs. If it can't, omit this entirely.
cb = (self.dependencies.output.controller_did_connect_device if is_available
else self.dependencies.output.controller_did_lose_device)
asyncio.create_task(cb(self, device.id))
self._client.subscribe(device.integration_data.device_hostname,
on_property_changed=on_property_changed,
on_availability_changed=on_availability_changed) # pseudo
What's Deliberately Left Out¶
The example skips a few things your real integration will likely need, so it doesn't obscure the shape of the interface itself:
- Error handling — set
discovery.last_error/device.last_erroron failures (see Implementing a Controller:last_error). start_pairing_window— only implement it if your protocol needs an explicit, short-lived scan mode (Zigbee permit-join, BLE burst scan). Always-on discovery (mDNS, SSDP) doesn't need it.- Retry/backoff, connection pooling, rate limiting — whatever your protocol's client needs; the SDK prescribes nothing here.
- Implementing the protocol yourself — the example assumes
AcmeClientalready exists as a high-level SDK (pair,get_properties,set_property, ...). Real protocols don't always have one; if yours doesn't, you may implement the transport, connection management, and serialization directly inside your integration. That's expected. The framework (AbstractController, the discovery services, theDevice/Parametermodels) is deliberately independent of any protocol's implementation details — and, discovery services and data models in particular being plain public SDK, can be reused standalone (e.g. to build a protocol library outside MajorDom entirely).
Testing¶
Test your controller against simulated devices with the SDK's majordom_integration_sdk.testing
doubles — build_test_dependencies() plus RecordingControllerOutput — rather than real hardware.
See Testing and the template's own tests/ for the pattern.