Compare commits

..

2 Commits

Author SHA1 Message Date
Claude
aa6c2b4f74 Rename skill reference files to uppercase 2026-01-22 03:53:26 +00:00
Claude
e0349cae63 Add Home Assistant integration development skill
Create a Claude Code skill for developing Home Assistant integrations with
separate reference files for each platform and component:

- SKILL.md: Main skill entry point with quick reference table
- config-flow.md: Configuration flow patterns
- coordinator.md: Data update coordinator patterns
- entity.md: Base entity development patterns
- sensor.md: Sensor platform reference
- binary-sensor.md: Binary sensor platform reference
- switch.md: Switch platform reference
- number.md: Number platform reference
- select.md: Select platform reference
- button.md: Button platform reference
- device.md: Device management patterns
- diagnostics.md: Diagnostics implementation
- services.md: Service registration patterns
- testing.md: Testing patterns and fixtures

This skill extracts best practices from CLAUDE.md into a reusable
format that can be invoked via /ha-integration.
2026-01-22 03:30:27 +00:00
1042 changed files with 13782 additions and 45458 deletions

View File

@@ -0,0 +1,238 @@
# Binary Sensor Platform Reference
Binary sensors represent on/off states.
## Basic Binary Sensor
```python
"""Binary sensor platform for My Integration."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensors from config entry."""
coordinator = entry.runtime_data
async_add_entities([
DoorSensor(coordinator),
MotionSensor(coordinator),
])
class DoorSensor(MyEntity, BinarySensorEntity):
"""Door open/close sensor."""
_attr_device_class = BinarySensorDeviceClass.DOOR
_attr_translation_key = "door"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_door"
@property
def is_on(self) -> bool | None:
"""Return true if door is open."""
return self.coordinator.data.door_open
```
## Device Classes
Common binary sensor device classes:
| Device Class | On Means | Off Means |
|--------------|----------|-----------|
| `BATTERY` | Low | Normal |
| `BATTERY_CHARGING` | Charging | Not charging |
| `CONNECTIVITY` | Connected | Disconnected |
| `DOOR` | Open | Closed |
| `GARAGE_DOOR` | Open | Closed |
| `LOCK` | Unlocked | Locked |
| `MOISTURE` | Wet | Dry |
| `MOTION` | Motion detected | Clear |
| `OCCUPANCY` | Occupied | Clear |
| `OPENING` | Open | Closed |
| `PLUG` | Plugged in | Unplugged |
| `POWER` | Power detected | No power |
| `PRESENCE` | Present | Away |
| `PROBLEM` | Problem | OK |
| `RUNNING` | Running | Not running |
| `SAFETY` | Unsafe | Safe |
| `SMOKE` | Smoke detected | Clear |
| `SOUND` | Sound detected | Clear |
| `TAMPER` | Tampering | Clear |
| `UPDATE` | Update available | Up-to-date |
| `VIBRATION` | Vibration | Clear |
| `WINDOW` | Open | Closed |
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntityDescription,
)
@dataclass(frozen=True, kw_only=True)
class MyBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe My binary sensor entity."""
is_on_fn: Callable[[MyData], bool | None]
BINARY_SENSORS: tuple[MyBinarySensorEntityDescription, ...] = (
MyBinarySensorEntityDescription(
key="door",
translation_key="door",
device_class=BinarySensorDeviceClass.DOOR,
is_on_fn=lambda data: data.door_open,
),
MyBinarySensorEntityDescription(
key="motion",
translation_key="motion",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda data: data.motion_detected,
),
MyBinarySensorEntityDescription(
key="low_battery",
translation_key="low_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda data: data.battery_level < 20 if data.battery_level else None,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensors from config entry."""
coordinator = entry.runtime_data
async_add_entities(
MyBinarySensor(coordinator, description)
for description in BINARY_SENSORS
)
class MyBinarySensor(MyEntity, BinarySensorEntity):
"""Binary sensor using entity description."""
entity_description: MyBinarySensorEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MyBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.is_on_fn(self.coordinator.data)
```
## Connectivity Sensor
```python
class ConnectivitySensor(MyEntity, BinarySensorEntity):
"""Device connectivity sensor."""
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_translation_key = "connectivity"
@property
def is_on(self) -> bool:
"""Return true if device is connected."""
return self.coordinator.data.is_connected
```
## Problem Sensor
```python
class ProblemSensor(MyEntity, BinarySensorEntity):
"""Problem indicator sensor."""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_translation_key = "problem"
@property
def is_on(self) -> bool:
"""Return true if there's a problem."""
return self.coordinator.data.has_error
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
return {
"error_code": self.coordinator.data.error_code,
"error_message": self.coordinator.data.error_message,
}
```
## Update Available Sensor
```python
class UpdateAvailableSensor(MyEntity, BinarySensorEntity):
"""Firmware update available sensor."""
_attr_device_class = BinarySensorDeviceClass.UPDATE
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_translation_key = "update_available"
@property
def is_on(self) -> bool:
"""Return true if an update is available."""
return self.coordinator.data.update_available
```
## Translations
In `strings.json`:
```json
{
"entity": {
"binary_sensor": {
"door": {
"name": "Door"
},
"motion": {
"name": "Motion"
},
"low_battery": {
"name": "Low battery"
},
"connectivity": {
"name": "Connectivity"
}
}
}
}
```

View File

@@ -0,0 +1,201 @@
# Button Platform Reference
Button entities trigger actions when pressed.
## Basic Button
```python
"""Button platform for My Integration."""
from __future__ import annotations
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up buttons from config entry."""
coordinator = entry.runtime_data
async_add_entities([
RestartButton(coordinator),
IdentifyButton(coordinator),
])
class RestartButton(MyEntity, ButtonEntity):
"""Restart button."""
_attr_device_class = ButtonDeviceClass.RESTART
_attr_translation_key = "restart"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_restart"
async def async_press(self) -> None:
"""Handle the button press."""
await self.coordinator.client.restart()
```
## Device Classes
| Device Class | Icon | Use Case |
|--------------|------|----------|
| `IDENTIFY` | mdi:crosshairs-question | Flash light/beep to locate device |
| `RESTART` | mdi:restart | Restart the device |
| `UPDATE` | mdi:package-up | Trigger firmware update |
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.components.button import ButtonDeviceClass, ButtonEntityDescription
@dataclass(frozen=True, kw_only=True)
class MyButtonEntityDescription(ButtonEntityDescription):
"""Describe My button entity."""
press_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
BUTTONS: tuple[MyButtonEntityDescription, ...] = (
MyButtonEntityDescription(
key="restart",
translation_key="restart",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.restart(),
),
MyButtonEntityDescription(
key="identify",
translation_key="identify",
device_class=ButtonDeviceClass.IDENTIFY,
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.identify(),
),
MyButtonEntityDescription(
key="factory_reset",
translation_key="factory_reset",
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.factory_reset(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up buttons from config entry."""
coordinator = entry.runtime_data
async_add_entities(
MyButton(coordinator, description)
for description in BUTTONS
)
class MyButton(MyEntity, ButtonEntity):
"""Button using entity description."""
entity_description: MyButtonEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MyButtonEntityDescription,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(self.coordinator.client)
```
## Identify Button
```python
class IdentifyButton(MyEntity, ButtonEntity):
"""Identify button to locate the device."""
_attr_device_class = ButtonDeviceClass.IDENTIFY
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "identify"
async def async_press(self) -> None:
"""Flash the device LED to identify it."""
await self.coordinator.client.identify()
```
## Error Handling
```python
from homeassistant.exceptions import HomeAssistantError
class SafeButton(MyEntity, ButtonEntity):
"""Button with error handling."""
async def async_press(self) -> None:
"""Handle the button press with error handling."""
try:
await self.coordinator.client.perform_action()
except MyDeviceError as err:
raise HomeAssistantError(f"Failed to perform action: {err}") from err
```
## Confirmation Buttons
For dangerous operations, consider using a diagnostic category and clear naming:
```python
class FactoryResetButton(MyEntity, ButtonEntity):
"""Factory reset button."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_translation_key = "factory_reset"
_attr_entity_registry_enabled_default = False # Disabled by default
async def async_press(self) -> None:
"""Perform factory reset."""
await self.coordinator.client.factory_reset()
```
## Translations
In `strings.json`:
```json
{
"entity": {
"button": {
"restart": {
"name": "Restart"
},
"identify": {
"name": "Identify"
},
"factory_reset": {
"name": "Factory reset"
}
}
}
}
```

View File

@@ -0,0 +1,254 @@
# Config Flow Reference
Configuration flows allow users to set up integrations via the UI.
## Basic Config Flow
```python
"""Config flow for My Integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_API_KEY
from homeassistant.helpers.selector import TextSelector
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): TextSelector(),
vol.Required(CONF_API_KEY): TextSelector(),
}
)
class MyIntegrationConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for My Integration."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
# Test connection
client = MyClient(user_input[CONF_HOST], user_input[CONF_API_KEY])
info = await client.get_device_info()
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Set unique ID and abort if already configured
await self.async_set_unique_id(info.serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info.name,
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
```
## Version Control
Always set version numbers:
```python
VERSION = 1 # Bump for breaking changes requiring migration
MINOR_VERSION = 1 # Bump for backward-compatible changes
```
## Unique ID Management
```python
# Set unique ID and abort if exists
await self.async_set_unique_id(device_serial)
self._abort_if_unique_id_configured()
# Or abort if data matches (when no unique ID available)
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
```
## Reauthentication Flow
```python
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
errors: dict[str, str] = {}
if user_input is not None:
try:
client = MyClient(
self._get_reauth_entry().data[CONF_HOST],
user_input[CONF_API_KEY]
)
info = await client.get_device_info()
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
errors["base"] = "unknown"
else:
await self.async_set_unique_id(info.serial_number)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
```
## Reconfiguration Flow
```python
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
try:
client = MyClient(user_input[CONF_HOST], reconfigure_entry.data[CONF_API_KEY])
info = await client.get_device_info()
except CannotConnect:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(info.serial_number)
self._abort_if_unique_id_mismatch(reason="wrong_device")
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema({
vol.Required(CONF_HOST, default=reconfigure_entry.data[CONF_HOST]): str
}),
errors=errors,
)
```
## Discovery Flows
### Zeroconf Discovery
```python
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
serial = discovery_info.properties.get("serialno")
if not serial:
return self.async_abort(reason="no_serial")
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured(
updates={CONF_HOST: str(discovery_info.host)}
)
self._discovered_host = str(discovery_info.host)
self._discovered_name = discovery_info.name.removesuffix("._mydevice._tcp.local.")
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
return self.async_create_entry(
title=self._discovered_name,
data={CONF_HOST: self._discovered_host},
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self._discovered_name},
)
```
## strings.json for Config Flow
```json
{
"config": {
"step": {
"user": {
"title": "Connect to device",
"description": "Enter your device credentials.",
"data": {
"host": "Host",
"api_key": "API key"
}
},
"reauth_confirm": {
"title": "Reauthenticate",
"description": "Please enter a new API key for {name}.",
"data": {
"api_key": "API key"
}
},
"discovery_confirm": {
"title": "Discovered device",
"description": "Do you want to set up {name}?"
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured",
"wrong_account": "Wrong account",
"wrong_device": "Wrong device"
}
}
}
```
## Key Rules
1. **Never allow user-configurable entry names** (except helper integrations)
2. **Always test connection** before creating entry
3. **Always set unique ID** when possible
4. **Handle all exceptions** - bare `except Exception:` is allowed in config flows
5. **100% test coverage required** for all flow paths

View File

@@ -0,0 +1,239 @@
# Data Update Coordinator Reference
The coordinator pattern centralizes data fetching and provides efficient polling.
## Basic Coordinator
```python
"""DataUpdateCoordinator for My Integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from my_library import MyClient, MyData, MyError, AuthError
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
if TYPE_CHECKING:
from . import MyIntegrationConfigEntry
_LOGGER = logging.getLogger(__name__)
class MyCoordinator(DataUpdateCoordinator[MyData]):
"""My integration data update coordinator."""
config_entry: MyIntegrationConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
client: MyClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self.client = client
async def _async_update_data(self) -> MyData:
"""Fetch data from API."""
try:
return await self.client.get_data()
except AuthError as err:
raise ConfigEntryAuthFailed("Invalid credentials") from err
except MyError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
```
## Key Points
### Always Pass config_entry
```python
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=entry, # Always include this
)
```
### Generic Type Parameter
Specify the data type returned by `_async_update_data`:
```python
class MyCoordinator(DataUpdateCoordinator[MyData]):
...
```
### Error Types
- **`UpdateFailed`**: API communication errors (will retry)
- **`ConfigEntryAuthFailed`**: Authentication issues (triggers reauth flow)
## Polling Intervals
**Integration determines intervals** - never make them user-configurable.
```python
# Constants (in const.py)
SCAN_INTERVAL_LOCAL = timedelta(seconds=30)
SCAN_INTERVAL_CLOUD = timedelta(minutes=5)
# In coordinator
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, entry: MyIntegrationConfigEntry, client: MyClient) -> None:
# Determine interval based on connection type
interval = SCAN_INTERVAL_LOCAL if client.is_local else SCAN_INTERVAL_CLOUD
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=interval,
config_entry=entry,
)
```
**Minimum intervals:**
- Local network: 5 seconds
- Cloud services: 60 seconds
## Coordinator with Device Info
```python
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN
class MyCoordinator(DataUpdateCoordinator[MyData]):
"""Coordinator with device information."""
config_entry: MyIntegrationConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
client: MyClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self.client = client
self.device_info = DeviceInfo(
identifiers={(DOMAIN, client.serial_number)},
name=client.name,
manufacturer="My Company",
model=client.model,
sw_version=client.firmware_version,
)
async def _async_update_data(self) -> MyData:
"""Fetch data from API."""
try:
return await self.client.get_data()
except MyError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
```
## Multiple Data Sources
```python
from dataclasses import dataclass
@dataclass
class MyCoordinatorData:
"""Data class for coordinator."""
sensors: dict[str, SensorData]
status: DeviceStatus
settings: DeviceSettings
class MyCoordinator(DataUpdateCoordinator[MyCoordinatorData]):
"""Coordinator for multiple data sources."""
async def _async_update_data(self) -> MyCoordinatorData:
"""Fetch all data sources."""
try:
# Fetch all data concurrently
sensors, status, settings = await asyncio.gather(
self.client.get_sensors(),
self.client.get_status(),
self.client.get_settings(),
)
except MyError as err:
raise UpdateFailed(f"Error fetching data: {err}") from err
return MyCoordinatorData(
sensors=sensors,
status=status,
settings=settings,
)
```
## Setup in __init__.py
```python
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
"""Set up My Integration from a config entry."""
client = MyClient(entry.data[CONF_HOST], entry.data[CONF_API_KEY])
coordinator = MyCoordinator(hass, entry, client)
# Perform first refresh - raises ConfigEntryNotReady on failure
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
```
## Testing Coordinators
```python
@pytest.fixture
def mock_coordinator(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> MyCoordinator:
"""Return a mocked coordinator."""
coordinator = MyCoordinator(hass, mock_config_entry, MagicMock())
coordinator.data = MyData(temperature=21.5, humidity=45)
return coordinator
async def test_coordinator_update_failed(
hass: HomeAssistant,
mock_client: MagicMock,
) -> None:
"""Test coordinator handles update failure."""
mock_client.get_data.side_effect = MyError("Connection failed")
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
with pytest.raises(UpdateFailed):
await coordinator._async_update_data()
```

View File

@@ -0,0 +1,248 @@
# Device Management Reference
Device management groups entities and provides device information.
## Device Info
```python
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN
class MyEntity(CoordinatorEntity[MyCoordinator]):
"""Base entity with device info."""
_attr_has_entity_name = True
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.client.serial_number)},
name=coordinator.client.name,
manufacturer="My Company",
model=coordinator.client.model,
sw_version=coordinator.client.firmware_version,
hw_version=coordinator.client.hardware_version,
)
```
## DeviceInfo Fields
| Field | Description | Example |
|-------|-------------|---------|
| `identifiers` | Set of (domain, id) tuples | `{(DOMAIN, "ABC123")}` |
| `connections` | Set of (type, id) tuples | `{(CONNECTION_NETWORK_MAC, mac)}` |
| `name` | Device name | `"Living Room Thermostat"` |
| `manufacturer` | Manufacturer name | `"My Company"` |
| `model` | Model name | `"Smart Thermostat v2"` |
| `model_id` | Model identifier | `"THM-2000"` |
| `sw_version` | Software/firmware version | `"1.2.3"` |
| `hw_version` | Hardware version | `"rev2"` |
| `serial_number` | Serial number | `"ABC123456"` |
| `configuration_url` | Device config URL | `"http://192.168.1.100"` |
| `suggested_area` | Suggested room/area | `"Living Room"` |
| `entry_type` | Device entry type | `DeviceEntryType.SERVICE` |
| `via_device` | Parent device identifiers | `(DOMAIN, "hub_id")` |
## Device with Connections
Use connections (like MAC address) for better device merging:
```python
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, format_mac(coordinator.client.mac))},
identifiers={(DOMAIN, coordinator.client.serial_number)},
name=coordinator.client.name,
manufacturer="My Company",
model=coordinator.client.model,
)
```
## Hub and Child Devices
```python
# Hub device
class HubEntity(CoordinatorEntity[MyCoordinator]):
"""Hub entity."""
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the hub entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.hub_id)},
name="My Hub",
manufacturer="My Company",
model="Hub Pro",
)
# Child device connected via hub
class ChildEntity(CoordinatorEntity[MyCoordinator]):
"""Child device entity."""
def __init__(self, coordinator: MyCoordinator, device: ChildDevice) -> None:
"""Initialize the child entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
manufacturer="My Company",
model=device.model,
via_device=(DOMAIN, coordinator.hub_id), # Links to parent hub
)
```
## Service Entry Type
For cloud services without physical devices:
```python
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="My Cloud Service",
manufacturer="My Company",
entry_type=DeviceEntryType.SERVICE,
)
```
## Dynamic Device Addition
Auto-detect new devices after initial setup:
```python
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors from config entry."""
coordinator = entry.runtime_data
known_devices: set[str] = set()
@callback
def _check_devices() -> None:
"""Check for new devices."""
current_devices = set(coordinator.data.devices.keys())
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
MySensor(coordinator, device_id)
for device_id in new_devices
)
# Initial setup
_check_devices()
# Listen for updates
entry.async_on_unload(coordinator.async_add_listener(_check_devices))
```
## Stale Device Removal
Remove devices when they disappear:
```python
async def _async_update_data(self) -> MyData:
"""Fetch data and handle device removal."""
data = await self.client.get_data()
# Check for removed devices
device_registry = dr.async_get(self.hass)
current_device_ids = set(data.devices.keys())
for device_entry in dr.async_entries_for_config_entry(
device_registry, self.config_entry.entry_id
):
# Get device ID from identifiers
device_id = next(
(id for domain, id in device_entry.identifiers if domain == DOMAIN),
None,
)
if device_id and device_id not in current_device_ids:
# Device no longer exists, remove it
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=self.config_entry.entry_id,
)
return data
```
## Manual Device Removal
Allow users to manually remove devices:
```python
# In __init__.py
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: MyIntegrationConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
# Get device ID from identifiers
device_id = next(
(id for domain, id in device_entry.identifiers if domain == DOMAIN),
None,
)
if device_id is None:
return False
# Check if device is still present (don't allow removal of active devices)
coordinator = config_entry.runtime_data
if device_id in coordinator.data.devices:
return False # Device still exists, can't remove
return True # Allow removal of stale device
```
## Device Registry Access
```python
from homeassistant.helpers import device_registry as dr
# Get device registry
device_registry = dr.async_get(hass)
# Get device by identifiers
device = device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
)
# Get all devices for config entry
devices = dr.async_entries_for_config_entry(
device_registry, entry.entry_id
)
# Update device
device_registry.async_update_device(
device.id,
sw_version="2.0.0",
)
```
## Quality Scale Requirements
- **Bronze**: No specific device requirements
- **Gold**: Devices rule - group entities under devices
- **Gold**: Stale device removal - auto-remove disconnected devices
- **Gold**: Dynamic device addition - detect new devices at runtime

View File

@@ -0,0 +1,278 @@
# Diagnostics Reference
Diagnostics provide debug information for troubleshooting integrations.
## Basic Diagnostics
```python
"""Diagnostics support for My Integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_TOKEN
from homeassistant.core import HomeAssistant
from . import MyIntegrationConfigEntry
TO_REDACT = {
CONF_API_KEY,
CONF_PASSWORD,
CONF_TOKEN,
"serial_number",
"mac_address",
"latitude",
"longitude",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MyIntegrationConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"entry_data": async_redact_data(dict(entry.data), TO_REDACT),
"entry_options": async_redact_data(dict(entry.options), TO_REDACT),
"coordinator_data": async_redact_data(
coordinator.data.to_dict(), TO_REDACT
),
}
```
## What to Include
**Do include:**
- Configuration data (redacted)
- Current coordinator data
- Device information
- Error states and counts
- Connection status
- Firmware versions
- Feature flags
**Never include (always redact):**
- API keys, tokens, passwords
- Geographic coordinates (latitude/longitude)
- Personal identifiable information
- Email addresses
- MAC addresses (unless needed for debugging)
- Serial numbers (unless needed for debugging)
## Comprehensive Diagnostics
```python
"""Diagnostics support for My Integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import MyIntegrationConfigEntry
from .const import DOMAIN
TO_REDACT = {
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
"serial",
"serial_number",
"mac",
"mac_address",
"email",
"access_token",
"refresh_token",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MyIntegrationConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
# Get device registry entries
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
devices = []
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
entities = []
for entity in er.async_entries_for_device(
entity_registry, device.id, include_disabled_entities=True
):
entities.append({
"entity_id": entity.entity_id,
"unique_id": entity.unique_id,
"platform": entity.platform,
"disabled": entity.disabled,
"disabled_by": entity.disabled_by,
})
devices.append({
"name": device.name,
"model": device.model,
"manufacturer": device.manufacturer,
"sw_version": device.sw_version,
"hw_version": device.hw_version,
"identifiers": list(device.identifiers),
"connections": list(device.connections),
"entities": entities,
})
return {
"entry": {
"version": entry.version,
"minor_version": entry.minor_version,
"data": async_redact_data(dict(entry.data), TO_REDACT),
"options": async_redact_data(dict(entry.options), TO_REDACT),
},
"coordinator": {
"last_update_success": coordinator.last_update_success,
"last_exception": str(coordinator.last_exception) if coordinator.last_exception else None,
"data": async_redact_data(coordinator.data.to_dict(), TO_REDACT),
},
"devices": devices,
}
```
## Device-Level Diagnostics
For integrations with multiple devices, you can also provide device-level diagnostics:
```python
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: MyIntegrationConfigEntry, device: dr.DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
coordinator = entry.runtime_data
# Find device data based on device identifiers
device_id = next(
(id for domain, id in device.identifiers if domain == DOMAIN), None
)
if device_id is None:
return {"error": "Device not found"}
device_data = coordinator.data.devices.get(device_id)
if device_data is None:
return {"error": "Device data not found"}
return {
"device_info": {
"name": device.name,
"model": device.model,
"sw_version": device.sw_version,
},
"device_data": async_redact_data(device_data.to_dict(), TO_REDACT),
}
```
## Redaction Patterns
### Simple Redaction
```python
from homeassistant.components.diagnostics import async_redact_data
data = {"api_key": "secret123", "temperature": 21.5}
redacted = async_redact_data(data, {"api_key"})
# Result: {"api_key": "**REDACTED**", "temperature": 21.5}
```
### Nested Redaction
`async_redact_data` handles nested dictionaries automatically:
```python
data = {
"config": {
"host": "192.168.1.1",
"api_key": "secret123",
},
"device": {
"name": "My Device",
"serial_number": "ABC123",
}
}
redacted = async_redact_data(data, {"api_key", "serial_number"})
# Result: {"config": {"host": "192.168.1.1", "api_key": "**REDACTED**"},
# "device": {"name": "My Device", "serial_number": "**REDACTED**"}}
```
### Custom Redaction
For complex redaction needs:
```python
def _redact_data(data: dict[str, Any]) -> dict[str, Any]:
"""Redact sensitive data."""
result = dict(data)
# Redact specific keys
for key in ("api_key", "token", "password"):
if key in result:
result[key] = "**REDACTED**"
# Redact partial data (e.g., keep last 4 chars)
if "serial" in result:
result["serial"] = f"****{result['serial'][-4:]}"
# Redact coordinates to city level
if "latitude" in result:
result["latitude"] = round(result["latitude"], 1)
if "longitude" in result:
result["longitude"] = round(result["longitude"], 1)
return result
```
## Testing Diagnostics
```python
from homeassistant.components.diagnostics import REDACTED
from custom_components.my_integration.diagnostics import (
async_get_config_entry_diagnostics,
)
async def test_diagnostics(
hass: HomeAssistant,
init_integration: MockConfigEntry,
) -> None:
"""Test diagnostics."""
diagnostics = await async_get_config_entry_diagnostics(hass, init_integration)
assert diagnostics["entry"]["data"]["host"] == "192.168.1.1"
assert diagnostics["entry"]["data"]["api_key"] == REDACTED
assert "temperature" in diagnostics["coordinator"]["data"]
```
## Quality Scale Requirement
Diagnostics are required for **Gold** quality scale and above. Ensure your `quality_scale.yaml` includes:
```yaml
rules:
diagnostics: done
```

View File

@@ -0,0 +1,286 @@
# Entity Development Reference
Base patterns for entity development in Home Assistant.
## Base Entity Class
Create a shared base class to reduce duplication:
```python
"""Base entity for My Integration."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MyCoordinator
class MyEntity(CoordinatorEntity[MyCoordinator]):
"""Base entity for My Integration."""
_attr_has_entity_name = True
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
```
## Unique IDs
Every entity must have a unique ID:
```python
class MySensor(MyEntity, SensorEntity):
"""Sensor entity."""
def __init__(self, coordinator: MyCoordinator, sensor_type: str) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
# Unique per platform, don't include domain or platform name
self._attr_unique_id = f"{coordinator.client.serial_number}_{sensor_type}"
```
**Acceptable unique ID sources:**
- Device serial numbers
- MAC addresses (use `format_mac` from device registry)
- Physical identifiers
**Never use:**
- IP addresses, hostnames, URLs
- Device names
- Email addresses, usernames
## Entity Naming
```python
class MySensor(MyEntity, SensorEntity):
"""Sensor with proper naming."""
_attr_has_entity_name = True
_attr_translation_key = "temperature" # Translatable name
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
# For the main/primary entity of a device, use None
# self._attr_name = None
# For secondary entities, set the name
self._attr_name = "Temperature" # Or use translation_key
```
## Entity Translations
In `strings.json`:
```json
{
"entity": {
"sensor": {
"temperature": {
"name": "Temperature"
},
"humidity": {
"name": "Humidity"
},
"battery": {
"name": "Battery",
"state": {
"charging": "Charging",
"discharging": "Discharging"
}
}
}
}
}
```
## Entity Availability
### Coordinator Pattern
```python
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._sensor_key in self.coordinator.data.sensors
```
### Direct Update Pattern
```python
async def async_update(self) -> None:
"""Update entity state."""
try:
data = await self.client.get_data()
except MyException:
self._attr_available = False
return
self._attr_available = True
self._attr_native_value = data.value
```
## Entity Categories
```python
from homeassistant.const import EntityCategory
class DiagnosticSensor(MyEntity, SensorEntity):
"""Diagnostic sensor (hidden by default in UI)."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
class ConfigSwitch(MyEntity, SwitchEntity):
"""Configuration switch."""
_attr_entity_category = EntityCategory.CONFIG
```
## Disabled by Default
For noisy or less popular entities:
```python
class SignalStrengthSensor(MyEntity, SensorEntity):
"""Signal strength sensor - disabled by default."""
_attr_entity_registry_enabled_default = False
```
## Event Lifecycle
```python
class MyEntity(CoordinatorEntity[MyCoordinator]):
"""Entity with event subscriptions."""
async def async_added_to_hass(self) -> None:
"""Subscribe to events when added."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.client.events.subscribe(
"state_changed",
self._handle_state_change,
)
)
@callback
def _handle_state_change(self, event: Event) -> None:
"""Handle state change event."""
self._attr_native_value = event.value
self.async_write_ha_state()
```
**Key rules:**
- Subscribe in `async_added_to_hass`
- Use `async_on_remove` for automatic cleanup
- Never subscribe in `__init__`
## State Handling
```python
@property
def native_value(self) -> StateType:
"""Return the state."""
value = self.coordinator.data.get(self._key)
# Use None for unknown values, never "unknown" or "unavailable" strings
if value is None:
return None
return value
```
## Extra State Attributes
```python
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
data = self.coordinator.data
# All keys must always be present, use None for unknown
return {
"last_updated": data.last_updated,
"error_count": data.error_count,
"firmware": data.firmware or None, # Never omit keys
}
```
## Entity Descriptions Pattern
For multiple similar entities:
```python
from dataclasses import dataclass
from collections.abc import Callable
from homeassistant.components.sensor import SensorEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySensorEntityDescription(SensorEntityDescription):
"""Describe My sensor entity."""
value_fn: Callable[[MyData], StateType]
SENSOR_DESCRIPTIONS: tuple[MySensorEntityDescription, ...] = (
MySensorEntityDescription(
key="temperature",
translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.temperature,
),
MySensorEntityDescription(
key="humidity",
translation_key="humidity",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.humidity,
),
)
class MySensor(MyEntity, SensorEntity):
"""Sensor using entity description."""
entity_description: MySensorEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data)
```
## Multiline Lambdas
When lambdas are too long:
```python
# Good pattern - parentheses on same line as lambda
MySensorEntityDescription(
key="temperature",
value_fn=lambda data: (
round(data["temp_value"] * 1.8 + 32, 1)
if data.get("temp_value") is not None
else None
),
)
```

View File

@@ -0,0 +1,229 @@
# Number Platform Reference
Number entities represent numeric values that can be set.
## Basic Number
```python
"""Number platform for My Integration."""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up numbers from config entry."""
coordinator = entry.runtime_data
async_add_entities([
TargetTemperatureNumber(coordinator),
])
class TargetTemperatureNumber(MyEntity, NumberEntity):
"""Target temperature number entity."""
_attr_native_min_value = 16
_attr_native_max_value = 30
_attr_native_step = 0.5
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_mode = NumberMode.SLIDER
_attr_translation_key = "target_temperature"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_target_temp"
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.coordinator.data.target_temperature
async def async_set_native_value(self, value: float) -> None:
"""Set the target temperature."""
await self.coordinator.client.set_target_temperature(value)
await self.coordinator.async_request_refresh()
```
## Number Modes
```python
from homeassistant.components.number import NumberMode
# Slider display in UI
_attr_mode = NumberMode.SLIDER
# Input box display in UI
_attr_mode = NumberMode.BOX
# Auto (slider if range <= 256, else box)
_attr_mode = NumberMode.AUTO
```
## Device Classes
```python
from homeassistant.components.number import NumberDeviceClass
# For temperature settings
_attr_device_class = NumberDeviceClass.TEMPERATURE
# Other device classes
NumberDeviceClass.HUMIDITY
NumberDeviceClass.POWER
NumberDeviceClass.VOLTAGE
NumberDeviceClass.CURRENT
```
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.components.number import NumberEntityDescription, NumberMode
@dataclass(frozen=True, kw_only=True)
class MyNumberEntityDescription(NumberEntityDescription):
"""Describe My number entity."""
value_fn: Callable[[MyData], float | None]
set_value_fn: Callable[[MyClient, float], Coroutine[Any, Any, None]]
NUMBERS: tuple[MyNumberEntityDescription, ...] = (
MyNumberEntityDescription(
key="target_temperature",
translation_key="target_temperature",
native_min_value=16,
native_max_value=30,
native_step=0.5,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
mode=NumberMode.SLIDER,
value_fn=lambda data: data.target_temperature,
set_value_fn=lambda client, value: client.set_target_temperature(value),
),
MyNumberEntityDescription(
key="brightness",
translation_key="brightness",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.SLIDER,
entity_category=EntityCategory.CONFIG,
value_fn=lambda data: data.brightness,
set_value_fn=lambda client, value: client.set_brightness(int(value)),
),
)
class MyNumber(MyEntity, NumberEntity):
"""Number using entity description."""
entity_description: MyNumberEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MyNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.entity_description.value_fn(self.coordinator.data)
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
await self.entity_description.set_value_fn(self.coordinator.client, value)
await self.coordinator.async_request_refresh()
```
## Dynamic Min/Max Values
```python
class DynamicRangeNumber(MyEntity, NumberEntity):
"""Number with dynamic range based on device capabilities."""
_attr_translation_key = "fan_speed"
@property
def native_min_value(self) -> float:
"""Return minimum value."""
return self.coordinator.data.fan_speed_min
@property
def native_max_value(self) -> float:
"""Return maximum value."""
return self.coordinator.data.fan_speed_max
@property
def native_step(self) -> float:
"""Return step value."""
return self.coordinator.data.fan_speed_step or 1
```
## Configuration Number
```python
class ConfigNumber(MyEntity, NumberEntity):
"""Configuration number entity."""
_attr_entity_category = EntityCategory.CONFIG
_attr_native_min_value = 1
_attr_native_max_value = 60
_attr_native_step = 1
_attr_native_unit_of_measurement = "min"
_attr_translation_key = "timeout"
@property
def native_value(self) -> float | None:
"""Return the timeout setting."""
return self.coordinator.data.timeout_minutes
async def async_set_native_value(self, value: float) -> None:
"""Set the timeout."""
await self.coordinator.client.set_timeout(int(value))
await self.coordinator.async_request_refresh()
```
## Translations
In `strings.json`:
```json
{
"entity": {
"number": {
"target_temperature": {
"name": "Target temperature"
},
"brightness": {
"name": "Brightness"
},
"timeout": {
"name": "Timeout"
}
}
}
}
```

View File

@@ -0,0 +1,252 @@
# Select Platform Reference
Select entities allow choosing from a predefined list of options.
## Basic Select
```python
"""Select platform for My Integration."""
from __future__ import annotations
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up selects from config entry."""
coordinator = entry.runtime_data
async_add_entities([
ModeSelect(coordinator),
])
class ModeSelect(MyEntity, SelectEntity):
"""Mode select entity."""
_attr_options = ["auto", "cool", "heat", "fan_only", "dry"]
_attr_translation_key = "mode"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the select entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_mode"
@property
def current_option(self) -> str | None:
"""Return the current option."""
return self.coordinator.data.mode
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.coordinator.client.set_mode(option)
await self.coordinator.async_request_refresh()
```
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.components.select import SelectEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySelectEntityDescription(SelectEntityDescription):
"""Describe My select entity."""
current_option_fn: Callable[[MyData], str | None]
select_option_fn: Callable[[MyClient, str], Coroutine[Any, Any, None]]
SELECTS: tuple[MySelectEntityDescription, ...] = (
MySelectEntityDescription(
key="mode",
translation_key="mode",
options=["auto", "cool", "heat", "fan_only", "dry"],
current_option_fn=lambda data: data.mode,
select_option_fn=lambda client, option: client.set_mode(option),
),
MySelectEntityDescription(
key="fan_speed",
translation_key="fan_speed",
options=["low", "medium", "high", "auto"],
entity_category=EntityCategory.CONFIG,
current_option_fn=lambda data: data.fan_speed,
select_option_fn=lambda client, option: client.set_fan_speed(option),
),
)
class MySelect(MyEntity, SelectEntity):
"""Select using entity description."""
entity_description: MySelectEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MySelectEntityDescription,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def options(self) -> list[str]:
"""Return available options."""
return list(self.entity_description.options)
@property
def current_option(self) -> str | None:
"""Return current option."""
return self.entity_description.current_option_fn(self.coordinator.data)
async def async_select_option(self, option: str) -> None:
"""Select an option."""
await self.entity_description.select_option_fn(self.coordinator.client, option)
await self.coordinator.async_request_refresh()
```
## Dynamic Options
```python
class DynamicSelect(MyEntity, SelectEntity):
"""Select with options from device."""
_attr_translation_key = "preset"
@property
def options(self) -> list[str]:
"""Return available presets from device."""
return self.coordinator.data.available_presets
@property
def current_option(self) -> str | None:
"""Return current preset."""
return self.coordinator.data.current_preset
async def async_select_option(self, option: str) -> None:
"""Select a preset."""
await self.coordinator.client.set_preset(option)
await self.coordinator.async_request_refresh()
```
## Configuration Select
```python
class ConfigSelect(MyEntity, SelectEntity):
"""Configuration select (settings)."""
_attr_entity_category = EntityCategory.CONFIG
_attr_options = ["silent", "normal", "boost"]
_attr_translation_key = "performance_mode"
@property
def current_option(self) -> str | None:
"""Return current performance mode."""
return self.coordinator.data.performance_mode
async def async_select_option(self, option: str) -> None:
"""Set performance mode."""
await self.coordinator.client.set_performance_mode(option)
await self.coordinator.async_request_refresh()
```
## Translations
In `strings.json`:
```json
{
"entity": {
"select": {
"mode": {
"name": "Mode",
"state": {
"auto": "Auto",
"cool": "Cool",
"heat": "Heat",
"fan_only": "Fan only",
"dry": "Dry"
}
},
"fan_speed": {
"name": "Fan speed",
"state": {
"low": "Low",
"medium": "Medium",
"high": "High",
"auto": "Auto"
}
},
"performance_mode": {
"name": "Performance mode",
"state": {
"silent": "Silent",
"normal": "Normal",
"boost": "Boost"
}
}
}
}
}
```
## Icon by State
In `strings.json`:
```json
{
"entity": {
"select": {
"mode": {
"name": "Mode",
"default": "mdi:thermostat",
"state": {
"auto": "Auto",
"cool": "Cool",
"heat": "Heat"
},
"state_icons": {
"auto": "mdi:thermostat-auto",
"cool": "mdi:snowflake",
"heat": "mdi:fire"
}
}
}
}
}
```
Note: State icons are defined in `icons.json`:
```json
{
"entity": {
"select": {
"mode": {
"default": "mdi:thermostat",
"state": {
"auto": "mdi:thermostat-auto",
"cool": "mdi:snowflake",
"heat": "mdi:fire"
}
}
}
}
}
```

