Device Integration
MajorDom Device Integration Guide¶
Overview¶
To integrate a device into MajorDom, you need to implement a subclass of AbstractController - which provides an essential standartized interface for MajorDom to interact with your device(s). To share runtime information with MajorDom, you should call respective methods of self.dependencies.output (defined as ControllerOutput).
Storing Data¶
self.dependencies.make_device_repository provides and async context manager for device repository which allows you to perform CRUD operations on devices. You can store arbitrary data in Device.integration_data or Parameter.integration_data fields as long as they are JSON serializable. You can use custom subclasses of Device and Parameter to override integration_data type annotation, for example, with another pydantic model for hassle-free automatic serialization and deserialization.
Checklist¶
- discovery of new devices (
self.dependencies.output.controller_did_receive_discoveryis called) - discovery of devices already paired to Hub, for example, in cases of device's or Hub's reboot (
self.dependencies.output.controller_did_connect_deviceis called) - device pairing (
async def pair_deviceis implemented) - device schema is properly mapped: device info, parameters list, and each parameter's metadata are translated to MajorDom language
- Hub -> Device control (
async def send_commandis implemented) - Device -> Hub device event subscription handling (
self.dependencies.output.controller_did_receive_device_eventsis being called) identify,unpair,fetchmethods are implemented- graceful shutdown in
def stopmethod
AbstractController Overview¶
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import AsyncContextManager, Callable, Protocol, Type
from uuid import UUID
from typing_extensions import Iterable
from zeroconf.asyncio import AsyncZeroconf
from majordom_hub.repository.device_repository import DeviceRepository
from majordom_hub.schemas.automation.events import DeviceParameterChangedEvent
from majordom_hub.schemas.command import DeviceCommand
from majordom_hub.schemas.device import CredentialsValue, Device, Discovery, Parameter
class ControllerOutput(Protocol):
async def controller_did_receive_discovery(self, controller: AbstractController, discovery: Discovery): ...
async def controller_did_connect_device(self, controller: AbstractController, device_id: UUID): ...
async def controller_did_receive_device_events(self, controller: AbstractController, event: Iterable[DeviceParameterChangedEvent]): ...
class AbstractController[TDevice: Device, TParameter: Parameter](ABC):
@dataclass
class Dependencies:
output: ControllerOutput
make_device_repository: Callable[[], AsyncContextManager[DeviceRepository]]
zeroconf: AsyncZeroconf
register_zeroconf: Callable[[set[str]], None]
def __init__(self, dependencies: Dependencies):
self.dependencies = dependencies
# Abstract - to be implemented
@property
@abstractmethod
def discoveries(self) -> dict[UUID, Discovery]:
return {}
@property
@abstractmethod
def name(self) -> str:
return ''
@property
def device_type(self) -> Type[TDevice]:
'''Override this property to use your own Device subclass for auto parsing'''
return Device
@property
def parameter_type(self) -> Type[TParameter]:
'''Override this property to use your own Parameter subclass for auto parsing'''
return Parameter
@abstractmethod
async def start(self):
'''Setup the integration here'''
...
@abstractmethod
async def stop(self):
'''Gracefully cleanup and shutdown the integration'''
...
@abstractmethod
async def pair_device(self, discovery: Discovery, credentials: CredentialsValue | None): ...
@abstractmethod
async def unpair(self, device: TDevice): ...
@abstractmethod
async def identify(self, device: TDevice): ...
@abstractmethod
async def fetch(self, device: TDevice): ...
@abstractmethod
async def send_command(self, command: DeviceCommand, device: TDevice, parameter: TParameter): ...
Data Models¶
# Pairing
class CredentialsType(str, Enum):
code = "code" # pin, e.g. 1234-123-1234 (matter) or 123-45-678 (homekit)
secret = "secret" # for example, AES key like in esphome
qr = "qr" # raw qr data;
none = "none"
# can be extended if needed
def with_mask(self, code_mask: str) -> CredentialsType:
"""
mask format: D as digit placeholder, other symbols like dashes remain unchanged,
for example "DDD-DD-DDD" for "123-45-678"
Can be extended if needed.
"""
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):
name: str
note: str = ""
icon: str | None = None
category: str | None = None
room_id: UUID
id: UUID
transport: str
integration: str
manufacturer: str | None
last_seen: datetime | None = None
available: bool = False
class Device(DeviceInfo):
integration_data: SerializeAsAny[dict | Base] = Field(default_factory=Base)
class DeviceDataModel(DeviceInfo):
parameters: list[Parameter]
class DeviceState(DeviceInfo):
parameters: list[ParameterState]
# Parameters
class ParameterDataType(StrEnum):
none = "none" # e.g. button
# numeric
bool = "bool"
integer = "integer"
decimal = "decimal" # python float
enum = "enum" # integer with string_representation
# data
string = "string"
data = "data" # binary data, base64 encoded at high level
# can be extended if needed
class ParameterUnit(StrEnum):
plain = "plain" # raw data type
percentage = "percentage"
# time
second = "second"
hertz = "hertz"
# kinematic
kilogram = "kilogram"
arcdegree = "arcdegree"
meters = "meters"
mps = "mps" # meters per second, speed
mps2 = "mps2" # meters per second squared, acceleration
rpm = "rpm" # revolutions per minute
newton = "newton" # force
joule = "joule" # energy
watt = "watt" # power
# temperature
celsius = "celsius"
kelvin = "kelvin"
# electricity
volt = "volt"
ampere = "ampere"
# light
lux = "lux"
# air
pascal = "pascal"
ppm = "ppm" # parts per million, air quality
# informatics
bytes = "bytes" # data size
bps = "bps" # bytes per second, data rate
class ParameterRole(StrEnum):
sensor = 'sensor' # get-only
control = 'control' # get-set
event = 'event'
class Parameter(UUIdentifable):
id: UUID
name: str
data_type: ParameterDataType
unit: ParameterUnit = ParameterUnit.plain
role: ParameterRole
# value constraints (value for nubmers, char length for string, byte length for data)
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 and string representation, mostly for enums
integration_data: Any
class ParameterState(Parameter):
value: bytes