Build a Home Assistant custom component¶
This guide shows how to build a Home Assistant custom component that exposes Quilt rooms as climate entities with real-time updates through the streaming API.
Architecture overview¶
A Quilt HA component uses two parts:
DataUpdateCoordinator: Manages theQuiltClientconnection, holds theSystemSnapshot, and drives entity updates.- Entity classes: Translate
SpaceandIndoorUnitmodels into Home Assistant platform abstractions.
The coordinator uses an initial snapshot fetch on async_setup_entry, then a NotifierStream for real-time diffs. Polling is configured as a long-TTL fallback only.
HA event loop
└── QuiltCoordinator (DataUpdateCoordinator)
├── QuiltClient (gRPC channel + token store)
├── SystemSnapshot ← full state in-memory
└── NotifierStream ← real-time diffs → async_set_updated_data()
└── on_space_update, on_indoor_unit_update,
on_controller_update, on_qsm_update
Step 1: Implement the token store¶
To use HA's persistent JSON storage for tokens:
from __future__ import annotations
import logging
from homeassistant.helpers.storage import Store
from quilt_hp.tokens import TokenStore, CachedTokens
_LOGGER = logging.getLogger(__name__)
_STORE_KEY = "quilt_hp_tokens"
_STORE_VERSION = 1
class HATokenStore:
"""TokenStore backed by HA's async JSON storage."""
def __init__(self, hass) -> None:
self._store: Store = Store(hass, _STORE_VERSION, _STORE_KEY)
async def load(self, email: str) -> CachedTokens | None:
data = await self._store.async_load() or {}
entry = data.get(email)
if entry is None:
return None
try:
return CachedTokens(
id_token=entry["id_token"],
refresh_token=entry["refresh_token"],
expires_at=entry["expires_at"],
)
except KeyError:
_LOGGER.warning("Malformed token cache for %s; will re-authenticate", email)
return None
async def save(self, email: str, tokens: CachedTokens) -> None:
data = await self._store.async_load() or {}
data[email] = {
"id_token": tokens.id_token,
"refresh_token": tokens.refresh_token,
"expires_at": tokens.expires_at,
}
await self._store.async_save(data)
For the TokenStore protocol definition, see Token management reference.
Device and entity modeling¶
Before writing entity classes, you need a clear mental model of which API objects become HA devices and which become entities under those devices.
Why Spaces are not HA devices¶
A Space is a logical zone (room) in the Quilt data model. It carries the writable HVAC state — mode, setpoints, occupancy timeouts — but it has no serial number, no hardware model identifier, and no physical form. Home Assistant's device registry is designed for physical hardware that can be identified by manufacturer, model, and serial number.
The Quilt mobile app does not differentiate between a room and its indoor unit: controlling the room and controlling the head unit are the same action. Use snapshot.rooms (which filters to leaf spaces only — those with a parent_space_id) when setting up entities. Never create entities for floor-level or home-level parent spaces.
IDU and QSM share one HA device¶
The QuiltSmartModule (QSM) is embedded inside the IndoorUnit enclosure — users never see it as a separate piece of hardware. Because the Quilt app presents them as one unit and the QSM has no user-visible serial number, map them to a single HA device identified by the IDU's hardware identifiers.
Use snapshot.qsm_for_idu(idu) to resolve the QSM for a given IDU when you need radar or ambient-light sensor data.
from homeassistant.helpers.entity import DeviceInfo
DOMAIN = "quilt_hp"
def idu_device_info(idu, snapshot) -> DeviceInfo:
"""Build HA DeviceInfo for an IDU (and its embedded QSM)."""
space = next(
s for s in snapshot.rooms if s.id == idu.space_id
)
return DeviceInfo(
identifiers={(DOMAIN, idu.id)},
name=space.name, # room name, e.g. "Living Room"
manufacturer="Quilt",
model=idu.settings.name or "Indoor Unit",
serial_number=None, # serial_number not currently exposed via API
)
The climate entity for the room belongs to this device. It reads setpoint and mode from Space.controls and writes via client.set_space(), but it is registered as a child of the IDU device because the IDU is the physical hardware.
Controller (Quilt Dial) as a separate linked device¶
The Quilt Dial (Controller) is physically separate from the IDU — it is a standalone circular thermostat with its own Wi-Fi radio, temperature sensor, and display. It is associated with the same space_id as the IDU it works with, but it has its own serial number and model SKU.
Create a separate HA device for each Dial, linked to the IDU device for the same space using via_device:
def controller_device_info(ctrl, snapshot) -> DeviceInfo:
"""Build HA DeviceInfo for a Quilt Dial (Controller)."""
# Find the IDU in the same space so we can set via_device.
idu = next(
(u for u in snapshot.indoor_units if u.space_id == ctrl.space_id),
None,
)
return DeviceInfo(
identifiers={(DOMAIN, ctrl.id)},
name=ctrl.name or "Quilt Dial",
manufacturer="Quilt",
model=ctrl.model_sku or "Dial",
serial_number=ctrl.serial_number,
via_device=(DOMAIN, idu.id) if idu else None,
)
via_device tells HA that the Dial is physically associated with (and accessed through) the IDU device, which correctly groups them in the HA UI by room.
Entity-to-object mapping¶
| HA platform | Entity name example | Source model | Key fields | Writable? |
|---|---|---|---|---|
climate |
Living Room | Space.controls + IndoorUnit.controls |
hvac_mode, heating_setpoint_c, cooling_setpoint_c, fan_speed |
Yes — client.set_space() / client.set_indoor_unit() |
sensor (temperature) |
Living Room Temperature | IndoorUnit.state |
ambient_temperature_c |
No |
sensor (humidity) |
Living Room Humidity | IndoorUnit.state |
ambient_humidity_percent |
No |
binary_sensor (presence) |
Living Room Presence | IndoorUnit.effective_occupancy_state |
occupancy_state != 0 |
No |
light |
Living Room Light | IndoorUnit.controls |
led_state, led_brightness, led_color_code |
Yes — client.set_indoor_unit() |
select (fan speed) |
Living Room Fan Speed | IndoorUnit.controls |
fan_speed |
Yes — client.set_indoor_unit() |
select (louver) |
Living Room Louver | IndoorUnit.controls |
louver_mode |
Yes — client.set_indoor_unit() |
sensor (Dial temperature) |
Living Room Dial Temperature | Controller |
ambient_temperature_c |
No |
Presence note: Use
idu.effective_occupancy_staterather than readingidu.occupancydirectly. The property returnsNonewhen the IDU is offline, avoiding stale occupancy data being presented as current.
Resolving IDUs and Controllers for a space¶
A space always has at most one IDU in a typical Quilt installation. Use these patterns when setting up platforms:
# All rooms (leaf spaces only — no floor/home-level spaces)
rooms = snapshot.rooms
# Map space_id → IndoorUnit (for the common single-IDU-per-room case)
idu_by_space: dict[str, IndoorUnit] = {
idu.space_id: idu for idu in snapshot.indoor_units
}
# Map space_id → Controller (Dial), if one is installed in that room
ctrl_by_space: dict[str, Controller] = {
ctrl.space_id: ctrl for ctrl in snapshot.controllers
}
# Resolve the QSM embedded in an IDU
qsm = snapshot.qsm_for_idu(idu) # returns QuiltSmartModule | None
Step 2: Build the coordinator¶
from __future__ import annotations
import logging
from datetime import timedelta
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from quilt_hp import QuiltClient
from quilt_hp.models.controller import Controller
from quilt_hp.models.indoor_unit import IndoorUnit
from quilt_hp.models.qsm import QuiltSmartModule
from quilt_hp.models.space import Space
from quilt_hp.models.system import SystemSnapshot
_LOGGER = logging.getLogger(__name__)
class QuiltCoordinator(DataUpdateCoordinator[SystemSnapshot]):
def __init__(self, hass, email: str, token_store) -> None:
super().__init__(
hass,
_LOGGER,
name="Quilt HP",
update_interval=timedelta(minutes=5),
)
self._client = QuiltClient(email, token_store=token_store)
self._stream = None
async def async_setup(self) -> None:
await self._client.__aenter__()
await self._client.login()
snapshot = await self._client.get_snapshot()
self.async_set_updated_data(snapshot)
await self._start_stream(snapshot)
async def _start_stream(self, snapshot: SystemSnapshot) -> None:
topics = snapshot.stream_topics()
self._stream = self._client.stream(topics, max_reconnects=-1)
# Space updates drive climate entity state (mode, setpoints, occupancy).
self._stream.on_space_update(self._on_space_update)
# IDU updates drive temperature/humidity sensors, fan speed, LED, and
# presence binary sensor.
self._stream.on_indoor_unit_update(self._on_idu_update)
# Controller updates drive Dial temperature sensor entities.
self._stream.on_controller_update(self._on_controller_update)
# QSM updates provide raw radar and ALS diagnostic sensor data.
self._stream.on_qsm_update(self._on_qsm_update)
self._stream.on_disconnected(lambda: _LOGGER.warning("Quilt stream disconnected"))
await self._stream.start()
def _on_space_update(self, space: Space) -> None:
if self.data is not None:
self.data.apply_space(space)
self.async_set_updated_data(self.data)
def _on_idu_update(self, idu: IndoorUnit) -> None:
if self.data is not None:
self.data.apply_indoor_unit(idu)
self.async_set_updated_data(self.data)
def _on_controller_update(self, ctrl: Controller) -> None:
if self.data is not None:
self.data.apply_controller(ctrl)
self.async_set_updated_data(self.data)
def _on_qsm_update(self, qsm: QuiltSmartModule) -> None:
if self.data is not None:
self.data.apply_qsm(qsm)
self.async_set_updated_data(self.data)
async def _async_update_data(self) -> SystemSnapshot:
try:
return await self._client.get_snapshot()
except Exception as err:
raise UpdateFailed(f"Error fetching Quilt snapshot: {err}") from err
async def async_shutdown(self) -> None:
if self._stream is not None:
await self._stream.stop()
await self._client.__aexit__(None, None, None)
Step 3: Create the climate entity¶
The climate entity reads mode and setpoints from the Space but is registered under the IDU device. The device_info property links it to the physical hardware in the HA device registry.
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode as HAHVACMode,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.helpers.entity import DeviceInfo
from quilt_hp.models.enums import HVACMode as QHVACMode
DOMAIN = "quilt_hp"
_MODE_MAP: dict[QHVACMode, HAHVACMode] = {
QHVACMode.STANDBY: HAHVACMode.OFF,
QHVACMode.COOL: HAHVACMode.COOL,
QHVACMode.HEAT: HAHVACMode.HEAT,
QHVACMode.AUTO: HAHVACMode.HEAT_COOL,
QHVACMode.FAN: HAHVACMode.FAN_ONLY,
}
_HA_TO_QUILT: dict[HAHVACMode, QHVACMode] = {v: k for k, v in _MODE_MAP.items()}
class QuiltClimateEntity(ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
_attr_hvac_modes = list(_MODE_MAP.values())
def __init__(self, coordinator, space_id: str, idu_id: str) -> None:
self._coordinator = coordinator
self._space_id = space_id
self._idu_id = idu_id
@property
def space(self):
return next(s for s in self._coordinator.data.spaces if s.id == self._space_id)
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._idu_id)},
name=self.space.name,
manufacturer="Quilt",
model="Indoor Unit",
)
@property
def name(self) -> str:
return self.space.name
@property
def unique_id(self) -> str:
# Keyed on space_id because the climate entity represents the room,
# not the physical IDU.
return f"quilt_climate_{self._space_id}"
@property
def hvac_mode(self) -> HAHVACMode:
return _MODE_MAP.get(self.space.controls.hvac_mode, HAHVACMode.OFF)
@property
def current_temperature(self) -> float | None:
return self.space.state.ambient_temperature_c
@property
def target_temperature(self) -> float | None:
mode = self.space.controls.hvac_mode
if mode == QHVACMode.COOL:
return self.space.controls.cooling_setpoint_c
if mode == QHVACMode.HEAT:
return self.space.controls.heating_setpoint_c
return None
@property
def target_temperature_high(self) -> float | None:
return self.space.controls.cooling_setpoint_c
@property
def target_temperature_low(self) -> float | None:
return self.space.controls.heating_setpoint_c
async def async_set_hvac_mode(self, hvac_mode: HAHVACMode) -> None:
mode = _HA_TO_QUILT[hvac_mode]
await self._coordinator._client.set_space(self.space, mode=mode)
async def async_set_temperature(self, **kwargs) -> None:
from homeassistant.const import ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW
await self._coordinator._client.set_space(
self.space,
heat_setpoint_c=kwargs.get(ATTR_TARGET_TEMP_LOW),
cool_setpoint_c=kwargs.get(ATTR_TARGET_TEMP_HIGH) or kwargs.get(ATTR_TEMPERATURE),
)
Step 4: Create a temperature sensor entity¶
The IDU temperature sensor entity is also registered under the IDU device using the same device_info identifiers:
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
from homeassistant.const import UnitOfTemperature
from homeassistant.helpers.entity import DeviceInfo
DOMAIN = "quilt_hp"
class QuiltIndoorTempSensor(SensorEntity):
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
def __init__(self, coordinator, idu_id: str) -> None:
self._coordinator = coordinator
self._idu_id = idu_id
@property
def idu(self):
return next(u for u in self._coordinator.data.indoor_units if u.id == self._idu_id)
@property
def device_info(self) -> DeviceInfo:
space = next(
(s for s in self._coordinator.data.spaces if s.id == self.idu.space_id), None
)
return DeviceInfo(
identifiers={(DOMAIN, self._idu_id)},
name=space.name if space else self._idu_id,
manufacturer="Quilt",
model="Indoor Unit",
)
@property
def name(self) -> str:
space = next(
(s for s in self._coordinator.data.spaces if s.id == self.idu.space_id), None
)
return f"{space.name} Temperature" if space else f"IDU {self._idu_id} Temperature"
@property
def unique_id(self) -> str:
return f"quilt_idu_temp_{self._idu_id}"
@property
def native_value(self) -> float | None:
return self.idu.state.ambient_temperature_c
@property
def available(self) -> bool:
return self.idu.is_online
Dial temperature sensor¶
Create a parallel sensor class for the Controller (Quilt Dial). It is registered under a separate device linked to the IDU device via via_device:
from homeassistant.helpers.entity import DeviceInfo
DOMAIN = "quilt_hp"
class QuiltDialTempSensor(SensorEntity):
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
def __init__(self, coordinator, controller_id: str) -> None:
self._coordinator = coordinator
self._controller_id = controller_id
@property
def controller(self):
return next(
c for c in self._coordinator.data.controllers if c.id == self._controller_id
)
@property
def device_info(self) -> DeviceInfo:
ctrl = self.controller
# Find the IDU in the same space so we can set via_device.
idu = next(
(u for u in self._coordinator.data.indoor_units if u.space_id == ctrl.space_id),
None,
)
return DeviceInfo(
identifiers={(DOMAIN, self._controller_id)},
name=ctrl.name or "Quilt Dial",
manufacturer="Quilt",
model=ctrl.model_sku or "Dial",
serial_number=ctrl.serial_number,
via_device=(DOMAIN, idu.id) if idu else None,
)
@property
def name(self) -> str:
return f"{self.controller.name or 'Quilt Dial'} Temperature"
@property
def unique_id(self) -> str:
return f"quilt_dial_temp_{self._controller_id}"
@property
def native_value(self) -> float | None:
return self.controller.ambient_temperature_c
@property
def available(self) -> bool:
return self.controller.is_online
Step 5: Wire up the integration entry point¶
async def async_setup_entry(hass, entry):
token_store = HATokenStore(hass)
coordinator = QuiltCoordinator(hass, entry.data["email"], token_store)
await coordinator.async_setup()
hass.data.setdefault("quilt_hp", {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, ["climate", "sensor"])
entry.async_on_unload(coordinator.async_shutdown)
return True
Step 6: Handle OTP authentication via a config flow¶
Implement a two-step flow: the user enters their email, HA triggers the OTP send, then the user enters the code.
import voluptuous as vol
from homeassistant import config_entries
from quilt_hp import QuiltClient
from quilt_hp.tokens import CachedTokens
DOMAIN = "quilt_hp"
class QuiltConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Two-step config flow: email → OTP → done."""
VERSION = 1
def __init__(self) -> None:
self._email: str = ""
self._client: QuiltClient | None = None
async def async_step_user(self, user_input=None):
"""Step 1: collect email and trigger OTP send."""
errors = {}
if user_input is not None:
self._email = user_input["email"]
self._client = QuiltClient(self._email)
try:
# Passing no otp_callback triggers the OTP email without
# waiting for the code — the library sends the challenge and
# raises QuiltAuthError if the send itself fails.
await self._client.login(otp_callback=self._send_otp)
except Exception:
errors["base"] = "cannot_connect"
else:
return await self.async_step_otp()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required("email"): str}),
errors=errors,
)
async def _send_otp(self, email: str) -> str:
"""Called by QuiltClient.login() to collect the OTP.
Store the email so async_step_otp can finish the flow;
raise an exception to pause login until the user submits the form.
"""
# The actual OTP will come from the form in async_step_otp.
# Raise to interrupt login — we'll resume with the code.
raise _AwaitingOTP
async def async_step_otp(self, user_input=None):
"""Step 2: collect OTP and complete login."""
errors = {}
if user_input is not None:
otp = user_input["otp"]
try:
# Re-run login supplying the code directly this time.
await self._client.login(otp_callback=lambda _: otp)
except Exception:
errors["base"] = "invalid_auth"
else:
return self.async_create_entry(
title=self._email,
data={"email": self._email},
)
return self.async_show_form(
step_id="otp",
data_schema=vol.Schema({vol.Required("otp"): str}),
errors=errors,
description_placeholders={"email": self._email},
)
class _AwaitingOTP(Exception):
"""Sentinel raised to interrupt the login coroutine while awaiting user input."""
The config flow stores only the email in entry.data. Tokens are persisted in HATokenStore after the first successful login and reused on subsequent HA restarts without prompting again.
When a refresh token expires, surface re-authentication by calling self.hass.config_entries.async_start_reauth(entry) from the coordinator's error handler.