View File

@@ -0,0 +1,271 @@
# Sensor Platform Reference
Sensors represent read-only values from devices.
## Basic Sensor
```python
"""Sensor platform for My Integration."""
from __future__ import annotations
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors from config entry."""
coordinator = entry.runtime_data
async_add_entities([
TemperatureSensor(coordinator),
HumiditySensor(coordinator),
])
class TemperatureSensor(MyEntity, SensorEntity):
"""Temperature sensor."""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "temperature"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_temperature"
@property
def native_value(self) -> float | None:
"""Return the temperature."""
return self.coordinator.data.temperature
```
## Device Classes
Common sensor device classes:
| Device Class | Unit Examples | Use Case |
|--------------|---------------|----------|
| `TEMPERATURE` | °C, °F | Temperature readings |
| `HUMIDITY` | % | Humidity levels |
| `PRESSURE` | hPa, mbar | Atmospheric pressure |
| `BATTERY` | % | Battery level |
| `POWER` | W, kW | Power consumption |
| `ENERGY` | Wh, kWh | Energy usage |
| `VOLTAGE` | V | Electrical voltage |
| `CURRENT` | A, mA | Electrical current |
| `CO2` | ppm | Carbon dioxide |
| `PM25` | µg/m³ | Particulate matter |
## State Classes
```python
from homeassistant.components.sensor import SensorStateClass
# For instantaneous values that can go up or down
_attr_state_class = SensorStateClass.MEASUREMENT
# For ever-increasing totals (like energy consumption)
_attr_state_class = SensorStateClass.TOTAL
# For totals that reset periodically
_attr_state_class = SensorStateClass.TOTAL_INCREASING
```
## Entity Description Pattern
For multiple sensors with similar structure:
```python
from dataclasses import dataclass
from collections.abc import Callable
from typing import Any
from homeassistant.components.sensor import SensorEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySensorEntityDescription(SensorEntityDescription):
"""Describe My sensor entity."""
value_fn: Callable[[MyData], Any]
SENSORS: tuple[MySensorEntityDescription, ...] = (
MySensorEntityDescription(
key="temperature",
translation_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.temperature,
),
MySensorEntityDescription(
key="humidity",
translation_key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.humidity,
),
MySensorEntityDescription(
key="signal_strength",
translation_key="signal_strength",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, # Disabled by default
value_fn=lambda data: data.rssi,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors from config entry."""
coordinator = entry.runtime_data
async_add_entities(
MySensor(coordinator, description)
for description in SENSORS
)
class MySensor(MyEntity, SensorEntity):
"""Sensor using entity description."""
entity_description: MySensorEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data)
```
## Suggested Display Precision
```python
# Control decimal places shown in UI
_attr_suggested_display_precision = 1 # Show 21.5 instead of 21.456789
```
## Timestamp Sensors
```python
from datetime import datetime
from homeassistant.components.sensor import SensorDeviceClass
class LastUpdatedSensor(MyEntity, SensorEntity):
"""Last updated timestamp sensor."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_translation_key = "last_updated"
@property
def native_value(self) -> datetime | None:
"""Return the last update timestamp."""
return self.coordinator.data.last_updated
```
## Enum Sensors
```python
from homeassistant.components.sensor import SensorDeviceClass
class StatusSensor(MyEntity, SensorEntity):
"""Status sensor with enum values."""
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = ["idle", "running", "error", "offline"]
_attr_translation_key = "status"
@property
def native_value(self) -> str | None:
"""Return the current status."""
return self.coordinator.data.status
```
With translations in `strings.json`:
```json
{
"entity": {
"sensor": {
"status": {
"name": "Status",
"state": {
"idle": "Idle",
"running": "Running",
"error": "Error",
"offline": "Offline"
}
}
}
}
}
```
## Dynamic Icons
In `strings.json`:
```json
{
"entity": {
"sensor": {
"battery_level": {
"name": "Battery level",
"default": "mdi:battery-unknown",
"range": {
"0": "mdi:battery-outline",
"10": "mdi:battery-10",
"50": "mdi:battery-50",
"90": "mdi:battery-90",
"100": "mdi:battery"
}
}
}
}
}
```
## PARALLEL_UPDATES
```python
# At module level - limit concurrent updates
PARALLEL_UPDATES = 1 # Serialize to prevent overwhelming device
# Or unlimited for coordinator-based platforms
PARALLEL_UPDATES = 0
```

