Device Integration
MajorDom Device Integration Guide¶
An integration is a module that bridges MajorDom Hub with IoT devices of a specific protocol or vendor (e.g. HomeKit, Zigbee, Z-Wave).
Concepts¶
| Term | Meaning |
|---|---|
| Hub | The MajorDom Hub core software |
| Integration | A protocol/vendor-specific plugin |
| Controller (capitalized) | The class your integration must implement (AbstractController subclass) |
| a controller (lowercase) | Any third-party device that can control IoT devices (smartphone, Alexa, etc.) |
| Discovery | A detected, unpaired device that is available to be paired |
| Device | A paired and controllable device saved in the Hub's database |
| Parameter | A single controllable or observable property of a device (e.g. brightness, temperature) |
A device moves through these states:
Suggested Module Structure¶
An integration will typically need more than just a controller. Recommended minimal layout:
services/controller/myintegration/
├── controller.py # AbstractController subclass — the only required file
├── models.py # Typed integration_data schemas for Device and Parameter subclasses, see Storing Data
├── mapper.py # Protocol ↔ MajorDom domain model conversions, isolated from the controller for readability
└── parameters_map.py # Supplemental metadata for parameters that the API does not expose, usually in a form of a static dictionary. For example, device might expose min/max limits for a number via device's API, but the unit is only defined in pdf specification.
controller.py is the only required file. The rest are a template for keeping the controller clean — separate models, pull out conversion logic into a mapper, add metadata dictionary, etc. Of course, other files can be added as needed.
Implementation Checklist¶
- [ ] Discovery of new devices (
self.dependencies.output.controller_did_receive_discoveryis called) - [ ] Discovery of devices already paired to the Hub on reconnect, e.g. after a reboot (
self.dependencies.output.controller_did_connect_deviceis called) - [ ]
start_pairing_windowis implemented if the protocol requires an explicit scan mode - [ ] Device pairing (
pair_deviceis implemented) - [ ] Device schema is properly mapped: device info, parameter list, and each parameter's metadata are translated to MajorDom's domain model
- [ ] Hub → Device control (
send_commandis implemented) - [ ] Device → Hub event subscription (
self.dependencies.output.controller_did_receive_device_eventsis called on incoming events) - [ ]
identify,unpair, andfetchare implemented - [ ] Graceful shutdown in
stop
Implementing a Controller¶
Create a directory under services/controller/ for your integration and subclass AbstractController.
Hub → Device¶
Implement all abstract methods defined in AbstractController. The Hub calls these to drive your integration.
Device → Hub¶
Use self.dependencies.output (ControllerOutput), injected by the Hub:
# New unpaired device found during discovery
await self.dependencies.output.controller_did_receive_discovery(self, discovery)
# Device successfully paired and connected (also call on reconnect after reboot)
await self.dependencies.output.controller_did_connect_device(self, device_id)
# Device reported new parameter values
await self.dependencies.output.controller_did_receive_device_events(self, events)
The discoveries property¶
Return your current in-memory cache of unpaired, visible devices. The Hub polls this — do not trigger scanning here.
@property
def discoveries(self) -> dict[UUID, Discovery]:
return self._discoveries # maintained elsewhere, e.g. in a discovery event callback
The UUID for a given physical device must remain stable as long as it is visible.
start_pairing_window(duration_sec)¶
Optional. Override only if your protocol requires an explicit short-lived scan mode to surface new devices (e.g. Zigbee permit-join, BLE burst scan). Always-on discovery (mDNS, SSDP) does not need this. Default is a no-op.
Injected Dependencies¶
The Hub populates self.dependencies (see AbstractController.Dependencies) before calling start().
| Field | Type | Description |
|---|---|---|
output |
ControllerOutput |
Callback object for pushing events to the Hub |
make_device_repository |
Callable[[], AsyncContextManager[DeviceRepository]] |
Factory for the device repository |
zeroconf |
AsyncZeroconf |
Shared zeroconf instance for mDNS discovery |
register_zeroconf |
Callable[[set[str]], None] |
Register mDNS service types to listen for |
hardware_interfaces |
list[str] |
OS-level hardware interface paths assigned to this integration (e.g. /dev/ttyACM0) |
Storing Data¶
File Storage (documents_folder)¶
For files that cannot be stored in the database (e.g. a protocol's own SQLite DB, certificates, binary blobs), use self.documents_folder:
Resolves to a dedicated directory for this integration under the Hub's data root, created automatically on first write.
integration_data¶
MajorDom's Device and Parameter schemas expose an integration_data field for storing protocol-specific state (pairing tokens, internal IDs, etc.). By default it is an untyped dict persisted as JSON.
Integrations can subclass Device/DeviceState and Parameter/ParameterState to declare a typed schema for that field — Hub will then handle (de-)serialization automatically before passing Device instance to Controller's methods or when saving to the database.
# myintegration/models.py
from majordom_hub.schemas.base import Base
from majordom_hub.schemas.device import Device, DeviceState
from majordom_hub.schemas.parameter import Parameter, ParameterState
class MyDeviceIntegrationData(Base):
pairing_token: str | None = None
class MyDevice(Device):
integration_data: MyDeviceIntegrationData
class MyParameterState(ParameterState):
integration_data: MyParameterIntegrationData
To make the Hub use these custom types, the integration's Controller must override device_type and parameter_type:
@property
def device_type(self) -> type[MyDevice]:
return MyDevice
@property
def parameter_type(self) -> type[MyParameter]:
return MyParameter
The Hub will deserialize objects into these types before passing them to your implemented methods.
Repository¶
Use self.dependencies.make_device_repository to read or persist devices:
async with self.dependencies.make_device_repository() as repo:
device = await repo.get(device_id, as_=MyDevice) # `as_=MyDevice` is optional but provides hassle-free deserialization
async with self.dependencies.make_device_repository() as repo:
device.integration_data.some_field = new_value # assuming `MyDevice.integration_data` uses custom class with `some_field` present
await repo.save(device)
Notes¶
Helper Methods¶
AbstractController provides these @final helpers — do not override them:
| Method / Property | Description |
|---|---|
self.name_slug |
URL-safe slug of self.name; used as a stable integration identifier |
self.integration_uuid() |
Stable UUID for this integration, derived from name_slug |
self.device_uuid(device_id) |
Stable UUID for a device given any unique string (MAC, serial, etc.) |
self.parameter_uuid(device_uuid, parameter_id) |
Stable UUID for a parameter scoped to its device |
self.documents_folder |
Path to this integration's file storage directory |
IDs¶
Use the built-in helpers to generate deterministic UUIDs — no DB lookup needed, IDs stay consistent across restarts and re-pairings:
device_id = self.device_uuid(device_mac_or_serial)
parameter_id = self.parameter_uuid(device_id, f'{accessory_id}.{characteristic_id}')
Discovery id isn't required to match Device id, but this is recommended.
For IP Devices¶
- Handle IP changes. DHCP can reassign addresses. Identify devices by a stable ID (MAC, serial, mDNS hostname, domain-provided id) rather than IP. Monitor ip address regularly and keep it up to date.
- Use the Hub's provided discovery. Register your mDNS service types via
self.dependencies.register_zeroconf, or SSDP service viaself.dependencies.register_ssdp. It is not recommended to spin up your own discovery stack.
Data Models Reference¶
Pairing¶
class CredentialsType(str, Enum):
code = "code" # pin, e.g. 123-45-678 (HomeKit) or 1234-123-1234 (Matter)
secret = "secret" # e.g. AES key like in ESPHome
qr = "qr" # raw QR data
none = "none"
def with_mask(self, code_mask: str) -> CredentialsType:
"""
mask format: D as digit placeholder, other symbols like dashes remain unchanged,
e.g. "DDD-DD-DDD" for "123-45-678"
"""
self.code_mask = code_mask
return self
type CredentialsValue = str
class Discovery(Base):
# technical
id: UUID
integration: NonEmptyStr
credentials: CredentialsType
expiration: datetime | None = None
# for UX
transport: NonEmptyStr
device_manufacturer: str | None
device_name: NonEmptyStr
device_category: str | None
device_icon: str | None
Device¶
class DeviceInfo(DevicePatch):
id: UUID
name: str
note: str = ""
icon: str | None = None
category: str | None = None
room_id: UUID
transport: str
integration: str
manufacturer: str | None
last_seen: datetime | None = None
available: bool = False
main_parameter: UUID | None = None # for the tap action on the room view, toggle in most cases
class Device(DeviceInfo):
integration_data: SerializeAsAny[dict | Base] = Field(default_factory=Base)
class DeviceState(DeviceInfo):
parameters: list[ParameterState]
Parameter¶
class ParameterDataType(StrEnum):
none = "none" # e.g. button
bool = "bool"
integer = "integer"
decimal = "decimal" # python float
enum = "enum" # integer with string representation
string = "string"
data = "data" # binary data, base64 encoded at high level
class ParameterUnit(StrEnum):
plain = "plain"
percentage = "percentage"
second = "second"
hertz = "hertz"
kilogram = "kilogram"
arcdegree = "arcdegree"
meters = "meters"
mps = "mps" # meters per second
mps2 = "mps2" # meters per second squared
rpm = "rpm"
newton = "newton"
joule = "joule"
watt = "watt"
celsius = "celsius"
kelvin = "kelvin"
volt = "volt"
ampere = "ampere"
lux = "lux"
pascal = "pascal"
ppm = "ppm" # parts per million, air quality
bytes = "bytes"
bps = "bps" # bytes per second
class ParameterRole(StrEnum):
sensor = 'sensor' # read-only
control = 'control' # read-write
event = 'event' # fire-and-forget
class ParameterVisibility(StrEnum):
user = "user" # main, everyday interaction, device screen widgets (on/off, brightness, volume)
setting = "setting" # user-configurable, initial setup or low-frequency, like "auto off timer"
system = "system" # firmware, diagnostics, internals; not visible
class Parameter(UUIdentifable):
id: UUID
name: str
data_type: ParameterDataType
unit: ParameterUnit = ParameterUnit.plain
role: ParameterRole
visibility: ParameterVisibility
min_value: int | float | None = None
max_value: int | float | None = None
min_step: int | float | None = None
valid_values: dict[int | float | str, str] | None = None # value → display label
fields: list["Parameter"] | None = None # schema for data_type=struct
integration_data: Any
class ParameterState(Parameter):
value: bytes