This guide helps you migrate an existing Unfolded Circle integration to use the ucapi-framework. We'll cover both migrating from raw ucapi code and upgrading between framework versions.
- Why Migrate?
- Migration Overview
- Step-by-Step Migration
- Upgrading to 1.9+: The Coordinator Pattern
- Common Patterns
- Testing Your Migration
Before ucapi-framework:
- ~1500 lines of boilerplate per integration
- Manual configuration management with dict manipulation
- Global state management with module-level variables
- Repetitive event handler wiring
- Copy-paste setup flow code
- Manual device lifecycle management
- Entity and device state tightly coupled
After ucapi-framework:
- ~400 lines of integration-specific code
- Type-safe configuration with dataclasses
- Clean OOP design with proper encapsulation
- Automatic event handler wiring
- Reusable setup flow base class
- Automatic device lifecycle management
- Clear separation of concerns between device and entity
Code Reduction: ~70% less code to write and maintain!
The migration follows these steps:
- Configuration — Replace dict-based config with typed dataclass +
BaseConfigManager - Device — Inherit from a device base class; device knows nothing about entities
- Entities — Inherit from framework
Entity; entity subscribes to the device and owns its own state - Setup Flow — Inherit from
BaseSetupFlow, implement required methods - Driver — Inherit from
BaseIntegrationDriver, remove global state
# config.py - Old approach
import json, os
from typing import TypedDict
class MyDevice(TypedDict):
identifier: str
name: str
host: str
devices: dict[str, MyDevice] = {}
_config_path = os.path.join(os.path.dirname(__file__), "config.json")
def _load() -> bool:
global devices
if not os.path.exists(_config_path):
return True
with open(_config_path) as f:
data = json.load(f)
devices = {k: MyDevice(**v) for k, v in data.items()}
return True
def add_device(device: MyDevice) -> bool:
devices[device["identifier"]] = device
return _store()
def remove_device(identifier: str) -> bool:
if identifier in devices:
devices.pop(identifier)
return _store()
return FalseProblems: ~80 lines of boilerplate, global mutable state, manual JSON serialization.
# config.py - New approach
from dataclasses import dataclass
from ucapi_framework import BaseConfigManager
@dataclass
class MyDeviceConfig:
identifier: str
name: str
host: str
class MyConfigManager(BaseConfigManager[MyDeviceConfig]):
passUsage Comparison:
# Old:
import config
device = config.get_device(device_id)
config.add_device(new_device)
# New:
config = MyConfigManager("config.json", MyDeviceConfig)
device = config.get(device_id)
config.add_or_update(new_device)The framework enforces a key principle: the device knows nothing about entities. It stores raw hardware state (power, volume, input source — whatever your device has) and signals that something changed. Entities subscribe to those signals and translate them into ucapi attributes.
# device.py - Old approach
class MyDevice:
async def _process_update(self, data):
self.state = data["power"]
# Emitting entity ID + ucapi attribute dict — tightly coupled to entity type!
self.events.emit(
"UPDATE",
self.identifier,
{
media_player.Attributes.STATE: data["power"],
media_player.Attributes.VOLUME: data["volume"],
}
)Problem: The device has to know about ucapi entity attribute keys, which means changing entity types (e.g., adding a second entity) requires modifying the device. The device and entity are tightly coupled.
# device.py - New approach
from ucapi_framework import WebSocketDevice, DeviceEvents
import json
class MyDevice(WebSocketDevice):
def __init__(self, device_config, config_manager=None):
super().__init__(device_config, config_manager=config_manager)
# Raw device state — plain Python values, no ucapi attributes
self.power: str = "OFF"
self.volume: int = 0
self.source: str = ""
@property
def identifier(self) -> str:
return self._device_config.identifier
@property
def name(self) -> str:
return self._device_config.name
@property
def address(self) -> str:
return self._device_config.host
@property
def log_id(self) -> str:
return f"MyDevice[{self.identifier}]"
async def handle_message(self, message: str) -> None:
"""Process incoming message, update state, then notify subscribers."""
data = json.loads(message)
self.power = data.get("power", self.power)
self.volume = data.get("volume", self.volume)
self.source = data.get("source", self.source)
# Signal "something changed" — no entity IDs, no attribute keys
self.push_update()
async def establish_connection(self) -> None:
"""Called by the framework after the connection is established."""
# Fetch current state so entities have something to sync on connect
state = await self._fetch_state()
self.power = state["power"]
self.volume = state["volume"]
self.push_update() # Push initial state to all subscribed entitiespush_update() emits DeviceEvents.UPDATE with no arguments. Every entity that subscribed via subscribe_to_device(device) will have its sync_state() called automatically.
Entities are responsible for translating device state into ucapi attributes. This is the coordinator pattern — the entity coordinates state between the device and the Remote.
# media_player.py - Old approach
# Entity state was updated externally by the driver routing attribute dicts.
# Entity had no awareness of the device — driver handled everything.
class MyMediaPlayer(MediaPlayer):
def __init__(self, device_config, device):
super().__init__(
identifier=device_config.identifier,
...
attributes={media_player.Attributes.STATE: media_player.States.UNKNOWN},
)
self._device = device
# No sync_state(), no subscribe_to_device() — driver pushed state in# media_player.py - New approach
from ucapi import media_player
from ucapi_framework import Entity, create_entity_id, EntityTypes
class MyMediaPlayer(media_player.MediaPlayer, Entity):
"""Media player that subscribes to its device and manages its own state."""
def __init__(self, device_config, device):
self._device = device
entity_id = create_entity_id(EntityTypes.MEDIA_PLAYER, device_config.identifier)
super().__init__(
entity_id,
device_config.name,
features=[
media_player.Features.ON_OFF,
media_player.Features.VOLUME,
media_player.Features.VOLUME_UP_DOWN,
],
attributes={
media_player.Attributes.STATE: media_player.States.UNKNOWN,
media_player.Attributes.VOLUME: 0,
},
cmd_handler=self.handle_command,
)
# Wire entity to device: sync_state() is called on every push_update()
self.subscribe_to_device(device)
async def sync_state(self) -> None:
"""
Translate device state into ucapi attributes and push to Remote.
Called automatically when the device calls push_update().
Always pass a FRESH dict or dataclass — never mutate self.attributes.
Change-filtering compares the incoming dict against the last-pushed state,
so mutating self.attributes first defeats filtering entirely.
"""
self.update({
media_player.Attributes.STATE: self.map_entity_states(self._device.power),
media_player.Attributes.VOLUME: self._device.volume,
media_player.Attributes.SOURCE: self._device.source,
})
async def handle_command(self, entity, cmd_id, params):
"""Handle commands from the Remote."""
match cmd_id:
case media_player.Commands.ON:
await self._device.power_on()
case media_player.Commands.OFF:
await self._device.power_off()
case media_player.Commands.VOLUME:
await self._device.set_volume(params["volume"])The three key methods at a glance:
| Method | Called in | What it does |
|---|---|---|
subscribe_to_device(device) |
Entity __init__ |
Wires sync_state() to the device's UPDATE events |
push_update() |
Device, after state changes | Emits DeviceEvents.UPDATE — triggers all subscribed entities |
sync_state() |
Entity (override required) | Reads device state, calls self.update({...fresh dict...}) |
class MySetupFlow:
def __init__(self):
self._setup_step = "START"
async def handle_setup_request(self, msg):
if msg.reconfigure:
return await self._show_configuration_mode()
config.clear()
return await self._show_manual_entry()
async def handle_user_data_response(self, msg):
if self._setup_step == "CONFIGURATION_MODE":
return await self._handle_configuration_action(msg)
elif self._setup_step == "MANUAL_ENTRY":
return await self._handle_manual_entry_response(msg)
# ... hundreds more linesfrom ucapi_framework import BaseSetupFlow
from ucapi import IntegrationSetupError, RequestUserInput, SetupError
class MySetupFlow(BaseSetupFlow[MyDeviceConfig]):
def get_manual_entry_form(self) -> RequestUserInput:
"""Define the manual entry form fields."""
return RequestUserInput(
{"en": "Add Device"},
[
{"id": "name", "label": {"en": "Name"}, "field": {"text": {"value": ""}}},
{"id": "host", "label": {"en": "IP Address"}, "field": {"text": {"value": ""}}},
],
)
async def query_device(self, input_values):
"""Validate and create device config from user input."""
host = input_values.get("host", "").strip()
if not host:
return SetupError(error_type=IntegrationSetupError.CONNECTION_REFUSED)
return MyDeviceConfig(
identifier=host,
name=input_values.get("name", host),
host=host,
)You get for free: Configuration mode (add/update/remove/reset), backup/restore, duplicate detection, pre-discovery screens, multi-screen flows, migration support.
# driver.py - Old approach
_configured_devices: dict[str, MyDevice] = {}
@api.listens_to(ucapi.Events.CONNECT)
async def on_r2_connect_cmd():
for device in _configured_devices.values():
await device.connect()
@api.listens_to(ucapi.Events.SUBSCRIBE_ENTITIES)
async def on_subscribe_entities(entity_ids):
for entity_id in entity_ids:
device_config = config.get_device(entity_id)
device = MyDevice(device_config)
device.events.on("UPDATE", _on_device_update)
_configured_devices[entity_id] = device
# ... manual entity creation, registration, state sync, etc.from ucapi_framework import BaseIntegrationDriver
class MyDriver(BaseIntegrationDriver[MyDevice, MyDeviceConfig]):
def __init__(self):
super().__init__(
device_class=MyDevice,
entity_classes=[MyMediaPlayer],
)For hub devices where entities are discovered at runtime, use factory lambdas:
class MyHubDriver(BaseIntegrationDriver[MyHub, MyHubConfig]):
def __init__(self):
super().__init__(
device_class=MyHub,
entity_classes=[
lambda cfg, dev: [MyLight(cfg, info, dev) for info in dev.lights],
lambda cfg, dev: [MyCover(cfg, info, dev) for info in dev.covers],
lambda cfg, dev: [MyScene(cfg, info, dev) for info in dev.scenes],
],
require_connection_before_registry=True,
)Version 1.9 introduced the coordinator pattern — a fundamental shift in how device state flows to entities. Here are the three key changes.
Old pattern (legacy, still works via on_device_update):
# Device emits entity ID + attribute dict
device.events.emit(DeviceEvents.UPDATE, entity_id, {
media_player.Attributes.STATE: media_player.States.PLAYING,
media_player.Attributes.VOLUME: 50,
})
# Driver routes this to the right entity automaticallyNew pattern (coordinator):
# Device stores raw state and signals "something changed"
class MyDevice(WebSocketDevice):
async def handle_message(self, msg):
self.state = msg["state"]
self.volume = msg["volume"]
self.push_update() # No args, no entity awareness
# Entity subscribes and translates
class MyMediaPlayer(media_player.MediaPlayer, Entity):
def __init__(self, cfg, device):
...
self.subscribe_to_device(device) # Wire to device
async def sync_state(self) -> None:
self.update({
media_player.Attributes.STATE: self.map_entity_states(self._device.state),
media_player.Attributes.VOLUME: self._device.volume,
})The framework filters unchanged attributes before pushing to the Remote. For this to work, update() must receive a new dict each time — not self.attributes.
Wrong — breaks change filtering:
async def sync_state(self):
# BAD: the framework stores attributes by reference.
# self.attributes IS configured_entities.attributes — comparing an object to itself
# always produces an empty diff, so nothing ever gets sent to the Remote.
self.attributes[media_player.Attributes.STATE] = media_player.States.PLAYING
self.update(self.attributes)Correct — fresh dict or dataclass:
async def sync_state(self):
# GOOD: new dict each call — framework diffs against last-pushed state correctly
self.update({
media_player.Attributes.STATE: media_player.States.PLAYING,
media_player.Attributes.VOLUME: self._device.volume,
})
# Or with a typed dataclass (None values are automatically filtered):
# from ucapi_framework.helpers import MediaPlayerAttributes
# self.update(MediaPlayerAttributes(
# state=media_player.States.PLAYING,
# volume=self._device.volume,
# ))on_device_connected no longer calls sync_state() for coordinator-pattern entities. Instead, call push_update() at the end of your device's connection setup so entities receive their initial state.
class MyDevice(WebSocketDevice):
async def establish_connection(self) -> None:
"""Called by the framework after the connection is established."""
# Fetch current state from device
state = await self._fetch_initial_state()
self.power = state["power"]
self.volume = state["volume"]
# Push to all subscribed entities
self.push_update()Use require_connection_before_registry=True when the hub must be connected before you know what entities exist. The framework will connect the device first, then register entities:
class SmartHubDriver(BaseIntegrationDriver[SmartHub, SmartHubConfig]):
def __init__(self):
super().__init__(
device_class=SmartHub,
entity_classes=[
lambda cfg, dev: [HubLight(cfg, light, dev) for light in dev.lights],
lambda cfg, dev: [HubCover(cfg, cover, dev) for cover in dev.covers],
],
require_connection_before_registry=True,
)Override on_device_disconnected in your driver, or subscribe to DeviceEvents.ERROR in the entity:
class MyDriver(BaseIntegrationDriver[MyDevice, MyDeviceConfig]):
async def on_device_disconnected(self, device_id: str) -> None:
await super().on_device_disconnected(device_id)
for entity in self._get_framework_entities_for_device(device_id):
entity.set_unavailable()Use get_pre_discovery_screen() to collect API keys or server addresses before discovery runs:
class MySetupFlow(BaseSetupFlow[MyDeviceConfig]):
async def get_pre_discovery_screen(self):
return RequestUserInput(
{"en": "API Configuration"},
[{"id": "api_key", "label": {"en": "API Key"}, "field": {"text": {"value": ""}}}],
)
async def discover_devices(self):
api_key = self._pre_discovery_data.get("api_key")
return await MyDiscovery.run(api_key=api_key)Return RequestUserInput from query_device() after storing the partial config:
async def query_device(self, input_values):
device = await MyDevice.fetch_info(input_values["host"])
if not device:
return SetupError(error_type=IntegrationSetupError.NOT_FOUND)
# Store partial config, show next screen
self._pending_device_config = MyDeviceConfig(
identifier=device.id,
name=device.name,
host=input_values["host"],
)
return RequestUserInput(
{"en": "Select Zone"},
[{"id": "zone", "label": {"en": "Zone"}, "field": {"dropdown": {"items": device.zones}}}],
)
async def handle_additional_configuration_response(self, msg):
self._pending_device_config.zone = msg.input_values["zone"]
return None # Save and completeimport pytest
from unittest.mock import MagicMock, AsyncMock
from myintegration.config import MyDeviceConfig
from myintegration.device import MyDevice
from myintegration.media_player import MyMediaPlayer
@pytest.fixture
def device_config():
return MyDeviceConfig(identifier="test123", name="Test Device", host="192.168.1.100")
async def test_sync_state_maps_device_state(device_config):
"""Entity reads from device and pushes fresh dict to Remote."""
device = MyDevice(device_config)
device.power = "PLAYING"
device.volume = 42
# Mock the API so update() doesn't fail
entity = MyMediaPlayer(device_config, device)
entity._api = MagicMock()
entity._api.configured_entities.contains.return_value = True
entity._api.configured_entities.get.return_value = MagicMock(attributes={})
entity._api.configured_entities.update_attributes = MagicMock()
await entity.sync_state()
entity._api.configured_entities.update_attributes.assert_called_once()
args = entity._api.configured_entities.update_attributes.call_args[0]
assert media_player.Attributes.STATE in args[1]
assert args[1][media_player.Attributes.STATE] == media_player.States.PLAYING- Configuration converted to dataclass +
BaseConfigManager - Device inherits from appropriate base class
- Device stores raw state (not ucapi attribute keys)
- Device calls
push_update()after state changes (no args) - Device calls
push_update()at end ofestablish_connection()/connect() - Entity inherits from both the ucapi entity class and framework
Entity - Entity calls
subscribe_to_device(device)in__init__ - Entity overrides
sync_state()and passes a fresh dict or dataclass toupdate() - Entity does NOT call
self.attributes[...] = ...; self.update(self.attributes) - Setup flow inherits from
BaseSetupFlowand implementsget_manual_entry_form()+query_device() - Driver inherits from
BaseIntegrationDriver - All global state removed
- Factory lambdas used for hub-based dynamic entity creation (not
create_entities()override) - Tests verify
sync_state()reads from device and callsupdate()with a fresh dict
- Review inline docstrings in
ucapi_frameworkmodules — they include detailed examples - See the Device Patterns guide for connection class details
- See the Driver Guide for driver configuration
- Open an issue on GitHub for questions