View File

@@ -0,0 +1,335 @@
# Services Reference
Services allow automations and users to trigger actions.
## Service Registration
Register services in `async_setup`, NOT in `async_setup_entry`:
```python
"""My Integration setup."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN
SERVICE_REFRESH = "refresh"
SERVICE_SET_SCHEDULE = "set_schedule"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up My Integration services."""
async def handle_refresh(call: ServiceCall) -> None:
"""Handle refresh service call."""
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
coordinator = entry.runtime_data
await coordinator.async_request_refresh()
hass.services.async_register(
DOMAIN,
SERVICE_REFRESH,
handle_refresh,
schema=vol.Schema({
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
}),
)
return True
```
## Service with Response
```python
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up services with response."""
async def handle_get_schedule(call: ServiceCall) -> ServiceResponse:
"""Handle get_schedule service call."""
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
coordinator = entry.runtime_data
schedule = await coordinator.client.get_schedule()
return {
"schedule": [
{"day": item.day, "start": item.start, "end": item.end}
for item in schedule
]
}
hass.services.async_register(
DOMAIN,
"get_schedule",
handle_get_schedule,
schema=vol.Schema({
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
}),
supports_response=SupportsResponse.ONLY, # or SupportsResponse.OPTIONAL
)
return True
```
## Entity Services
Register entity-specific services in platform setup:
```python
"""Switch platform with entity service."""
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import voluptuous as vol
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches from config entry."""
coordinator = entry.runtime_data
async_add_entities([PowerSwitch(coordinator)])
# Register entity service
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
"set_timer",
{
vol.Required("minutes"): vol.All(
vol.Coerce(int), vol.Range(min=1, max=120)
),
},
"async_set_timer",
)
class PowerSwitch(MyEntity, SwitchEntity):
"""Power switch with timer service."""
async def async_set_timer(self, minutes: int) -> None:
"""Set auto-off timer."""
await self.coordinator.client.set_timer(minutes)
```
## Service Validation
```python
from homeassistant.exceptions import ServiceValidationError
async def handle_set_schedule(call: ServiceCall) -> None:
"""Handle set_schedule service call."""
start_date = call.data["start_date"]
end_date = call.data["end_date"]
# Validate input
if end_date < start_date:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_date_before_start_date",
)
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
entry = hass.config_entries.async_get_entry(entry_id)
try:
await entry.runtime_data.client.set_schedule(start_date, end_date)
except MyConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_failed",
) from err
```
## services.yaml
Define services in `services.yaml`:
```yaml
refresh:
name: Refresh
description: Force a data refresh from the device.
fields:
config_entry_id:
name: Config entry ID
description: The config entry to refresh.
required: true
selector:
config_entry:
integration: my_integration
set_schedule:
name: Set schedule
description: Set the device schedule.
fields:
config_entry_id:
name: Config entry ID
description: The config entry to configure.
required: true
selector:
config_entry:
integration: my_integration
start_date:
name: Start date
description: Schedule start date.
required: true
selector:
date:
end_date:
name: End date
description: Schedule end date.
required: true
selector:
date:
get_schedule:
name: Get schedule
description: Get the current device schedule.
fields:
config_entry_id:
name: Config entry ID
description: The config entry to query.
required: true
selector:
config_entry:
integration: my_integration
set_timer:
name: Set timer
description: Set auto-off timer for the switch.
target:
entity:
integration: my_integration
domain: switch
fields:
minutes:
name: Minutes
description: Timer duration in minutes.
required: true
selector:
number:
min: 1
max: 120
unit_of_measurement: min
```
## Exception Translations
In `strings.json`:
```json
{
"exceptions": {
"entry_not_found": {
"message": "Config entry not found."
},
"entry_not_loaded": {
"message": "Config entry is not loaded."
},
"end_date_before_start_date": {
"message": "The end date cannot be before the start date."
},
"connection_failed": {
"message": "Failed to connect to the device."
}
}
}
```
## Device-Based Service Targeting
```python
async def handle_device_service(call: ServiceCall) -> None:
"""Handle service call targeting a device."""
device_id = call.data[ATTR_DEVICE_ID]
device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id)
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
)
# Find config entry for device
entry_id = next(
(entry_id for entry_id in device.config_entries if entry_id),
None,
)
if entry_id is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
entry = hass.config_entries.async_get_entry(entry_id)
# ... continue with service logic
```
## Service Schema Patterns
```python
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
# Basic schema
SERVICE_SCHEMA = vol.Schema({
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
vol.Required("value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
vol.Optional("timeout", default=30): cv.positive_int,
})
# With entity targeting
SERVICE_SCHEMA_ENTITY = vol.Schema({
vol.Required("entity_id"): cv.entity_ids,
vol.Required("parameter"): cv.string,
})
# With selectors (for services.yaml)
# Use selector in services.yaml, not in Python schema
```
## Quality Scale Requirements
- **Bronze**: `action-setup` - Register services in `async_setup` if integration has services
- Services must validate config entry state before use
- Use translated exceptions for error messages

View File

@@ -0,0 +1,165 @@
---
name: ha-integration
description: Develop Home Assistant integrations following best practices. Use when creating, modifying, or reviewing integration code including config flows, entities, coordinators, diagnostics, services, and tests.
---
# Home Assistant Integration Development
You are developing a Home Assistant integration. Follow these guidelines and reference the supporting documentation for specific components.
## Quick Reference
| Component | Reference File |
|-----------|----------------|
| Config flow | [CONFIG-FLOW.MD](CONFIG-FLOW.MD) |
| Data coordinator | [COORDINATOR.MD](COORDINATOR.MD) |
| Entities (base) | [ENTITY.MD](ENTITY.MD) |
| Sensors | [SENSOR.MD](SENSOR.MD) |
| Binary sensors | [BINARY-SENSOR.MD](BINARY-SENSOR.MD) |
| Switches | [SWITCH.MD](SWITCH.MD) |
| Numbers | [NUMBER.MD](NUMBER.MD) |
| Selects | [SELECT.MD](SELECT.MD) |
| Buttons | [BUTTON.MD](BUTTON.MD) |
| Device management | [DEVICE.MD](DEVICE.MD) |
| Diagnostics | [DIAGNOSTICS.MD](DIAGNOSTICS.MD) |
| Services | [SERVICES.MD](SERVICES.MD) |
| Testing | [TESTING.MD](TESTING.MD) |
## Integration Structure
```
homeassistant/components/my_integration/
├── __init__.py # Entry point with async_setup_entry
├── manifest.json # Integration metadata and dependencies
├── const.py # Domain and constants
├── config_flow.py # UI configuration flow
├── coordinator.py # Data update coordinator
├── entity.py # Base entity class
├── sensor.py # Sensor platform
├── diagnostics.py # Diagnostic data collection
├── strings.json # User-facing text and translations
├── services.yaml # Service definitions (if applicable)
└── quality_scale.yaml # Quality scale rule status
```
## Quality Scale Levels
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
- **Silver**: Enhanced functionality (entity unavailability, parallel updates, auth flows)
- **Gold**: Advanced features (device management, diagnostics, translations)
- **Platinum**: Highest quality (strict typing, async dependencies, websession injection)
Check `manifest.json` for `"quality_scale"` key and `quality_scale.yaml` for rule status.
## Core Patterns
### Entry Point (`__init__.py`)
```python
"""Integration for My Device."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import MyCoordinator
PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR]
type MyIntegrationConfigEntry = ConfigEntry[MyCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
"""Set up My Integration from a config entry."""
coordinator = MyCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
```
### Constants (`const.py`)
```python
"""Constants for My Integration."""
DOMAIN = "my_integration"
```
### Manifest (`manifest.json`)
```json
{
"domain": "my_integration",
"name": "My Integration",
"codeowners": ["@username"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/my_integration",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["my-library==1.0.0"],
"quality_scale": "bronze"
}
```
## Python Requirements
- **Compatibility**: Python 3.13+
- **Type hints**: Required for all functions and methods
- **f-strings**: Preferred over `%` or `.format()`
- **Async**: All external I/O must be async
## Code Quality
- **Formatting**: Ruff
- **Linting**: PyLint and Ruff
- **Type Checking**: MyPy
- **Testing**: pytest with >95% coverage
## Common Anti-Patterns to Avoid
```python
# Blocking operations
data = requests.get(url) # Use async or executor
time.sleep(5) # Use asyncio.sleep()
# Hardcoded strings
self._attr_name = "Temperature" # Use translation_key
# Too much in try block
try:
data = await client.get_data()
processed = data["value"] * 100 # Move outside try
except Error:
pass
# User-configurable polling
vol.Optional("scan_interval"): cv.positive_int # Not allowed
```
## Development Commands
```bash
# Run tests with coverage
pytest ./tests/components/<domain> \
--cov=homeassistant.components.<domain> \
--cov-report term-missing \
--numprocesses=auto
# Type checking
mypy homeassistant/components/<domain>
# Linting
pylint homeassistant/components/<domain>
# Validate integration
python -m script.hassfest --integration-path homeassistant/components/<domain>
```

View File

@@ -0,0 +1,236 @@
# Switch Platform Reference
Switches control on/off functionality.
## Basic Switch
```python
"""Switch platform for My Integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches from config entry."""
coordinator = entry.runtime_data
async_add_entities([
PowerSwitch(coordinator),
])
class PowerSwitch(MyEntity, SwitchEntity):
"""Power switch."""
_attr_device_class = SwitchDeviceClass.SWITCH
_attr_translation_key = "power"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_power"
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
return self.coordinator.data.is_on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.coordinator.client.turn_on()
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.coordinator.client.turn_off()
await self.coordinator.async_request_refresh()
```
## Device Classes
| Device Class | Use Case |
|--------------|----------|
| `OUTLET` | Electrical outlet |
| `SWITCH` | Generic switch |
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.components.switch import SwitchEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySwitchEntityDescription(SwitchEntityDescription):
"""Describe My switch entity."""
is_on_fn: Callable[[MyData], bool | None]
turn_on_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
SWITCHES: tuple[MySwitchEntityDescription, ...] = (
MySwitchEntityDescription(
key="power",
translation_key="power",
device_class=SwitchDeviceClass.SWITCH,
is_on_fn=lambda data: data.is_on,
turn_on_fn=lambda client: client.turn_on(),
turn_off_fn=lambda client: client.turn_off(),
),
MySwitchEntityDescription(
key="child_lock",
translation_key="child_lock",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda data: data.child_lock_enabled,
turn_on_fn=lambda client: client.set_child_lock(True),
turn_off_fn=lambda client: client.set_child_lock(False),
),
)
class MySwitch(MyEntity, SwitchEntity):
"""Switch using entity description."""
entity_description: MySwitchEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MySwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
return self.entity_description.is_on_fn(self.coordinator.data)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.turn_on_fn(self.coordinator.client)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.turn_off_fn(self.coordinator.client)
await self.coordinator.async_request_refresh()
```
## Configuration Switch
```python
class ConfigSwitch(MyEntity, SwitchEntity):
"""Configuration switch (e.g., enable/disable a feature)."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "auto_mode"
@property
def is_on(self) -> bool | None:
"""Return true if auto mode is enabled."""
return self.coordinator.data.auto_mode_enabled
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable auto mode."""
await self.coordinator.client.set_auto_mode(True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable auto mode."""
await self.coordinator.client.set_auto_mode(False)
await self.coordinator.async_request_refresh()
```
## Optimistic Updates
For devices with slow response:
```python
class OptimisticSwitch(MyEntity, SwitchEntity):
"""Switch with optimistic state updates."""
_attr_assumed_state = True # Indicates state may not be accurate
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self._optimistic_state: bool | None = None
@property
def is_on(self) -> bool | None:
"""Return optimistic state if set, otherwise coordinator state."""
if self._optimistic_state is not None:
return self._optimistic_state
return self.coordinator.data.is_on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on with optimistic update."""
self._optimistic_state = True
self.async_write_ha_state()
try:
await self.coordinator.client.turn_on()
finally:
self._optimistic_state = None
await self.coordinator.async_request_refresh()
```
## Error Handling
```python
from homeassistant.exceptions import HomeAssistantError
class RobustSwitch(MyEntity, SwitchEntity):
"""Switch with proper error handling."""
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
try:
await self.coordinator.client.turn_on()
except MyDeviceError as err:
raise HomeAssistantError(f"Failed to turn on: {err}") from err
await self.coordinator.async_request_refresh()
```
## Translations
In `strings.json`:
```json
{
"entity": {
"switch": {
"power": {
"name": "Power"
},
"child_lock": {
"name": "Child lock"
},
"auto_mode": {
"name": "Auto mode"
}
}
}
}
```

View File

@@ -0,0 +1,396 @@
# Testing Reference
Testing patterns for Home Assistant integrations.
## Test Structure
```
tests/components/my_integration/
├── __init__.py
├── conftest.py # Shared fixtures
├── test_config_flow.py # Config flow tests (100% coverage required)
├── test_init.py # Integration setup tests
├── test_sensor.py # Sensor platform tests
├── test_diagnostics.py # Diagnostics tests
├── snapshots/ # Snapshot files
│ └── test_sensor.ambr
└── fixtures/ # Test data fixtures
└── device_data.json
```
## conftest.py
```python
"""Fixtures for My Integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.my_integration.const import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="My Device",
data={
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_api_key",
},
unique_id="device_serial_123",
)
@pytest.fixture
def mock_client() -> Generator[MagicMock]:
"""Return a mocked client."""
with patch(
"homeassistant.components.my_integration.MyClient",
autospec=True,
) as client_mock:
client = client_mock.return_value
client.get_data = AsyncMock(
return_value=MyData.from_json(load_fixture("device_data.json", DOMAIN))
)
client.serial_number = "device_serial_123"
client.name = "My Device"
client.model = "Model X"
client.firmware_version = "1.2.3"
yield client
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to test."""
return [Platform.SENSOR, Platform.BINARY_SENSOR]
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.my_integration.PLATFORMS",
platforms,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
```
## Config Flow Tests
**100% coverage required for all paths:**
```python
"""Test config flow for My Integration."""
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.my_integration.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_user_flow_success(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_key",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "My Device"
assert result["data"] == {
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_key",
}
assert result["result"].unique_id == "device_serial_123"
async def test_user_flow_cannot_connect(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test connection error in user flow."""
mock_client.get_data.side_effect = ConnectionError("Cannot connect")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_key",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
async def test_user_flow_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: AsyncMock,
) -> None:
"""Test already configured error."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_key",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reauth_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: AsyncMock,
) -> None:
"""Test reauthentication flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_KEY: "new_api_key"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
```
## Entity Tests with Snapshots
```python
"""Test sensor platform."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Override platforms for sensor tests."""
return [Platform.SENSOR]
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_sensor_device_assignment(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test sensors are assigned to correct device."""
device = device_registry.async_get_device(
identifiers={("my_integration", "device_serial_123")}
)
assert device is not None
entities = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity in entities:
assert entity.device_id == device.id
```
## Coordinator Tests
```python
"""Test coordinator."""
from unittest.mock import AsyncMock
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import UpdateFailed
async def test_coordinator_update_success(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test successful coordinator update."""
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
await coordinator.async_refresh()
assert coordinator.data.temperature == 21.5
assert coordinator.last_update_success
async def test_coordinator_update_failed(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test coordinator handles API error."""
mock_client.get_data.side_effect = MyError("Connection failed")
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
with pytest.raises(UpdateFailed):
await coordinator._async_update_data()
async def test_coordinator_auth_failed(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test coordinator handles auth error."""
mock_client.get_data.side_effect = AuthError("Invalid token")
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
with pytest.raises(ConfigEntryAuthFailed):
await coordinator._async_update_data()
```
## Diagnostics Tests
```python
"""Test diagnostics."""
from homeassistant.components.diagnostics import REDACTED
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.my_integration import snapshot_platform
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
assert await get_diagnostics_for_config_entry(
hass, hass_client, init_integration
) == snapshot
```
## Common Fixtures
```python
from tests.common import MockConfigEntry, load_fixture
# Load JSON fixture
data = load_fixture("device_data.json", DOMAIN)
# Enable all entities (including disabled by default)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
# Freeze time
from freezegun.api import FrozenDateTimeFactory
async def test_with_frozen_time(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
freezer.tick(timedelta(minutes=5))
await hass.async_block_till_done()
```
## Update Snapshots
```bash
# Update snapshots
pytest tests/components/my_integration --snapshot-update
# Always re-run without flag to verify
pytest tests/components/my_integration
```
## Test Commands
```bash
# Run tests with coverage
pytest tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing \
--numprocesses=auto
# Run specific test
pytest tests/components/my_integration/test_config_flow.py::test_user_flow_success
# Quick test of changed files
pytest --timeout=10 --picked
```
## Best Practices
1. **Never access `hass.data` directly** - Use fixtures and proper setup
2. **Mock all external APIs** - Use fixtures with realistic JSON data
3. **Use snapshot testing** - For entity states and attributes
4. **Test error paths** - Connection errors, auth failures, invalid data
5. **Test edge cases** - Empty data, missing fields, None values
6. **>95% coverage required** - All code paths must be tested

View File

@@ -1,784 +0,0 @@
---
name: Home Assistant Integration knowledge
description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference.
---
### File Locations
- **Integration code**: `./homeassistant/components/<integration_domain>/`
- **Integration tests**: `./tests/components/<integration_domain>/`
## Integration Templates
### Standard Integration Structure
```
homeassistant/components/my_integration/
├── __init__.py # Entry point with async_setup_entry
├── manifest.json # Integration metadata and dependencies
├── const.py # Domain and constants
├── config_flow.py # UI configuration flow
├── coordinator.py # Data update coordinator (if needed)
├── entity.py # Base entity class (if shared patterns)
├── sensor.py # Sensor platform
├── strings.json # User-facing text and translations
├── services.yaml # Service definitions (if applicable)
└── quality_scale.yaml # Quality scale rule status
```
An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
### Minimal Integration Checklist
- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.)
- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry`
- [ ] `config_flow.py` with UI configuration support
- [ ] `const.py` with `DOMAIN` constant
- [ ] `strings.json` with at least config flow text
- [ ] Platform files (`sensor.py`, etc.) as needed
- [ ] `quality_scale.yaml` with rule status tracking
## Integration Quality Scale
Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply:
### Quality Scale Levels
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
- **Silver**: Enhanced functionality
- **Gold**: Advanced features
- **Platinum**: Highest quality standards
### Quality Scale Progression
- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows
- **Silver → Gold**: Add device management, diagnostics, translations
- **Gold → Platinum**: Add strict typing, async dependencies, websession injection
### How Rules Apply
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
2. **Bronze Rules**: Always required for any integration with quality scale
3. **Higher Tier Rules**: Only apply if integration targets that tier or higher
4. **Rule Status**: Check `quality_scale.yaml` in integration folder for:
- `done`: Rule implemented
- `exempt`: Rule doesn't apply (with reason in comment)
- `todo`: Rule needs implementation
### Example `quality_scale.yaml` Structure
```yaml
rules:
# Bronze (mandatory)
config-flow: done
entity-unique-id: done
action-setup:
status: exempt
comment: Integration does not register custom actions.
# Silver (if targeting Silver+)
entity-unavailable: done
parallel-updates: done
# Gold (if targeting Gold+)
devices: done
diagnostics: done
# Platinum (if targeting Platinum)
strict-typing: done
```
**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules.
## Code Organization
### Core Locations
- Shared constants: `homeassistant/const.py` (use these instead of hardcoding)
- Integration structure:
- `homeassistant/components/{domain}/const.py` - Constants
- `homeassistant/components/{domain}/models.py` - Data models
- `homeassistant/components/{domain}/coordinator.py` - Update coordinator
- `homeassistant/components/{domain}/config_flow.py` - Configuration flow
- `homeassistant/components/{domain}/{platform}.py` - Platform implementations
### Common Modules
- **coordinator.py**: Centralize data fetching logic
```python
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
```
- **entity.py**: Base entity definitions to reduce duplication
```python
class MyEntity(CoordinatorEntity[MyCoordinator]):
_attr_has_entity_name = True
```
### Runtime Data Storage
- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data
```python
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
client = MyClient(entry.data[CONF_HOST])
entry.runtime_data = client
```
### Manifest Requirements
- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements`
- **Integration Types**: `device`, `hub`, `service`, `system`, `helper`
- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`)
- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb`
- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`)
### Config Flow Patterns
- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1`
- **Unique ID Management**:
```python
await self.async_set_unique_id(device_unique_id)
self._abort_if_unique_id_configured()
```
- **Error Handling**: Define errors in `strings.json` under `config.error`
- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.)
### Integration Ownership
- **manifest.json**: Add GitHub usernames to `codeowners`:
```json
{
"domain": "my_integration",
"name": "My Integration",
"codeowners": ["@me"]
}
```
### Async Dependencies (Platinum)
- **Requirement**: All dependencies must use asyncio
- Ensures efficient task handling without thread context switching
### WebSession Injection (Platinum)
- **Pass WebSession**: Support passing web sessions to dependencies
```python
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
"""Set up integration from config entry."""
client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass))
```
- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx)
### Data Update Coordinator
- **Standard Pattern**: Use for efficient data management
```python
class MyCoordinator(DataUpdateCoordinator):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
self.client = client
async def _async_update_data(self):
try:
return await self.client.fetch_data()
except ApiError as err:
raise UpdateFailed(f"API communication error: {err}")
```
- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues
- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended
## Integration Guidelines
### Configuration Flow
- **UI Setup Required**: All integrations must support configuration via UI
- **Manifest**: Set `"config_flow": true` in `manifest.json`
- **Data Storage**:
- Connection-critical config: Store in `ConfigEntry.data`
- Non-critical settings: Store in `ConfigEntry.options`
- **Validation**: Always validate user input before creating entries
- **Config Entry Naming**:
- ❌ Do NOT allow users to set config entry names in config flows
- Names are automatically generated or can be customized later in UI
- ✅ Exception: Helper integrations MAY allow custom names in config flow
- **Connection Testing**: Test device/service connection during config flow:
```python
try:
await client.get_data()
except MyException:
errors["base"] = "cannot_connect"
```
- **Duplicate Prevention**: Prevent duplicate configurations:
```python
# Using unique ID
await self.async_set_unique_id(identifier)
self._abort_if_unique_id_configured()
# Using unique data
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
```
### Reauthentication Support
- **Required Method**: Implement `async_step_reauth` in config flow
- **Credential Updates**: Allow users to update credentials without re-adding
- **Validation**: Verify account matches existing unique ID:
```python
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}
)
```
### Reconfiguration Flow
- **Purpose**: Allow configuration updates without removing device
- **Implementation**: Add `async_step_reconfigure` method
- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch`
### Device Discovery
- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.)
```json
{
"zeroconf": ["_mydevice._tcp.local."]
}
```
- **Discovery Handler**: Implement appropriate `async_step_*` method:
```python
async def async_step_zeroconf(self, discovery_info):
"""Handle zeroconf discovery."""
await self.async_set_unique_id(discovery_info.properties["serialno"])
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
```
- **Network Updates**: Use discovery to update dynamic IP addresses
### Network Discovery Implementation
- **Zeroconf/mDNS**: Use async instances
```python
aiozc = await zeroconf.async_get_async_instance(hass)
```
- **SSDP Discovery**: Register callbacks with cleanup
```python
entry.async_on_unload(
ssdp.async_register_callback(
hass, _async_discovered_device,
{"st": "urn:schemas-upnp-org:device:ZonePlayer:1"}
)
)
```
### Bluetooth Integration
- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies
- **Connectable**: Set `"connectable": true` for connection-required devices
- **Scanner Usage**: Always use shared scanner instance
```python
scanner = bluetooth.async_get_scanner()
entry.async_on_unload(
bluetooth.async_register_callback(
hass, _async_discovered_device,
{"service_uuid": "example_uuid"},
bluetooth.BluetoothScanningMode.ACTIVE
)
)
```
- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts
### Setup Validation
- **Test Before Setup**: Verify integration can be set up in `async_setup_entry`
- **Exception Handling**:
- `ConfigEntryNotReady`: Device offline or temporary failure
- `ConfigEntryAuthFailed`: Authentication issues
- `ConfigEntryError`: Unresolvable setup problems
### Config Entry Unloading
- **Required**: Implement `async_unload_entry` for runtime removal/reload
- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms`
- **Cleanup**: Register callbacks with `entry.async_on_unload`:
```python
async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
entry.runtime_data.listener() # Clean up resources
return unload_ok
```
### Service Actions
- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry`
- **Validation**: Check config entry existence and loaded state:
```python
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def service_action(call: ServiceCall) -> ServiceResponse:
if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])):
raise ServiceValidationError("Entry not found")
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError("Entry not loaded")
```
- **Exception Handling**: Raise appropriate exceptions:
```python
# For invalid input
if end_date < start_date:
raise ServiceValidationError("End date must be after start date")
# For service errors
try:
await client.set_schedule(start_date, end_date)
except MyConnectionError as err:
raise HomeAssistantError("Could not connect to the schedule") from err
```
### Service Registration Patterns
- **Entity Services**: Register on platform setup
```python
platform.async_register_entity_service(
"my_entity_service",
{vol.Required("parameter"): cv.string},
"handle_service_method"
)
```
- **Service Schema**: Always validate input
```python
SERVICE_SCHEMA = vol.Schema({
vol.Required("entity_id"): cv.entity_ids,
vol.Required("parameter"): cv.string,
vol.Optional("timeout", default=30): cv.positive_int,
})
```
- **Services File**: Create `services.yaml` with descriptions and field definitions
### Polling
- Use update coordinator pattern when possible
- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries
- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input
- **Minimum Intervals**:
- Local network: 5 seconds
- Cloud services: 60 seconds
- **Parallel Updates**: Specify number of concurrent updates:
```python
PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device
# OR
PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only)
```
## Entity Development
### Unique IDs
- **Required**: Every entity must have a unique ID for registry tracking
- Must be unique per platform (not per integration)
- Don't include integration domain or platform in ID
- **Implementation**:
```python
class MySensor(SensorEntity):
def __init__(self, device_id: str) -> None:
self._attr_unique_id = f"{device_id}_temperature"
```
**Acceptable ID Sources**:
- Device serial numbers
- MAC addresses (formatted using `format_mac` from device registry)
- Physical identifiers (printed/EEPROM)
- Config entry ID as last resort: `f"{entry.entry_id}-battery"`
**Never Use**:
- IP addresses, hostnames, URLs
- Device names
- Email addresses, usernames
### Entity Descriptions
- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation
- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability
- **Bad pattern**:
```python
SensorEntityDescription(
key="temperature",
name="Temperature",
value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long
)
```
- **Good pattern**:
```python
SensorEntityDescription(
key="temperature",
name="Temperature",
value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda
round(data["temp_value"] * 1.8 + 32, 1)
if data.get("temp_value") is not None
else None
),
)
```
### Entity Naming
- **Use has_entity_name**: Set `_attr_has_entity_name = True`
- **For specific fields**:
```python
class MySensor(SensorEntity):
_attr_has_entity_name = True
def __init__(self, device: Device, field: str) -> None:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
)
self._attr_name = field # e.g., "temperature", "humidity"
```
- **For device itself**: Set `_attr_name = None`
### Event Lifecycle Management
- **Subscribe in `async_added_to_hass`**:
```python
async def async_added_to_hass(self) -> None:
"""Subscribe to events."""
self.async_on_remove(
self.client.events.subscribe("my_event", self._handle_event)
)
```
- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove`
- Never subscribe in `__init__` or other methods
### State Handling
- Unknown values: Use `None` (not "unknown" or "unavailable")
- Availability: Implement `available()` property instead of using "unavailable" state
### Entity Availability
- **Mark Unavailable**: When data cannot be fetched from device/service
- **Coordinator Pattern**:
```python
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.identifier in self.coordinator.data
```
- **Direct Update Pattern**:
```python
async def async_update(self) -> None:
"""Update entity."""
try:
data = await self.client.get_data()
except MyException:
self._attr_available = False
else:
self._attr_available = True
self._attr_native_value = data.value
```
### Extra State Attributes
- All attribute keys must always be present
- Unknown values: Use `None`
- Provide descriptive attributes
## Device Management
### Device Registry
- **Create Devices**: Group related entities under devices
- **Device Info**: Provide comprehensive metadata:
```python
_attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, device.mac)},
identifiers={(DOMAIN, device.id)},
name=device.name,
manufacturer="My Company",
model="My Sensor",
sw_version=device.version,
)
```
- For services: Add `entry_type=DeviceEntryType.SERVICE`
### Dynamic Device Addition
- **Auto-detect New Devices**: After initial setup
- **Implementation Pattern**:
```python
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices])
entry.async_on_unload(coordinator.async_add_listener(_check_device))
```
### Stale Device Removal
- **Auto-remove**: When devices disappear from hub/account
- **Device Registry Update**:
```python
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
```
- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed
### Entity Categories
- **Required**: Assign appropriate category to entities
- **Implementation**: Set `_attr_entity_category`
```python
class MySensor(SensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
```
- Categories include: `DIAGNOSTIC` for system/technical information
### Device Classes
- **Use When Available**: Set appropriate device class for entity type
```python
class MyTemperatureSensor(SensorEntity):
_attr_device_class = SensorDeviceClass.TEMPERATURE
```
- Provides context for: unit conversion, voice control, UI representation
### Disabled by Default
- **Disable Noisy/Less Popular Entities**: Reduce resource usage
```python
class MySignalStrengthSensor(SensorEntity):
_attr_entity_registry_enabled_default = False
```
- Target: frequently changing states, technical diagnostics
### Entity Translations
- **Required with has_entity_name**: Support international users
- **Implementation**:
```python
class MySensor(SensorEntity):
_attr_has_entity_name = True
_attr_translation_key = "phase_voltage"
```
- Create `strings.json` with translations:
```json
{
"entity": {
"sensor": {
"phase_voltage": {
"name": "Phase voltage"
}
}
}
}
```
### Exception Translations (Gold)
- **Translatable Errors**: Use translation keys for user-facing exceptions
- **Implementation**:
```python
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_date_before_start_date",
)
```
- Add to `strings.json`:
```json
{
"exceptions": {
"end_date_before_start_date": {
"message": "The end date cannot be before the start date."
}
}
}
```
### Icon Translations (Gold)
- **Dynamic Icons**: Support state and range-based icon selection
- **State-based Icons**:
```json
{
"entity": {
"sensor": {
"tree_pollen": {
"default": "mdi:tree",
"state": {
"high": "mdi:tree-outline"
}
}
}
}
}
```
- **Range-based Icons** (for numeric values):
```json
{
"entity": {
"sensor": {
"battery_level": {
"default": "mdi:battery-unknown",
"range": {
"0": "mdi:battery-outline",
"90": "mdi:battery-90",
"100": "mdi:battery"
}
}
}
}
}
```
## Testing Requirements
- **Location**: `tests/components/{domain}/`
- **Coverage Requirement**: Above 95% test coverage for all modules
- **Best Practices**:
- Use pytest fixtures from `tests.common`
- Mock all external dependencies
- Use snapshots for complex data structures
- Follow existing test patterns
### Config Flow Testing
- **100% Coverage Required**: All config flow paths must be tested
- **Test Scenarios**:
- All flow initiation methods (user, discovery, import)
- Successful configuration paths
- Error recovery scenarios
- Prevention of duplicate entries
- Flow completion after errors
### Testing
- **Integration-specific tests** (recommended):
```bash
pytest ./tests/components/<integration_domain> \
--cov=homeassistant.components.<integration_domain> \
--cov-report term-missing \
--durations-min=1 \
--durations=0 \
--numprocesses=auto
```
### Testing Best Practices
- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead
- **Use snapshot testing** - For verifying entity states and attributes
- **Test through integration setup** - Don't test entities in isolation
- **Mock external APIs** - Use fixtures with realistic JSON data
- **Verify registries** - Ensure entities are properly registered with devices
### Config Flow Testing Template
```python
async def test_user_flow_success(hass, mock_api):
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
# Test form submission
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "My Device"
assert result["data"] == TEST_USER_INPUT
async def test_flow_connection_error(hass, mock_api_error):
"""Test connection error handling."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
```
### Entity Testing Patterns
```python
@pytest.fixture
def platforms() -> list[Platform]:
"""Overridden fixture to specify platforms to test."""
return [Platform.SENSOR] # Or another specific platform as needed.
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
# Ensure entities are correctly assigned to device
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "device_unique_id")}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
```
### Mock Patterns
```python
# Modern integration fixture setup
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="My Integration",
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"},
unique_id="device_unique_id",
)
@pytest.fixture
def mock_device_api() -> Generator[MagicMock]:
"""Return a mocked device API."""
with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock:
api = api_mock.return_value
api.get_data.return_value = MyDeviceData.from_json(
load_fixture("device_data.json", DOMAIN)
)
yield api
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return PLATFORMS
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device_api: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
```
## Debugging & Troubleshooting
### Common Issues & Solutions
- **Integration won't load**: Check `manifest.json` syntax and required fields
- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation
- **Config flow errors**: Check `strings.json` entries and error handling
- **Discovery not working**: Verify manifest discovery configuration and callbacks
- **Tests failing**: Check mock setup and async context
### Debug Logging Setup
```python
# Enable debug logging in tests
caplog.set_level(logging.DEBUG, logger="my_integration")
# In integration code - use proper logging
_LOGGER = logging.getLogger(__name__)
_LOGGER.debug("Processing data: %s", data) # Use lazy logging
```
### Validation Commands
```bash
# Check specific integration
python -m script.hassfest --integration-path homeassistant/components/my_integration
# Validate quality scale
# Check quality_scale.yaml against current rules
# Run integration tests with coverage
pytest ./tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing
```

View File

@@ -1,19 +0,0 @@
# Integration Diagnostics
Platform exists as `homeassistant/components/<domain>/diagnostics.py`.
- **Required**: Implement diagnostic data collection
- **Implementation**:
```python
TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": entry.runtime_data.data,
}
```
- **Security**: Never expose passwords, tokens, or sensitive coordinates

View File

@@ -1,55 +0,0 @@
# Repairs platform
Platform exists as `homeassistant/components/<domain>/repairs.py`.
- **Actionable Issues Required**: All repair issues must be actionable for end users
- **Issue Content Requirements**:
- Clearly explain what is happening
- Provide specific steps users need to take to resolve the issue
- Use friendly, helpful language
- Include relevant context (device names, error details, etc.)
- **Implementation**:
```python
ir.async_create_issue(
hass,
DOMAIN,
"outdated_version",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.ERROR,
translation_key="outdated_version",
)
```
- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`:
```json
{
"issues": {
"outdated_version": {
"title": "Device firmware is outdated",
"description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant."
}
}
}
```
- **String Content Must Include**:
- What the problem is
- Why it matters
- Exact steps to resolve (numbered list when multiple steps)
- What to expect after following the steps
- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps
- **Severity Guidelines**:
- `CRITICAL`: Reserved for extreme scenarios only
- `ERROR`: Requires immediate user attention
- `WARNING`: Indicates future potential breakage
- **Additional Attributes**:
```python
ir.async_create_issue(
hass, DOMAIN, "issue_id",
breaks_in_ha_version="2024.1.0",
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.ERROR,
translation_key="issue_description",
)
```
- Only create issues for problems users can potentially resolve

View File

@@ -8,6 +8,9 @@
"PYTHONASYNCIODEBUG": "1"
},
"features": {
// Node feature required for Claude Code until fixed https://github.com/anthropics/devcontainer-features/issues/28
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// Port 5683 udp is used by Shelly integration

View File

@@ -80,7 +80,7 @@ If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running: `python3 -m script.hassfest`.
- [ ] New or updated dependencies have been added to `requirements_all.txt`.
Updated by running `python3 -m script.gen_requirements_all`.
- [ ] For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.
- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.
<!--
This project is very active and we have a high turnover of pull requests.

File diff suppressed because it is too large Load Diff

View File

@@ -10,12 +10,12 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.14.2"
DEFAULT_PYTHON: "3.13"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.01.0"
BASE_IMAGE_VERSION: "2025.12.0"
ARCHITECTURES: '["amd64", "aarch64"]'
jobs:
@@ -30,10 +30,10 @@ jobs:
architectures: ${{ env.ARCHITECTURES }}
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -96,7 +96,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
@@ -122,7 +122,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -184,7 +184,7 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -273,7 +273,7 @@ jobs:
- green
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set build additional args
run: |
@@ -287,7 +287,7 @@ jobs:
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -311,7 +311,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
@@ -358,13 +358,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -474,10 +474,10 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -519,10 +519,10 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -551,7 +551,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}

View File

@@ -40,9 +40,9 @@ env:
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.3"
DEFAULT_PYTHON: "3.14.2"
ALL_PYTHON_VERSIONS: "['3.14.2']"
HA_SHORT_VERSION: "2026.2"
DEFAULT_PYTHON: "3.13.11"
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support
@@ -97,7 +97,7 @@ jobs:
steps:
- &checkout
name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Generate partial Python venv restore key
id: generate_python_cache_key
run: |
@@ -254,7 +254,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@564dda4cfa5e96aafdc4a5696c4bf7b46baae5ac # v1.1.0
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
RUFF_OUTPUT_FORMAT: github
@@ -297,7 +297,7 @@ jobs:
- &setup-python-matrix
name: Set up Python ${{ matrix.python-version }}
id: python
uses: &actions-setup-python actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -310,7 +310,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: &actions-cache actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: venv
key: &key-python-venv >-
@@ -374,7 +374,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: &actions-cache-save actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -425,7 +425,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
uses: &actions-cache-restore actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: &actions-cache-restore actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
fail-on-cache-miss: true
@@ -479,22 +479,6 @@ jobs:
. venv/bin/activate
python -m script.gen_requirements_all validate
gen-copilot-instructions:
name: Check copilot instructions
runs-on: *runs-on-ubuntu
needs:
- info
if: |
github.event.inputs.pylint-only != 'true'
&& github.event.inputs.mypy-only != 'true'
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- *checkout
- *setup-python-default
- name: Run gen_copilot_instructions.py
run: |
python -m script.gen_copilot_instructions validate
dependency-review:
name: Dependency review
runs-on: *runs-on-ubuntu

View File

@@ -21,14 +21,14 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
category: "/language:python"

View File

@@ -10,7 +10,7 @@ on:
- "**strings.json"
env:
DEFAULT_PYTHON: "3.14.2"
DEFAULT_PYTHON: "3.13"
jobs:
upload:
@@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}

View File

@@ -17,7 +17,7 @@ on:
- "script/gen_requirements_all.py"
env:
DEFAULT_PYTHON: "3.14.2"
DEFAULT_PYTHON: "3.13"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}
@@ -31,11 +31,11 @@ jobs:
steps:
- &checkout
name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true

View File

@@ -1 +1 @@
3.14
3.13

View File

@@ -53,7 +53,6 @@ homeassistant.components.air_quality.*
homeassistant.components.airgradient.*
homeassistant.components.airly.*
homeassistant.components.airnow.*
homeassistant.components.airobot.*
homeassistant.components.airos.*
homeassistant.components.airq.*
homeassistant.components.airthings.*
@@ -376,7 +375,6 @@ homeassistant.components.no_ip.*
homeassistant.components.nordpool.*
homeassistant.components.notify.*
homeassistant.components.notion.*
homeassistant.components.nrgkick.*
homeassistant.components.ntfy.*
homeassistant.components.number.*
homeassistant.components.nut.*

328
AGENTS.md
View File

@@ -1,328 +0,0 @@
# GitHub Copilot & Claude Code Instructions
This repository contains the core of Home Assistant, a Python 3 based home automation application.
## Code Review Guidelines
**When reviewing code, do NOT comment on:**
- **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements
- **Compatibility**: Python 3.13+
- **Language Features**: Use the newest features when possible:
- Pattern matching
- Type hints
- f-strings (preferred over `%` or `.format()`)
- Dataclasses
- Walrus operator
### Strict Typing (Platinum)
- **Comprehensive Type Hints**: Add type hints to all functions, methods, and variables
- **Custom Config Entry Types**: When using runtime_data:
```python
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
```
- **Library Requirements**: Include `py.typed` file for PEP-561 compliance
## Code Quality Standards
- **Formatting**: Ruff
- **Linting**: PyLint and Ruff
- **Type Checking**: MyPy
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
- **Testing**: pytest with plain functions and fixtures
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)
### Writing Style Guidelines
- **Tone**: Friendly and informative
- **Perspective**: Use second-person ("you" and "your") for user-facing messages
- **Inclusivity**: Use objective, non-discriminatory language
- **Clarity**: Write for non-native English speakers
- **Formatting in Messages**:
- Use backticks for: file paths, filenames, variable names, field entries
- Use sentence case for titles and messages (capitalize only the first word and proper nouns)
- Avoid abbreviations when possible
### Documentation Standards
- **File Headers**: Short and concise
```python
"""Integration for Peblar EV chargers."""
```
- **Method/Function Docstrings**: Required for all
```python
async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
"""Set up Peblar from a config entry."""
```
- **Comment Style**:
- Use clear, descriptive comments
- Explain the "why" not just the "what"
- Keep code block lines under 80 characters when possible
- Use progressive disclosure (simple explanation first, complex details later)
## Async Programming
- All external I/O operations must be async
- **Best Practices**:
- Avoid sleeping in loops
- Avoid awaiting in loops - use `gather` instead
- No blocking calls
- Group executor jobs when possible - switching between event loop and executor is expensive
### Blocking Operations
- **Use Executor**: For blocking I/O operations
```python
result = await hass.async_add_executor_job(blocking_function, args)
```
- **Never Block Event Loop**: Avoid file operations, `time.sleep()`, blocking HTTP calls
- **Replace with Async**: Use `asyncio.sleep()` instead of `time.sleep()`
### Thread Safety
- **@callback Decorator**: For event loop safe functions
```python
@callback
def async_update_callback(self, event):
"""Safe to run in event loop."""
self.async_write_ha_state()
```
- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads
- **Registry Changes**: Must be done in event loop thread
### Error Handling
- **Exception Types**: Choose most specific exception available
- `ServiceValidationError`: User input errors (preferred over `ValueError`)
- `HomeAssistantError`: Device communication failures
- `ConfigEntryNotReady`: Temporary setup issues (device offline)
- `ConfigEntryAuthFailed`: Authentication problems
- `ConfigEntryError`: Permanent setup issues
- **Try/Catch Best Practices**:
- Only wrap code that can throw exceptions
- Keep try blocks minimal - process data after the try/catch
- **Avoid bare exceptions** except in specific cases:
- ❌ Generally not allowed: `except:` or `except Exception:`
- ✅ Allowed in config flows to ensure robustness
- ✅ Allowed in functions/methods that run in background tasks
- Bad pattern:
```python
try:
data = await device.get_data() # Can throw
# ❌ Don't process data inside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
except DeviceError:
_LOGGER.error("Failed to get data")
```
- Good pattern:
```python
try:
data = await device.get_data() # Can throw
except DeviceError:
_LOGGER.error("Failed to get data")
return
# ✅ Process data outside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
```
- **Bare Exception Usage**:
```python
# ❌ Not allowed in regular code
try:
data = await device.get_data()
except Exception: # Too broad
_LOGGER.error("Failed")
# ✅ Allowed in config flow for robustness
async def async_step_user(self, user_input=None):
try:
await self._test_connection(user_input)
except Exception: # Allowed here
errors["base"] = "unknown"
# ✅ Allowed in background tasks
async def _background_refresh():
try:
await coordinator.async_refresh()
except Exception: # Allowed in task
_LOGGER.exception("Unexpected error in background task")
```
- **Setup Failure Patterns**:
```python
try:
await device.async_setup()
except (asyncio.TimeoutError, TimeoutException) as ex:
raise ConfigEntryNotReady(f"Timeout connecting to {device.host}") from ex
except AuthFailed as ex:
raise ConfigEntryAuthFailed(f"Credentials expired for {device.name}") from ex
```
### Logging
- **Format Guidelines**:
- No periods at end of messages
- No integration names/domains (added automatically)
- No sensitive data (keys, tokens, passwords)
- Use debug level for non-user-facing messages
- **Use Lazy Logging**:
```python
_LOGGER.debug("This is a log message with %s", variable)
```
### Unavailability Logging
- **Log Once**: When device/service becomes unavailable (info level)
- **Log Recovery**: When device/service comes back online
- **Implementation Pattern**:
```python
_unavailable_logged: bool = False
if not self._unavailable_logged:
_LOGGER.info("The sensor is unavailable: %s", ex)
self._unavailable_logged = True
# On recovery:
if self._unavailable_logged:
_LOGGER.info("The sensor is back online")
self._unavailable_logged = False
```
## Development Commands
### Environment
- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate`
- **Dev container**: No activation needed, the environment is pre-configured
### Code Quality & Linting
- **Run all linters on all files**: `prek run --all-files`
- **Run linters on staged files only**: `prek run`
- **PyLint on everything** (slow): `pylint homeassistant`
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
- **MyPy type checking (whole project)**: `mypy homeassistant/`
- **MyPy on specific integration**: `mypy homeassistant/components/my_integration`
### Testing
- **Quick test of changed files**: `pytest --timeout=10 --picked`
- **Update test snapshots**: Add `--snapshot-update` to pytest command
- ⚠️ Omit test results after using `--snapshot-update`
- Always run tests again without the flag to verify snapshots
- **Full test suite** (AVOID - very slow): `pytest ./tests`
### Dependencies & Requirements
- **Update generated files after dependency changes**: `python -m script.gen_requirements_all`
- **Install all Python requirements**:
```bash
uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt
```
- **Install test requirements only**:
```bash
uv pip install -r requirements_test_all.txt -r requirements.txt
```
### Translations
- **Update translations after strings.json changes**:
```bash
python -m script.translations develop --all
```
### Project Validation
- **Run hassfest** (checks project structure and updates generated files):
```bash
python -m script.hassfest
```
## Common Anti-Patterns & Best Practices
### ❌ **Avoid These Patterns**
```python
# Blocking operations in event loop
data = requests.get(url) # ❌ Blocks event loop
time.sleep(5) # ❌ Blocks event loop
# Reusing BleakClient instances
self.client = BleakClient(address)
await self.client.connect()
# Later...
await self.client.connect() # ❌ Don't reuse
# Hardcoded strings in code
self._attr_name = "Temperature Sensor" # ❌ Not translatable
# Missing error handling
data = await self.api.get_data() # ❌ No exception handling
# Storing sensitive data in diagnostics
return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets
# Accessing hass.data directly in tests
coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data
# User-configurable polling intervals
# In config flow
vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed
# In coordinator
update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed
# User-configurable config entry names (non-helper integrations)
vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations
# Too much code in try block
try:
response = await client.get_data() # Can throw
# ❌ Data processing should be outside try block
temperature = response["temperature"] / 10
humidity = response["humidity"]
self._attr_native_value = temperature
except ClientError:
_LOGGER.error("Failed to fetch data")
# Bare exceptions in regular code
try:
value = await sensor.read_value()
except Exception: # ❌ Too broad - catch specific exceptions
_LOGGER.error("Failed to read sensor")
```
### ✅ **Use These Patterns Instead**
```python
# Async operations with executor
data = await hass.async_add_executor_job(requests.get, url)
await asyncio.sleep(5) # ✅ Non-blocking
# Fresh BleakClient instances
client = BleakClient(address) # ✅ New instance each time
await client.connect()
# Translatable entity names
_attr_translation_key = "temperature_sensor" # ✅ Translatable
# Proper error handling
try:
data = await self.api.get_data()
except ApiException as err:
raise UpdateFailed(f"API error: {err}") from err
# Redacted diagnostics data
return async_redact_data(data, {"api_key", "password"}) # ✅ Safe
# Test through proper integration setup and fixtures
@pytest.fixture
async def init_integration(hass, mock_config_entry, mock_api):
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup
# Integration-determined polling intervals (not user-configurable)
SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
# ✅ Integration determines interval based on device capabilities, connection type, etc.
interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=interval,
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
```

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
.github/copilot-instructions.md

View File

@@ -1 +1 @@
AGENTS.md
.github/copilot-instructions.md

15
CODEOWNERS generated
View File

@@ -288,8 +288,6 @@ build.json @home-assistant/supervisor
/tests/components/cloud/ @home-assistant/cloud
/homeassistant/components/cloudflare/ @ludeeus @ctalkington
/tests/components/cloudflare/ @ludeeus @ctalkington
/homeassistant/components/cloudflare_r2/ @corrreia
/tests/components/cloudflare_r2/ @corrreia
/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99
/tests/components/co2signal/ @jpbede @VIKTORVAV99
/homeassistant/components/coinbase/ @tombrien
@@ -643,8 +641,6 @@ build.json @home-assistant/supervisor
/tests/components/gpsd/ @fabaff @jrieger
/homeassistant/components/gree/ @cmroche
/tests/components/gree/ @cmroche
/homeassistant/components/green_planet_energy/ @petschni
/tests/components/green_planet_energy/ @petschni
/homeassistant/components/greeneye_monitor/ @jkeljo
/tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core
@@ -921,8 +917,6 @@ build.json @home-assistant/supervisor
/tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob
/homeassistant/components/liebherr/ @mettolen
/tests/components/liebherr/ @mettolen
/homeassistant/components/lifx/ @Djelibeybi
/tests/components/lifx/ @Djelibeybi
/homeassistant/components/light/ @home-assistant/core
@@ -1130,8 +1124,6 @@ build.json @home-assistant/supervisor
/tests/components/notify_events/ @matrozov @papajojo
/homeassistant/components/notion/ @bachya
/tests/components/notion/ @bachya
/homeassistant/components/nrgkick/ @andijakl
/tests/components/nrgkick/ @andijakl
/homeassistant/components/nsw_fuel_station/ @nickw444
/tests/components/nsw_fuel_station/ @nickw444
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
@@ -1267,8 +1259,6 @@ build.json @home-assistant/supervisor
/tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
/homeassistant/components/prana/ @prana-dev-official
/tests/components/prana/ @prana-dev-official
/homeassistant/components/private_ble_device/ @Jc2k
/tests/components/private_ble_device/ @Jc2k
/homeassistant/components/probe_plus/ @pantherale0
@@ -1732,8 +1722,6 @@ build.json @home-assistant/supervisor
/tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen
/homeassistant/components/twitch/ @joostlek
/tests/components/twitch/ @joostlek
/homeassistant/components/uhoo/ @getuhoo @joshsmonta
/tests/components/uhoo/ @getuhoo @joshsmonta
/homeassistant/components/ukraine_alarm/ @PaulAnnekov
/tests/components/ukraine_alarm/ @PaulAnnekov
/homeassistant/components/unifi/ @Kane610
@@ -1821,7 +1809,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
/tests/components/waterfurnace/ @sdague @masterkoppa
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai
@@ -1880,8 +1867,6 @@ build.json @home-assistant/supervisor
/tests/components/worldclock/ @fabaff
/homeassistant/components/ws66i/ @ssaenger
/tests/components/ws66i/ @ssaenger
/homeassistant/components/wsdot/ @ucodery
/tests/components/wsdot/ @ucodery
/homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @hunterjm @tr4nt0r

4
Dockerfile generated
View File

@@ -24,13 +24,13 @@ ENV \
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv==0.9.26
&& pip3 install uv==0.9.17
WORKDIR /usr/src

View File

@@ -52,9 +52,6 @@ RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
uv pip install -r requirements.txt -r requirements_test.txt
# Claude Code native install
RUN curl -fsSL https://claude.ai/install.sh | bash
WORKDIR /workspaces
# Set the default shell to bash instead of sh

View File

@@ -67,6 +67,8 @@ from .const import (
BASE_PLATFORMS,
FORMAT_DATETIME,
KEY_DATA_LOGGING as DATA_LOGGING,
REQUIRED_NEXT_PYTHON_HA_RELEASE,
REQUIRED_NEXT_PYTHON_VER,
SIGNAL_BOOTSTRAP_INTEGRATIONS,
)
from .core_config import async_process_ha_core_config
@@ -514,6 +516,38 @@ async def async_from_config_dict(
stop = monotonic()
_LOGGER.info("Home Assistant initialized in %.2fs", stop - start)
if (
REQUIRED_NEXT_PYTHON_HA_RELEASE
and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER
):
current_python_version = ".".join(str(x) for x in sys.version_info[:3])
required_python_version = ".".join(str(x) for x in REQUIRED_NEXT_PYTHON_VER[:2])
_LOGGER.warning(
(
"Support for the running Python version %s is deprecated and "
"will be removed in Home Assistant %s; "
"Please upgrade Python to %s"
),
current_python_version,
REQUIRED_NEXT_PYTHON_HA_RELEASE,
required_python_version,
)
issue_registry.async_create_issue(
hass,
core.DOMAIN,
f"python_version_{required_python_version}",
is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,
translation_key="python_version",
translation_placeholders={
"current_python_version": current_python_version,
"required_python_version": required_python_version,
"breaks_in_ha_version": REQUIRED_NEXT_PYTHON_HA_RELEASE,
},
)
return hass

View File

@@ -1,5 +0,0 @@
{
"domain": "cloudflare",
"name": "Cloudflare",
"integrations": ["cloudflare", "cloudflare_r2"]
}

View File

@@ -1,6 +1,5 @@
{
"domain": "leviton",
"name": "Leviton",
"integrations": ["decora_wifi"],
"iot_standards": ["zwave"]
}

View File

@@ -30,7 +30,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_POLLING, DOMAIN, DOMAIN_DATA, LOGGER
from .const import CONF_POLLING, DOMAIN, LOGGER
from .services import async_setup_services
ATTR_DEVICE_NAME = "device_name"
@@ -99,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except (AbodeException, ConnectTimeout, HTTPError) as ex:
raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex
hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling)
hass.data[DOMAIN] = AbodeSystem(abode, polling)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -113,12 +113,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.stop)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.logout)
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop)
await hass.async_add_executor_job(hass.data[DOMAIN].abode.logout)
if logout_listener := hass.data[DOMAIN_DATA].logout_listener:
logout_listener()
hass.data.pop(DOMAIN_DATA)
hass.data[DOMAIN].logout_listener()
hass.data.pop(DOMAIN)
return unload_ok
@@ -128,16 +127,16 @@ async def setup_hass_events(hass: HomeAssistant) -> None:
def logout(event: Event) -> None:
"""Logout of Abode."""
if not hass.data[DOMAIN_DATA].polling:
hass.data[DOMAIN_DATA].abode.events.stop()
if not hass.data[DOMAIN].polling:
hass.data[DOMAIN].abode.events.stop()
hass.data[DOMAIN_DATA].abode.logout()
hass.data[DOMAIN].abode.logout()
LOGGER.info("Logged out of Abode")
if not hass.data[DOMAIN_DATA].polling:
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start)
if not hass.data[DOMAIN].polling:
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start)
hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once(
hass.data[DOMAIN].logout_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, logout
)
@@ -179,6 +178,6 @@ def setup_abode_events(hass: HomeAssistant) -> None:
]
for event in events:
hass.data[DOMAIN_DATA].abode.events.add_event_callback(
hass.data[DOMAIN].abode.events.add_event_callback(
event, partial(event_callback, event)
)

View File

@@ -13,7 +13,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -23,7 +24,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode alarm control panel device."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
)

View File

@@ -15,7 +15,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -25,7 +26,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode binary sensor devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
device_types = [
"connectivity",

View File

@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from . import AbodeSystem
from .const import DOMAIN_DATA, LOGGER
from .const import DOMAIN, LOGGER
from .entity import AbodeDevice
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
@@ -31,7 +31,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode camera devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeCamera(data, device, timeline.CAPTURE_IMAGE)

View File

@@ -1,19 +1,10 @@
"""Constants for the Abode Security System component."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import AbodeSystem
LOGGER = logging.getLogger(__package__)
DOMAIN = "abode"
DOMAIN_DATA: HassKey[AbodeSystem] = HassKey(DOMAIN)
ATTRIBUTION = "Data provided by goabode.com"
CONF_POLLING = "polling"

View File

@@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -19,7 +20,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode cover devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeCover(data, device)

View File

@@ -7,7 +7,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import AbodeSystem
from .const import ATTRIBUTION, DOMAIN, DOMAIN_DATA
from .const import ATTRIBUTION, DOMAIN
class AbodeEntity(Entity):
@@ -29,7 +29,7 @@ class AbodeEntity(Entity):
self._update_connection_status,
)
self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id)
self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from Abode connection status updates."""

View File

@@ -20,7 +20,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -30,7 +31,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode light devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeLight(data, device)
@@ -99,7 +100,7 @@ class AbodeLight(AbodeDevice, LightEntity):
return _hs
@property
def color_mode(self) -> ColorMode | None:
def color_mode(self) -> str | None:
"""Return the color mode of the light."""
if self._device.is_dimmable and self._device.is_color_capable:
if self.hs_color is not None:
@@ -110,7 +111,7 @@ class AbodeLight(AbodeDevice, LightEntity):
return ColorMode.ONOFF
@property
def supported_color_modes(self) -> set[ColorMode] | None:
def supported_color_modes(self) -> set[str] | None:
"""Flag supported color modes."""
if self._device.is_dimmable and self._device.is_color_capable:
return {ColorMode.COLOR_TEMP, ColorMode.HS}

View File

@@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice
@@ -19,7 +20,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode lock devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeLock(data, device)

View File

@@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeSystem
from .const import DOMAIN_DATA
from .const import DOMAIN
from .entity import AbodeDevice
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
@@ -66,7 +66,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode sensor devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
async_add_entities(
AbodeSensor(data, device, description)

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DOMAIN, DOMAIN_DATA, LOGGER
from .const import DOMAIN, LOGGER
SERVICE_SETTINGS = "change_setting"
SERVICE_CAPTURE_IMAGE = "capture_image"
@@ -35,7 +35,7 @@ def _change_setting(call: ServiceCall) -> None:
value = call.data[ATTR_VALUE]
try:
call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value)
call.hass.data[DOMAIN].abode.set_setting(setting, value)
except AbodeException as ex:
LOGGER.warning(ex)
@@ -46,7 +46,7 @@ def _capture_image(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
for entity_id in call.hass.data[DOMAIN].entity_ids
if entity_id in entity_ids
]
@@ -61,7 +61,7 @@ def _trigger_automation(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
for entity_id in call.hass.data[DOMAIN].entity_ids
if entity_id in entity_ids
]

View File

@@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA
from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeAutomation, AbodeDevice
DEVICE_TYPES = ["switch", "valve"]
@@ -24,7 +25,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode switch devices."""
data = hass.data[DOMAIN_DATA]
data: AbodeSystem = hass.data[DOMAIN]
entities: list[SwitchEntity] = [
AbodeSwitch(data, device)

View File

@@ -87,7 +87,7 @@ class AdaxConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=data_schema,
)
wifi_ssid = user_input[WIFI_SSID]
wifi_ssid = user_input[WIFI_SSID].replace(" ", "")
wifi_pswd = user_input[WIFI_PSWD].replace(" ", "")
configurator = adax_local.AdaxConfig(wifi_ssid, wifi_pswd)

View File

@@ -107,7 +107,7 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_hassio(
self, discovery_info: HassioServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a Hass.io AdGuard Home app.
"""Prepare configuration for a Hass.io AdGuard Home add-on.
This flow is triggered by the discovery component.
"""

View File

@@ -9,8 +9,8 @@
},
"step": {
"hassio_confirm": {
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the app: {addon}?",
"title": "AdGuard Home via Home Assistant app"
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the add-on: {addon}?",
"title": "AdGuard Home via Home Assistant add-on"
},
"user": {
"data": {

View File

@@ -8,15 +8,12 @@ from advantage_air import ApiError, advantage_air
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ADVANTAGE_AIR_RETRY, DOMAIN
from .const import ADVANTAGE_AIR_RETRY
from .models import AdvantageAirData
from .services import async_setup_services
type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirData]
@@ -35,14 +32,6 @@ PLATFORMS = [
_LOGGER = logging.getLogger(__name__)
REQUEST_REFRESH_DELAY = 0.5
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(
hass: HomeAssistant, entry: AdvantageAirDataConfigEntry

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from decimal import Decimal
from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -12,6 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
@@ -21,6 +24,7 @@ from .models import AdvantageAirData
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes"
ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min"
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
PARALLEL_UPDATES = 0
@@ -49,6 +53,13 @@ async def async_setup_entry(
entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key))
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
{vol.Required("minutes"): cv.positive_int},
"set_time_to",
)
class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
"""Representation of Advantage Air timer control."""

View File

@@ -1,27 +0,0 @@
"""Services for Advantage Air integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
entity_domain=SENSOR_DOMAIN,
schema={vol.Required("minutes"): cv.positive_int},
func="set_time_to",
)

View File

@@ -29,7 +29,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirobotConfigEntry
from .const import DOMAIN
from .coordinator import AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 1
@@ -64,7 +63,7 @@ class AirobotClimate(AirobotEntity, ClimateEntity):
_attr_min_temp = SETPOINT_TEMP_MIN
_attr_max_temp = SETPOINT_TEMP_MAX
def __init__(self, coordinator: AirobotDataUpdateCoordinator) -> None:
def __init__(self, coordinator) -> None:
"""Initialize the climate entity."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.data.status.device_id

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from dataclasses import dataclass
import logging
@@ -61,17 +60,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> DeviceInf
try:
# Try to fetch data to validate connection and authentication
status, settings = await asyncio.gather(
client.get_statuses(), client.get_settings()
)
status = await client.get_statuses()
settings = await client.get_settings()
except AirobotAuthError as err:
raise InvalidAuth from err
except (
AirobotConnectionError,
AirobotTimeoutError,
AirobotError,
TimeoutError,
) as err:
except (AirobotConnectionError, AirobotTimeoutError, AirobotError) as err:
raise CannotConnect from err
# Use device name or device ID as title

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
@@ -53,10 +52,8 @@ class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]):
async def _async_update_data(self) -> AirobotData:
"""Fetch data from API endpoint."""
try:
status, settings = await asyncio.gather(
self.client.get_statuses(),
self.client.get_settings(),
)
status = await self.client.get_statuses()
settings = await self.client.get_settings()
except AirobotAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,

View File

@@ -35,6 +35,6 @@ class AirobotEntity(CoordinatorEntity[AirobotDataUpdateCoordinator]):
manufacturer="Airobot",
model="Thermostat",
model_id="TE1",
sw_version=status.fw_version_string,
hw_version=status.hw_version_string,
sw_version=str(status.fw_version),
hw_version=str(status.hw_version),
)

View File

@@ -12,6 +12,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "platinum",
"requirements": ["pyairobotrest==0.3.0"]
"quality_scale": "gold",
"requirements": ["pyairobotrest==0.2.0"]
}

View File

@@ -69,4 +69,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
strict-typing: todo

View File

@@ -28,7 +28,6 @@ from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from . import AirobotConfigEntry
from .coordinator import AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@@ -54,7 +53,6 @@ SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda status: status.temp_air,
),
AirobotSensorEntityDescription(
@@ -138,7 +136,7 @@ class AirobotSensor(AirobotEntity, SensorEntity):
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
coordinator,
description: AirobotSensorEntityDescription,
) -> None:
"""Initialize the sensor."""

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["airos==0.6.3"]
"requirements": ["airos==0.6.1"]
}

View File

@@ -4,24 +4,12 @@
"health_index": {
"default": "mdi:heart-pulse"
},
"mold": {
"default": "mdi:water-check"
},
"oxygen": {
"default": "mdi:leaf"
},
"performance_index": {
"default": "mdi:head-check"
},
"r32": {
"default": "mdi:hvac"
},
"r454b": {
"default": "mdi:hvac"
},
"r454c": {
"default": "mdi:hvac"
},
"radon": {
"default": "mdi:radioactive"
},

View File

@@ -219,13 +219,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
state_class=SensorStateClass.MEASUREMENT,
value=lambda data: data.get("ch4_MIPEX"),
),
AirQEntityDescription(
key="mold",
translation_key="mold",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value=lambda data: data.get("mold"),
),
AirQEntityDescription(
key="n2o",
device_class=SensorDeviceClass.NITROUS_OXIDE,
@@ -326,25 +319,11 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
value=lambda data: data.get("c3h8_MIPEX"),
),
AirQEntityDescription(
key="r32",
translation_key="r32",
native_unit_of_measurement=PERCENTAGE,
key="refigerant",
translation_key="refigerant",
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value=lambda data: data.get("r32"),
),
AirQEntityDescription(
key="r454b",
translation_key="r454b",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value=lambda data: data.get("r454b"),
),
AirQEntityDescription(
key="r454c",
translation_key="r454c",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value=lambda data: data.get("r454c"),
value=lambda data: data.get("refigerant"),
),
AirQEntityDescription(
key="sih4",

View File

@@ -101,9 +101,6 @@
"methanethiol": {
"name": "Methanethiol"
},
"mold": {
"name": "Mold index"
},
"noise": {
"name": "Noise"
},
@@ -119,18 +116,12 @@
"propane": {
"name": "Propane"
},
"r32": {
"name": "Refrigerant R-32"
},
"r454b": {
"name": "Refrigerant R-454B"
},
"r454c": {
"name": "Refrigerant R-454C"
},
"radon": {
"name": "Radon"
},
"refigerant": {
"name": "Refrigerant"
},
"relative_pressure": {
"name": "Relative pressure"
},

View File

@@ -1,14 +1,6 @@
{
"entity": {
"sensor": {
"connectivity_mode": {
"default": "mdi:bluetooth-off",
"state": {
"bluetooth": "mdi:bluetooth",
"not_configured": "mdi:alert-circle",
"smartlink": "mdi:hub"
}
},
"radon_1day_avg": {
"default": "mdi:radioactive"
},

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import dataclasses
import logging
from airthings_ble import AirthingsConnectivityMode, AirthingsDevice
from airthings_ble import AirthingsDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -41,12 +41,6 @@ from .coordinator import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordina
_LOGGER = logging.getLogger(__name__)
CONNECTIVITY_MODE_MAP = {
AirthingsConnectivityMode.BLE.value: "bluetooth",
AirthingsConnectivityMode.SMARTLINK.value: "smartlink",
AirthingsConnectivityMode.NOT_CONFIGURED.value: "not_configured",
}
SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
"radon_1day_avg": SensorEntityDescription(
key="radon_1day_avg",
@@ -135,14 +129,6 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"connectivity_mode": SensorEntityDescription(
key="connectivity_mode",
translation_key="connectivity_mode",
device_class=SensorDeviceClass.ENUM,
options=list(CONNECTIVITY_MODE_MAP.values()),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
}
PARALLEL_UPDATES = 0
@@ -270,12 +256,4 @@ class AirthingsSensor(
@property
def native_value(self) -> StateType:
"""Return the value reported by the sensor."""
value = self.coordinator.data.sensors[self.entity_description.key]
# Map connectivity mode to enum values
if self.entity_description.key == "connectivity_mode":
if not isinstance(value, str):
return None
return CONNECTIVITY_MODE_MAP.get(value)
return value
return self.coordinator.data.sensors[self.entity_description.key]

View File

@@ -30,14 +30,6 @@
"ambient_noise": {
"name": "Ambient noise"
},
"connectivity_mode": {
"name": "Connectivity mode",
"state": {
"bluetooth": "Bluetooth",
"not_configured": "Not configured",
"smartlink": "SmartLink"
}
},
"illuminance": {
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
},

View File

@@ -9,7 +9,6 @@
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",

View File

@@ -166,7 +166,7 @@
},
"services": {
"alarm_arm_away": {
"description": "Arms an alarm in the away mode.",
"description": "Arms the alarm in the away mode.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
@@ -176,7 +176,7 @@
"name": "Arm away"
},
"alarm_arm_custom_bypass": {
"description": "Arms an alarm while allowing to bypass a custom area.",
"description": "Arms the alarm while allowing to bypass a custom area.",
"fields": {
"code": {
"description": "Code to arm the alarm.",
@@ -186,7 +186,7 @@
"name": "Arm with custom bypass"
},
"alarm_arm_home": {
"description": "Arms an alarm in the home mode.",
"description": "Arms the alarm in the home mode.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
@@ -196,7 +196,7 @@
"name": "Arm home"
},
"alarm_arm_night": {
"description": "Arms an alarm in the night mode.",
"description": "Arms the alarm in the night mode.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
@@ -206,7 +206,7 @@
"name": "Arm night"
},
"alarm_arm_vacation": {
"description": "Arms an alarm in the vacation mode.",
"description": "Arms the alarm in the vacation mode.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
@@ -216,7 +216,7 @@
"name": "Arm vacation"
},
"alarm_disarm": {
"description": "Disarms an alarm.",
"description": "Disarms the alarm.",
"fields": {
"code": {
"description": "Code to disarm the alarm.",
@@ -226,7 +226,7 @@
"name": "Disarm"
},
"alarm_trigger": {
"description": "Triggers an alarm manually.",
"description": "Triggers the alarm manually.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",

View File

@@ -4,7 +4,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import labs, websocket_api
from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.typing import ConfigType
@@ -18,13 +18,7 @@ from .analytics import (
EntityAnalyticsModifications,
async_devices_payload,
)
from .const import (
ATTR_ONBOARDED,
ATTR_PREFERENCES,
ATTR_SNAPSHOTS,
DOMAIN,
PREFERENCE_SCHEMA,
)
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, PREFERENCE_SCHEMA
from .http import AnalyticsDevicesView
__all__ = [
@@ -50,55 +44,29 @@ CONFIG_SCHEMA = vol.Schema(
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
LABS_SNAPSHOT_FEATURE = "snapshots"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the analytics integration."""
analytics_config = config.get(DOMAIN, {})
# For now we want to enable device analytics only if the url option
# is explicitly listed in YAML.
if CONF_SNAPSHOTS_URL in analytics_config:
await labs.async_update_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
)
disable_snapshots = False
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
else:
disable_snapshots = True
snapshots_url = None
analytics = Analytics(hass, snapshots_url)
analytics = Analytics(hass, snapshots_url, disable_snapshots)
# Load stored data
await analytics.load()
started = False
async def _async_handle_labs_update(
event: Event[labs.EventLabsUpdatedData],
) -> None:
"""Handle labs feature toggle."""
await analytics.save_preferences({ATTR_SNAPSHOTS: event.data["enabled"]})
if started:
await analytics.async_schedule()
@callback
def _async_labs_event_filter(event_data: labs.EventLabsUpdatedData) -> bool:
"""Filter labs events for this integration's snapshot feature."""
return (
event_data["domain"] == DOMAIN
and event_data["preview_feature"] == LABS_SNAPSHOT_FEATURE
)
async def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
nonlocal started
started = True
await analytics.async_schedule()
hass.bus.async_listen(
labs.EVENT_LABS_UPDATED,
_async_handle_labs_update,
event_filter=_async_labs_event_filter,
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
websocket_api.async_register_command(hass, websocket_analytics)

View File

@@ -22,7 +22,6 @@ from homeassistant.components.energy import (
DOMAIN as ENERGY_DOMAIN,
is_configured as energy_is_configured,
)
from homeassistant.components.labs import async_is_preview_feature_enabled
from homeassistant.components.recorder import (
DOMAIN as RECORDER_DOMAIN,
get_instance as get_recorder_instance,
@@ -242,10 +241,12 @@ class Analytics:
self,
hass: HomeAssistant,
snapshots_url: str | None = None,
disable_snapshots: bool = False,
) -> None:
"""Initialize the Analytics class."""
self._hass: HomeAssistant = hass
self._snapshots_url = snapshots_url
self._disable_snapshots = disable_snapshots
self._session = async_get_clientsession(hass)
self._data = AnalyticsData(False, {})
@@ -257,13 +258,15 @@ class Analytics:
def preferences(self) -> dict:
"""Return the current active preferences."""
preferences = self._data.preferences
return {
result = {
ATTR_BASE: preferences.get(ATTR_BASE, False),
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
}
if not self._disable_snapshots:
result[ATTR_SNAPSHOTS] = preferences.get(ATTR_SNAPSHOTS, False)
return result
@property
def onboarded(self) -> bool:
@@ -288,11 +291,6 @@ class Analytics:
"""Return bool if a supervisor is present."""
return is_hassio(self._hass)
@property
def _snapshots_enabled(self) -> bool:
"""Check if snapshots feature is enabled via labs."""
return async_is_preview_feature_enabled(self._hass, DOMAIN, "snapshots")
async def load(self) -> None:
"""Load preferences."""
stored = await self._store.async_load()
@@ -647,10 +645,7 @@ class Analytics:
),
)
if (
not self.preferences.get(ATTR_SNAPSHOTS, False)
or not self._snapshots_enabled
):
if not self.preferences.get(ATTR_SNAPSHOTS, False) or self._disable_snapshots:
LOGGER.debug("Snapshot analytics not scheduled")
if self._snapshot_scheduled:
self._snapshot_scheduled()

View File

@@ -7,11 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system",
"iot_class": "cloud_push",
"preview_features": {
"snapshots": {
"feedback_url": "https://forms.gle/GqvRmgmghSDco8M46",
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
}
},
"quality_scale": "internal"
}

View File

@@ -1,10 +0,0 @@
{
"preview_features": {
"snapshots": {
"description": "This free, open source device database of the Open Home Foundation helps users find useful information about smart home devices used in real installations.\n\nYou can help build it by anonymously sharing data about your devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).\n\nLearn more about the device database and how we process your data in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement), which you accept by opting in.",
"disable_confirmation": "Your data will no longer be shared with the Open Home Foundation's device database.",
"enable_confirmation": "This feature is still in development and may change. The device database is being refined based on user feedback and is not yet complete.",
"name": "Device database"
}
}
}

View File

@@ -13,10 +13,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_TRACKED_APPS, CONF_TRACKED_INTEGRATIONS
from .const import CONF_TRACKED_INTEGRATIONS
from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -60,30 +59,6 @@ async def async_setup_entry(
return True
async def async_migrate_entry(
hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry
) -> bool:
"""Migrate to a new version."""
# Migration for switching add-ons to apps
if entry.version < 2:
ent_reg = er.async_get(hass)
for entity_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
if not entity_entry.unique_id.startswith("addon_"):
continue
ent_reg.async_update_entity(
entity_entry.entity_id,
new_unique_id=entity_entry.unique_id.replace("addon_", "app_"),
)
options = dict(entry.options)
options[CONF_TRACKED_APPS] = options.pop("tracked_addons", [])
hass.config_entries.async_update_entry(entry, version=2, options=options)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry
) -> bool:

View File

@@ -26,7 +26,7 @@ from homeassistant.helpers.selector import (
from . import AnalyticsInsightsConfigEntry
from .const import (
CONF_TRACKED_APPS,
CONF_TRACKED_ADDONS,
CONF_TRACKED_CUSTOM_INTEGRATIONS,
CONF_TRACKED_INTEGRATIONS,
DOMAIN,
@@ -43,8 +43,6 @@ INTEGRATION_TYPES_WITHOUT_ANALYTICS = (
class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Homeassistant Analytics."""
VERSION = 2
@staticmethod
@callback
def async_get_options_flow(
@@ -61,7 +59,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
if all(
[
not user_input.get(CONF_TRACKED_APPS),
not user_input.get(CONF_TRACKED_ADDONS),
not user_input.get(CONF_TRACKED_INTEGRATIONS),
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
]
@@ -72,7 +70,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
title="Home Assistant Analytics Insights",
data={},
options={
CONF_TRACKED_APPS: user_input.get(CONF_TRACKED_APPS, []),
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
CONF_TRACKED_INTEGRATIONS: user_input.get(
CONF_TRACKED_INTEGRATIONS, []
),
@@ -86,7 +84,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
session=async_get_clientsession(self.hass)
)
try:
apps = await client.get_addons()
addons = await client.get_addons()
integrations = await client.get_integrations(Environment.NEXT)
custom_integrations = await client.get_custom_integrations()
except HomeassistantAnalyticsConnectionError:
@@ -109,9 +107,9 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
data_schema=vol.Schema(
{
vol.Optional(CONF_TRACKED_APPS): SelectSelector(
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
SelectSelectorConfig(
options=list(apps),
options=list(addons),
multiple=True,
sort=True,
)
@@ -146,7 +144,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
if user_input is not None:
if all(
[
not user_input.get(CONF_TRACKED_APPS),
not user_input.get(CONF_TRACKED_ADDONS),
not user_input.get(CONF_TRACKED_INTEGRATIONS),
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
]
@@ -156,7 +154,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
return self.async_create_entry(
title="",
data={
CONF_TRACKED_APPS: user_input.get(CONF_TRACKED_APPS, []),
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
CONF_TRACKED_INTEGRATIONS: user_input.get(
CONF_TRACKED_INTEGRATIONS, []
),
@@ -170,7 +168,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
session=async_get_clientsession(self.hass)
)
try:
apps = await client.get_addons()
addons = await client.get_addons()
integrations = await client.get_integrations(Environment.NEXT)
custom_integrations = await client.get_custom_integrations()
except HomeassistantAnalyticsConnectionError:
@@ -191,9 +189,9 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload):
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Optional(CONF_TRACKED_APPS): SelectSelector(
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
SelectSelectorConfig(
options=list(apps),
options=list(addons),
multiple=True,
sort=True,
)

View File

@@ -4,7 +4,7 @@ import logging
DOMAIN = "analytics_insights"
CONF_TRACKED_APPS = "tracked_apps"
CONF_TRACKED_ADDONS = "tracked_addons"
CONF_TRACKED_INTEGRATIONS = "tracked_integrations"
CONF_TRACKED_CUSTOM_INTEGRATIONS = "tracked_custom_integrations"

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_TRACKED_APPS,
CONF_TRACKED_ADDONS,
CONF_TRACKED_CUSTOM_INTEGRATIONS,
CONF_TRACKED_INTEGRATIONS,
DOMAIN,
@@ -35,7 +35,7 @@ class AnalyticsData:
active_installations: int
reports_integrations: int
apps: dict[str, int]
addons: dict[str, int]
core_integrations: dict[str, int]
custom_integrations: dict[str, int]
@@ -60,7 +60,7 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
update_interval=timedelta(hours=12),
)
self._client = client
self._tracked_apps = self.config_entry.options.get(CONF_TRACKED_APPS, [])
self._tracked_addons = self.config_entry.options.get(CONF_TRACKED_ADDONS, [])
self._tracked_integrations = self.config_entry.options[
CONF_TRACKED_INTEGRATIONS
]
@@ -70,9 +70,7 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
async def _async_update_data(self) -> AnalyticsData:
try:
apps_data = (
await self._client.get_addons()
) # Still add method name. Needs library update
addons_data = await self._client.get_addons()
data = await self._client.get_current_analytics()
custom_data = await self._client.get_custom_integrations()
except HomeassistantAnalyticsConnectionError as err:
@@ -81,7 +79,9 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
) from err
except HomeassistantAnalyticsNotModifiedError:
return self.data
apps = {app: get_app_value(apps_data, app) for app in self._tracked_apps}
addons = {
addon: get_addon_value(addons_data, addon) for addon in self._tracked_addons
}
core_integrations = {
integration: data.integrations.get(integration, 0)
for integration in self._tracked_integrations
@@ -93,14 +93,14 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
return AnalyticsData(
data.active_installations,
data.reports_integrations,
apps,
addons,
core_integrations,
custom_integrations,
)
def get_app_value(data: dict[str, Addon], name_slug: str) -> int:
"""Get app value."""
def get_addon_value(data: dict[str, Addon], name_slug: str) -> int:
"""Get addon value."""
if name_slug in data:
return data[name_slug].total
return 0

View File

@@ -29,17 +29,17 @@ class AnalyticsSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[AnalyticsData], StateType]
def get_app_entity_description(
def get_addon_entity_description(
name_slug: str,
) -> AnalyticsSensorEntityDescription:
"""Get app entity description."""
"""Get addon entity description."""
return AnalyticsSensorEntityDescription(
key=f"app_{name_slug}_active_installations",
translation_key="apps",
key=f"addon_{name_slug}_active_installations",
translation_key="addons",
name=name_slug,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.apps.get(name_slug),
value_fn=lambda data: data.addons.get(name_slug),
)
@@ -106,9 +106,9 @@ async def async_setup_entry(
entities.extend(
HomeassistantAnalyticsSensor(
coordinator,
get_app_entity_description(app_name_slug),
get_addon_entity_description(addon_name_slug),
)
for app_name_slug in coordinator.data.apps
for addon_name_slug in coordinator.data.addons
)
entities.extend(
HomeassistantAnalyticsSensor(

View File

@@ -10,12 +10,12 @@
"step": {
"user": {
"data": {
"tracked_apps": "Apps",
"tracked_addons": "Add-ons",
"tracked_custom_integrations": "Custom integrations",
"tracked_integrations": "Integrations"
},
"data_description": {
"tracked_apps": "Select the apps you want to track",
"tracked_addons": "Select the add-ons you want to track",
"tracked_custom_integrations": "Select the custom integrations you want to track",
"tracked_integrations": "Select the integrations you want to track"
}
@@ -45,12 +45,12 @@
"step": {
"init": {
"data": {
"tracked_apps": "[%key:component::analytics_insights::config::step::user::data::tracked_apps%]",
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data::tracked_addons%]",
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]",
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]"
},
"data_description": {
"tracked_apps": "[%key:component::analytics_insights::config::step::user::data_description::tracked_apps%]",
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data_description::tracked_addons%]",
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]",
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]"
}

View File

@@ -201,13 +201,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
)
for api in llm.async_get_apis(self.hass)
]
if suggested_llm_apis := self.options.get(CONF_LLM_HASS_API):
if isinstance(suggested_llm_apis, str):
suggested_llm_apis = [suggested_llm_apis]
known_apis = {api.id for api in llm.async_get_apis(self.hass)}
self.options[CONF_LLM_HASS_API] = [
api for api in suggested_llm_apis if api in known_apis
]
if (suggested_llm_apis := self.options.get(CONF_LLM_HASS_API)) and isinstance(
suggested_llm_apis, str
):
self.options[CONF_LLM_HASS_API] = [suggested_llm_apis]
step_schema: VolDictType = {}
errors: dict[str, str] = {}

View File

@@ -600,16 +600,6 @@ class AnthropicBaseLLMEntity(Entity):
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise TypeError("First message must be a system message")
# System prompt with caching enabled
system_prompt: list[TextBlockParam] = [
TextBlockParam(
type="text",
text=system.content,
cache_control={"type": "ephemeral"},
)
]
messages = _convert_content(chat_log.content[1:])
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
@@ -618,7 +608,7 @@ class AnthropicBaseLLMEntity(Entity):
model=model,
messages=messages,
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
system=system_prompt,
system=system.content,
stream=True,
)
@@ -705,6 +695,10 @@ class AnthropicBaseLLMEntity(Entity):
type="auto",
)
if isinstance(model_args["system"], str):
model_args["system"] = [
TextBlockParam(type="text", text=model_args["system"])
]
model_args["system"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",

View File

@@ -51,7 +51,7 @@ DEFAULT_NAME_HP = "HomePod"
BACKOFF_TIME_LOWER_LIMIT = 15 # seconds
BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.REMOTE]
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
AUTH_EXCEPTIONS = (
exceptions.AuthenticationError,

View File

@@ -1,63 +0,0 @@
"""Binary sensor support for Apple TV."""
from __future__ import annotations
from pyatv.const import KeyboardFocusState
from pyatv.interface import AppleTV, KeyboardListener
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AppleTvConfigEntry
from .entity import AppleTVEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AppleTvConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Apple TV binary sensor based on a config entry."""
# apple_tv config entries always have a unique id
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
manager = config_entry.runtime_data
async_add_entities([AppleTVKeyboardFocused(name, config_entry.unique_id, manager)])
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
"""Binary sensor for Text input focused."""
_attr_translation_key = "keyboard_focused"
_attr_available = True
@callback
def async_device_connected(self, atv: AppleTV) -> None:
"""Handle when connection is made to device."""
self._attr_available = True
# Listen to keyboard updates
atv.keyboard.listener = self
# Set initial state based on current focus state
self._update_state(atv.keyboard.text_focus_state == KeyboardFocusState.Focused)
@callback
def async_device_disconnected(self) -> None:
"""Handle when connection was lost to device."""
self._attr_available = False
self._update_state(False)
def focusstate_update(
self, old_state: KeyboardFocusState, new_state: KeyboardFocusState
) -> None:
"""Update keyboard state when it changes.
This is a callback function from pyatv.interface.KeyboardListener.
"""
self._update_state(new_state == KeyboardFocusState.Focused)
def _update_state(self, new_state: bool) -> None:
"""Update and report."""
self._attr_is_on = new_state
self.async_write_ha_state()

View File

@@ -18,6 +18,7 @@ class AppleTVEntity(Entity):
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
atv: AppleTVInterface | None = None
def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None:

View File

@@ -1,12 +0,0 @@
{
"entity": {
"binary_sensor": {
"keyboard_focused": {
"default": "mdi:keyboard",
"state": {
"off": "mdi:keyboard-off"
}
}
}
}
}

View File

@@ -115,7 +115,6 @@ class AppleTvMediaPlayer(
"""Representation of an Apple TV media player."""
_attr_supported_features = SUPPORT_APPLE_TV
_attr_name = None
def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None:
"""Initialize the Apple TV media player."""

View File

@@ -51,8 +51,6 @@ async def async_setup_entry(
class AppleTVRemote(AppleTVEntity, RemoteEntity):
"""Device that sends commands to an Apple TV."""
_attr_name = None
@property
def is_on(self) -> bool:
"""Return true if device is on."""

View File

@@ -62,13 +62,6 @@
}
}
},
"entity": {
"binary_sensor": {
"keyboard_focused": {
"name": "Keyboard focus"
}
}
},
"options": {
"step": {
"init": {

View File

@@ -2,35 +2,14 @@
from __future__ import annotations
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import ArveConfigEntry, ArveCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_migrate_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version == 1:
# 1 -> 1.2: Unique ID from integer to string
if entry.minor_version == 1:
minor_version = 2
hass.config_entries.async_update_entry(
entry, unique_id=str(entry.unique_id), minor_version=minor_version
)
_LOGGER.debug("Migration successful")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool:
"""Set up Arve from a config entry."""

View File

@@ -19,9 +19,6 @@ _LOGGER = logging.getLogger(__name__)
class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Arve."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -38,7 +35,7 @@ class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN):
except ArveConnectionError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(str(customer.customerId))
await self.async_set_unique_id(customer.customerId)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Arve",

View File

@@ -9,7 +9,6 @@
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_implementation_unavailable": "[%key:common::config_flow::abort::oauth2_implementation_unavailable%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_invalid_user": "Reauthenticate must use the same account.",

View File

@@ -7,7 +7,7 @@ import asyncio
from collections.abc import Callable, Mapping
from dataclasses import dataclass
import logging
from typing import Any, Protocol, cast
from typing import Any, Literal, Protocol, cast
from propcache.api import cached_property
import voluptuous as vol
@@ -25,11 +25,18 @@ from homeassistant.const import (
CONF_ACTIONS,
CONF_ALIAS,
CONF_CONDITIONS,
CONF_DEVICE_ID,
CONF_ENTITY_ID,
CONF_EVENT_DATA,
CONF_ID,
CONF_MODE,
CONF_OPTIONS,
CONF_PATH,
CONF_PLATFORM,
CONF_TARGET,
CONF_TRIGGERS,
CONF_VARIABLES,
CONF_ZONE,
EVENT_HOMEASSISTANT_STARTED,
SERVICE_RELOAD,
SERVICE_TOGGLE,
@@ -46,13 +53,10 @@ from homeassistant.core import (
ServiceCall,
callback,
split_entity_id,
valid_entity_id,
)
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
from homeassistant.helpers import (
condition as condition_helper,
config_validation as cv,
trigger as trigger_helper,
)
from homeassistant.helpers import condition as condition_helper, config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (
@@ -82,6 +86,7 @@ from homeassistant.helpers.trace import (
trace_get,
trace_path,
)
from homeassistant.helpers.trigger import async_initialize_triggers
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.dt import parse_datetime
@@ -120,18 +125,9 @@ NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"climate",
"device_tracker",
"fan",
"humidifier",
"lawn_mower",
"light",
"lock",
"media_player",
"person",
"siren",
"switch",
"vacuum",
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
@@ -613,7 +609,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
)
for conf in self._trigger_config:
referenced |= set(trigger_helper.async_extract_targets(conf, ATTR_LABEL_ID))
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
return referenced
@cached_property
@@ -628,7 +624,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
)
for conf in self._trigger_config:
referenced |= set(trigger_helper.async_extract_targets(conf, ATTR_FLOOR_ID))
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
return referenced
@cached_property
@@ -641,7 +637,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
referenced |= condition_helper.async_extract_targets(conf, ATTR_AREA_ID)
for conf in self._trigger_config:
referenced |= set(trigger_helper.async_extract_targets(conf, ATTR_AREA_ID))
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
return referenced
@property
@@ -661,7 +657,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
referenced |= condition_helper.async_extract_devices(conf)
for conf in self._trigger_config:
referenced |= set(trigger_helper.async_extract_devices(conf))
referenced |= set(_trigger_extract_devices(conf))
return referenced
@@ -675,7 +671,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
referenced |= condition_helper.async_extract_entities(conf)
for conf in self._trigger_config:
for entity_id in trigger_helper.async_extract_entities(conf):
for entity_id in _trigger_extract_entities(conf):
referenced.add(entity_id)
return referenced
@@ -949,7 +945,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
self._logger.error("Error rendering trigger variables: %s", err)
return None
return await trigger_helper.async_initialize_triggers(
return await async_initialize_triggers(
self.hass,
self._trigger_config,
self._async_trigger_if_enabled,
@@ -1233,6 +1229,78 @@ async def _async_process_if(
return result
@callback
def _trigger_extract_devices(trigger_conf: dict) -> list[str]:
"""Extract devices from a trigger config."""
if trigger_conf[CONF_PLATFORM] == "device":
return [trigger_conf[CONF_DEVICE_ID]]
if (
trigger_conf[CONF_PLATFORM] == "event"
and CONF_EVENT_DATA in trigger_conf
and CONF_DEVICE_ID in trigger_conf[CONF_EVENT_DATA]
and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID], str)
):
return [trigger_conf[CONF_EVENT_DATA][CONF_DEVICE_ID]]
if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf:
return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return]
if target_devices := _get_targets_from_trigger_config(trigger_conf, CONF_DEVICE_ID):
return target_devices
return []
@callback
def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
"""Extract entities from a trigger config."""
if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"):
return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return]
if trigger_conf[CONF_PLATFORM] == "calendar":
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
if trigger_conf[CONF_PLATFORM] == "zone":
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]
if trigger_conf[CONF_PLATFORM] == "geo_location":
return [trigger_conf[CONF_ZONE]]
if trigger_conf[CONF_PLATFORM] == "sun":
return ["sun.sun"]
if (
trigger_conf[CONF_PLATFORM] == "event"
and CONF_EVENT_DATA in trigger_conf
and CONF_ENTITY_ID in trigger_conf[CONF_EVENT_DATA]
and isinstance(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID], str)
and valid_entity_id(trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID])
):
return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]]
if target_entities := _get_targets_from_trigger_config(
trigger_conf, CONF_ENTITY_ID
):
return target_entities
return []
@callback
def _get_targets_from_trigger_config(
config: dict,
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
) -> list[str]:
"""Extract targets from a target config."""
if not (target_conf := config.get(CONF_TARGET)):
return []
if not (targets := target_conf.get(target)):
return []
return [targets] if isinstance(targets, str) else targets
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
def websocket_config(
hass: HomeAssistant,

View File

@@ -17,12 +17,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_MODEL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers import device_registry as dr
from homeassistant.util.ssl import get_default_context
from .const import DOMAIN
from .services import async_setup_services
from .websocket import BeoWebsocket
@@ -43,14 +41,6 @@ PLATFORMS = [
Platform.SENSOR,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
"""Set up from a config entry."""

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["mozart-api==5.3.1.108.2"],
"requirements": ["mozart-api==5.3.1.108.0"],
"zeroconf": ["_bangolufsen._tcp.local."]
}

View File

@@ -8,7 +8,6 @@ from datetime import timedelta
import json
import logging
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from aiohttp import ClientConnectorError
from mozart_api import __version__ as MOZART_API_VERSION
@@ -39,6 +38,7 @@ from mozart_api.models import (
VolumeState,
)
from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -56,10 +56,17 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util.dt import utcnow
from . import BeoConfigEntry
@@ -67,6 +74,7 @@ from .const import (
BEO_REPEAT_FROM_HA,
BEO_REPEAT_TO_HA,
BEO_STATES,
BEOLINK_JOIN_SOURCES,
BEOLINK_JOIN_SOURCES_TO_UPPER,
CONF_BEOLINK_JID,
CONNECTION_STATUS,
@@ -121,6 +129,61 @@ async def async_setup_entry(
update_before_add=True,
)
# Register actions.
platform = async_get_current_platform()
jid_regex = vol.Match(
r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$"
)
platform.async_register_entity_service(
name="beolink_join",
schema={
vol.Optional("beolink_jid"): jid_regex,
vol.Optional("source_id"): vol.In(BEOLINK_JOIN_SOURCES),
},
func="async_beolink_join",
)
platform.async_register_entity_service(
name="beolink_expand",
schema={
vol.Exclusive("all_discovered", "devices", ""): cv.boolean,
vol.Exclusive(
"beolink_jids",
"devices",
"Define either specific Beolink JIDs or all discovered",
): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_expand",
)
platform.async_register_entity_service(
name="beolink_unexpand",
schema={
vol.Required("beolink_jids"): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_unexpand",
)
platform.async_register_entity_service(
name="beolink_leave",
schema=None,
func="async_beolink_leave",
)
platform.async_register_entity_service(
name="beolink_allstandby",
schema=None,
func="async_beolink_allstandby",
)
class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
"""Representation of a media player."""
@@ -736,7 +799,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
await self._client.set_active_source(source_id=key)
else:
# Video
await self._client.post_remote_trigger(id=UUID(key))
await self._client.post_remote_trigger(id=key)
async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Select a sound mode."""
@@ -895,7 +958,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
translation_key="play_media_error",
translation_placeholders={
"media_type": media_type,
"error_message": json.loads(cast(str, error.body))["message"],
"error_message": json.loads(error.body)["message"],
},
) from error

View File

@@ -1,83 +0,0 @@
"""Services for Bang & Olufsen integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import BEOLINK_JOIN_SOURCES, DOMAIN
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
jid_regex = vol.Match(
r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$"
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"beolink_join",
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Optional("beolink_jid"): jid_regex,
vol.Optional("source_id"): vol.In(BEOLINK_JOIN_SOURCES),
},
func="async_beolink_join",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"beolink_expand",
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Exclusive("all_discovered", "devices", ""): cv.boolean,
vol.Exclusive(
"beolink_jids",
"devices",
"Define either specific Beolink JIDs or all discovered",
): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_expand",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"beolink_unexpand",
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Required("beolink_jids"): vol.All(
cv.ensure_list,
[jid_regex],
),
},
func="async_beolink_unexpand",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"beolink_leave",
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_beolink_leave",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"beolink_allstandby",
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_beolink_allstandby",
)

View File

@@ -13,7 +13,14 @@ from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import ATTR_MASTER, DOMAIN, SERVICE_JOIN, SERVICE_UNJOIN
from .const import (
ATTR_MASTER,
DOMAIN,
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
SERVICE_UNJOIN,
)
from .coordinator import (
BluesoundConfigEntry,
BluesoundCoordinator,
@@ -30,6 +37,22 @@ PLATFORMS = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_TIMER,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_increase_timer",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_CLEAR_TIMER,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_clear_timer",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,

View File

@@ -5,5 +5,7 @@ INTEGRATION_TITLE = "Bluesound"
ATTR_BLUESOUND_GROUP = "bluesound_group"
ATTR_MASTER = "master"
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"

View File

@@ -1,8 +1,14 @@
{
"services": {
"clear_sleep_timer": {
"service": "mdi:sleep-off"
},
"join": {
"service": "mdi:link-variant"
},
"set_sleep_timer": {
"service": "mdi:sleep"
},
"unjoin": {
"service": "mdi:link-variant-off"
}

Some files were not shown because too many files have changed in this diff Show More