mirror of
https://github.com/home-assistant/core.git
synced 2026-01-22 07:26:58 +01:00
Compare commits
151 Commits
multi_doma
...
claude/ext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa6c2b4f74 | ||
|
|
e0349cae63 | ||
|
|
83a53dea94 | ||
|
|
4fb89e68a7 | ||
|
|
5202ddf095 | ||
|
|
f7d7a4502e | ||
|
|
c7417d77b5 | ||
|
|
22018f1f80 | ||
|
|
22c6704d81 | ||
|
|
0552934b3c | ||
|
|
bbe1d28e88 | ||
|
|
b700a27c8f | ||
|
|
0566a668a9 | ||
|
|
94f636bc2d | ||
|
|
a6e7546142 | ||
|
|
493319894b | ||
|
|
987396722b | ||
|
|
4f52b0363d | ||
|
|
52e18ed6f6 | ||
|
|
4180175fd3 | ||
|
|
e39ee8cae7 | ||
|
|
c214193087 | ||
|
|
2d84847be5 | ||
|
|
0d69fd4535 | ||
|
|
56f556864c | ||
|
|
c1b03dc553 | ||
|
|
07e76578e6 | ||
|
|
bc45fd4e45 | ||
|
|
0ea03f549c | ||
|
|
0ee46dbf5d | ||
|
|
e12f394f8e | ||
|
|
b40046264d | ||
|
|
22afa1d248 | ||
|
|
8920ffbcdb | ||
|
|
a447c1b42e | ||
|
|
50211f75ed | ||
|
|
27117c9d17 | ||
|
|
7c4cdd57b6 | ||
|
|
6af5698645 | ||
|
|
75db2cde40 | ||
|
|
329dd05434 | ||
|
|
53c53d03e0 | ||
|
|
360b394d03 | ||
|
|
a663d55632 | ||
|
|
3fd266a513 | ||
|
|
442c1d6242 | ||
|
|
0e2aae02f6 | ||
|
|
3227a6e49f | ||
|
|
9d0cfb628b | ||
|
|
4578fe0260 | ||
|
|
0d92708108 | ||
|
|
cceb50071b | ||
|
|
62f296c9dd | ||
|
|
ea1f280494 | ||
|
|
67108a2fc8 | ||
|
|
1ccbd5124e | ||
|
|
818af90a7b | ||
|
|
23bc78fa25 | ||
|
|
0b1cc7638f | ||
|
|
c291a2fbc1 | ||
|
|
7379a4ff4b | ||
|
|
ddcf5cb749 | ||
|
|
4b10a542b0 | ||
|
|
beea9fa74b | ||
|
|
ce8fd16456 | ||
|
|
2172d15489 | ||
|
|
0cfa0ed670 | ||
|
|
f6839913d8 | ||
|
|
8fa01497ee | ||
|
|
e077c65a77 | ||
|
|
7c49656fa8 | ||
|
|
1730479c8d | ||
|
|
bc28c8fd3c | ||
|
|
c3616fd5df | ||
|
|
6b97f2ac06 | ||
|
|
deefcbcbe4 | ||
|
|
e84aeb9f99 | ||
|
|
ade3d8a657 | ||
|
|
a65d9032ff | ||
|
|
b950a4eaf4 | ||
|
|
3fe91751f5 | ||
|
|
6ee58b96ca | ||
|
|
d1404e7905 | ||
|
|
7c34191813 | ||
|
|
7540d04779 | ||
|
|
d828130670 | ||
|
|
2ec6c08bd7 | ||
|
|
48852bab7a | ||
|
|
7d370f4513 | ||
|
|
9d97791faf | ||
|
|
4fe8982b68 | ||
|
|
8248ade211 | ||
|
|
572c0e393c | ||
|
|
d25f2bab9a | ||
|
|
916812dd58 | ||
|
|
cea84aa3c8 | ||
|
|
af83fa809a | ||
|
|
8c997cb6a9 | ||
|
|
4ccb6e4c8b | ||
|
|
37a45b1a92 | ||
|
|
ac84211702 | ||
|
|
c209ddbb24 | ||
|
|
66ab50c737 | ||
|
|
46074b0f9c | ||
|
|
56d8913159 | ||
|
|
c1bbfec203 | ||
|
|
290c2fd5b6 | ||
|
|
e472180fb2 | ||
|
|
a1ced9a259 | ||
|
|
80a700f668 | ||
|
|
54fc963297 | ||
|
|
59776adeb3 | ||
|
|
af53daa43c | ||
|
|
65123609ea | ||
|
|
847adcf977 | ||
|
|
f0dc66cb53 | ||
|
|
54275a0ee4 | ||
|
|
964f36bc50 | ||
|
|
e83cbc3fc5 | ||
|
|
e26d90d82b | ||
|
|
da52482365 | ||
|
|
6ba16ee9e9 | ||
|
|
fa29d8180f | ||
|
|
5d43efb22d | ||
|
|
3539c4bcec | ||
|
|
3e3ec4616c | ||
|
|
907861effd | ||
|
|
862a2bc95c | ||
|
|
60f498c1fa | ||
|
|
bb3617ac08 | ||
|
|
48d1bd13fa | ||
|
|
8555bc9da0 | ||
|
|
9260394883 | ||
|
|
8503637a80 | ||
|
|
c993cd9bee | ||
|
|
171013c0d0 | ||
|
|
c8a7aa359e | ||
|
|
88d8951657 | ||
|
|
b66ab3cf92 | ||
|
|
253b32abd6 | ||
|
|
cc20072c86 | ||
|
|
f86db56d48 | ||
|
|
3e2ebb8ebb | ||
|
|
6e7b206788 | ||
|
|
cee007b0b0 | ||
|
|
bd24c27bc9 | ||
|
|
49bd26da86 | ||
|
|
49c42b9ad0 | ||
|
|
411491dc45 | ||
|
|
47383a499e | ||
|
|
f9aa307cb2 |
238
.claude/skills/ha-integration/BINARY-SENSOR.MD
Normal file
238
.claude/skills/ha-integration/BINARY-SENSOR.MD
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
201
.claude/skills/ha-integration/BUTTON.MD
Normal file
201
.claude/skills/ha-integration/BUTTON.MD
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
254
.claude/skills/ha-integration/CONFIG-FLOW.MD
Normal file
254
.claude/skills/ha-integration/CONFIG-FLOW.MD
Normal 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
|
||||
239
.claude/skills/ha-integration/COORDINATOR.MD
Normal file
239
.claude/skills/ha-integration/COORDINATOR.MD
Normal 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()
|
||||
```
|
||||
248
.claude/skills/ha-integration/DEVICE.MD
Normal file
248
.claude/skills/ha-integration/DEVICE.MD
Normal 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
|
||||
278
.claude/skills/ha-integration/DIAGNOSTICS.MD
Normal file
278
.claude/skills/ha-integration/DIAGNOSTICS.MD
Normal 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
|
||||
```
|
||||
286
.claude/skills/ha-integration/ENTITY.MD
Normal file
286
.claude/skills/ha-integration/ENTITY.MD
Normal 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
|
||||
),
|
||||
)
|
||||
```
|
||||
229
.claude/skills/ha-integration/NUMBER.MD
Normal file
229
.claude/skills/ha-integration/NUMBER.MD
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
252
.claude/skills/ha-integration/SELECT.MD
Normal file
252
.claude/skills/ha-integration/SELECT.MD
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
271
.claude/skills/ha-integration/SENSOR.MD
Normal file
271
.claude/skills/ha-integration/SENSOR.MD
Normal 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
|
||||
```
|
||||
335
.claude/skills/ha-integration/SERVICES.MD
Normal file
335
.claude/skills/ha-integration/SERVICES.MD
Normal 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
|
||||
165
.claude/skills/ha-integration/SKILL.md
Normal file
165
.claude/skills/ha-integration/SKILL.md
Normal 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>
|
||||
```
|
||||
236
.claude/skills/ha-integration/SWITCH.MD
Normal file
236
.claude/skills/ha-integration/SWITCH.MD
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
396
.claude/skills/ha-integration/TESTING.MD
Normal file
396
.claude/skills/ha-integration/TESTING.MD
Normal 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
|
||||
@@ -91,6 +91,7 @@ components: &components
|
||||
- homeassistant/components/input_number/**
|
||||
- homeassistant/components/input_select/**
|
||||
- homeassistant/components/input_text/**
|
||||
- homeassistant/components/labs/**
|
||||
- homeassistant/components/logbook/**
|
||||
- homeassistant/components/logger/**
|
||||
- homeassistant/components/lovelace/**
|
||||
|
||||
13
.github/workflows/ci.yaml
vendored
13
.github/workflows/ci.yaml
vendored
@@ -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@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
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@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
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@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: &actions-cache-restore actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
with:
|
||||
path: *path-apt-cache
|
||||
fail-on-cache-miss: true
|
||||
@@ -1187,6 +1187,8 @@ jobs:
|
||||
- pytest-postgres
|
||||
- pytest-mariadb
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
id-token: write
|
||||
# codecov/test-results-action currently doesn't support tokenless uploads
|
||||
# therefore we can't run it on forks
|
||||
if: |
|
||||
@@ -1198,8 +1200,9 @@ jobs:
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
report_type: test_results
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
use_oidc: true
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.13.0
|
||||
rev: v0.14.13
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -455,6 +455,7 @@ homeassistant.components.russound_rio.*
|
||||
homeassistant.components.ruuvi_gateway.*
|
||||
homeassistant.components.ruuvitag_ble.*
|
||||
homeassistant.components.samsungtv.*
|
||||
homeassistant.components.saunum.*
|
||||
homeassistant.components.scene.*
|
||||
homeassistant.components.schedule.*
|
||||
homeassistant.components.schlage.*
|
||||
|
||||
7
CODEOWNERS
generated
7
CODEOWNERS
generated
@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/mill/ @danielhiversen
|
||||
/homeassistant/components/min_max/ @gjohansson-ST
|
||||
/tests/components/min_max/ @gjohansson-ST
|
||||
/homeassistant/components/minecraft_server/ @elmurato
|
||||
/tests/components/minecraft_server/ @elmurato
|
||||
/homeassistant/components/minecraft_server/ @elmurato @zachdeibert
|
||||
/tests/components/minecraft_server/ @elmurato @zachdeibert
|
||||
/homeassistant/components/minio/ @tkislan
|
||||
/tests/components/minio/ @tkislan
|
||||
/homeassistant/components/moat/ @bdraco
|
||||
@@ -1273,7 +1273,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/prosegur/ @dgomes
|
||||
/homeassistant/components/proximity/ @mib1185
|
||||
/tests/components/proximity/ @mib1185
|
||||
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
|
||||
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
|
||||
/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
|
||||
/homeassistant/components/ps4/ @ktnrg45
|
||||
/tests/components/ps4/ @ktnrg45
|
||||
/homeassistant/components/pterodactyl/ @elmurato
|
||||
|
||||
@@ -52,7 +52,7 @@ class AdGuardHomeEntity(Entity):
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device information about this AdGuard Home instance."""
|
||||
if self._entry.source == SOURCE_HASSIO:
|
||||
config_url = "homeassistant://hassio/ingress/a0d7b954_adguard"
|
||||
config_url = "homeassistant://app/a0d7b954_adguard"
|
||||
elif self.adguard.tls:
|
||||
config_url = f"https://{self.adguard.host}:{self.adguard.port}"
|
||||
else:
|
||||
|
||||
@@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ class AirobotClimate(AirobotEntity, ClimateEntity):
|
||||
_attr_min_temp = SETPOINT_TEMP_MIN
|
||||
_attr_max_temp = SETPOINT_TEMP_MAX
|
||||
|
||||
def __init__(self, coordinator) -> None:
|
||||
"""Initialize the climate entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.data.status.device_id
|
||||
|
||||
@property
|
||||
def _status(self) -> ThermostatStatus:
|
||||
"""Get status from coordinator data."""
|
||||
|
||||
@@ -24,8 +24,6 @@ class AirobotEntity(CoordinatorEntity[AirobotDataUpdateCoordinator]):
|
||||
status = coordinator.data.status
|
||||
settings = coordinator.data.settings
|
||||
|
||||
self._attr_unique_id = status.device_id
|
||||
|
||||
connections = set()
|
||||
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
|
||||
connections.add((CONNECTION_NETWORK_MAC, mac))
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"hysteresis_band": {
|
||||
"default": "mdi:delta"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"actuator_exercise_disabled": {
|
||||
"default": "mdi:valve"
|
||||
},
|
||||
"child_lock": {
|
||||
"default": "mdi:lock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
"heating_uptime": {
|
||||
"name": "Heating uptime"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"actuator_exercise_disabled": {
|
||||
"name": "Actuator exercise disabled"
|
||||
},
|
||||
"child_lock": {
|
||||
"name": "Child lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -105,6 +113,12 @@
|
||||
},
|
||||
"set_value_failed": {
|
||||
"message": "Failed to set value: {error}"
|
||||
},
|
||||
"switch_turn_off_failed": {
|
||||
"message": "Failed to turn off {switch}."
|
||||
},
|
||||
"switch_turn_on_failed": {
|
||||
"message": "Failed to turn on {switch}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
118
homeassistant/components/airobot/switch.py
Normal file
118
homeassistant/components/airobot/switch.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Switch platform for Airobot thermostat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pyairobotrest.exceptions import AirobotError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AirobotConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirobotDataUpdateCoordinator
|
||||
from .entity import AirobotEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirobotSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describes Airobot switch entity."""
|
||||
|
||||
is_on_fn: Callable[[AirobotDataUpdateCoordinator], bool]
|
||||
turn_on_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
|
||||
turn_off_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
SWITCH_TYPES: tuple[AirobotSwitchEntityDescription, ...] = (
|
||||
AirobotSwitchEntityDescription(
|
||||
key="child_lock",
|
||||
translation_key="child_lock",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
is_on_fn=lambda coordinator: (
|
||||
coordinator.data.settings.setting_flags.childlock_enabled
|
||||
),
|
||||
turn_on_fn=lambda coordinator: coordinator.client.set_child_lock(True),
|
||||
turn_off_fn=lambda coordinator: coordinator.client.set_child_lock(False),
|
||||
),
|
||||
AirobotSwitchEntityDescription(
|
||||
key="actuator_exercise_disabled",
|
||||
translation_key="actuator_exercise_disabled",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
is_on_fn=lambda coordinator: (
|
||||
coordinator.data.settings.setting_flags.actuator_exercise_disabled
|
||||
),
|
||||
turn_on_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
|
||||
True
|
||||
),
|
||||
turn_off_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
|
||||
False
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AirobotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Airobot switch entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AirobotSwitch(coordinator, description) for description in SWITCH_TYPES
|
||||
)
|
||||
|
||||
|
||||
class AirobotSwitch(AirobotEntity, SwitchEntity):
|
||||
"""Representation of an Airobot switch."""
|
||||
|
||||
entity_description: AirobotSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirobotDataUpdateCoordinator,
|
||||
description: AirobotSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the switch is on."""
|
||||
return self.entity_description.is_on_fn(self.coordinator)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
try:
|
||||
await self.entity_description.turn_on_fn(self.coordinator)
|
||||
except AirobotError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_turn_on_failed",
|
||||
translation_placeholders={"switch": self.entity_description.key},
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
try:
|
||||
await self.entity_description.turn_off_fn(self.coordinator)
|
||||
except AirobotError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_turn_off_failed",
|
||||
translation_placeholders={"switch": self.entity_description.key},
|
||||
) from err
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -14,7 +14,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If an alarm is armed"
|
||||
"name": "Alarm is armed"
|
||||
},
|
||||
"is_armed_away": {
|
||||
"description": "Tests if one or more alarms are armed in away mode.",
|
||||
@@ -24,7 +24,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If an alarm is armed away"
|
||||
"name": "Alarm is armed away"
|
||||
},
|
||||
"is_armed_home": {
|
||||
"description": "Tests if one or more alarms are armed in home mode.",
|
||||
@@ -34,7 +34,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If an alarm is armed home"
|
||||
"name": "Alarm is armed home"
|
||||
},
|
||||
"is_armed_night": {
|
||||
"description": "Tests if one or more alarms are armed in night mode.",
|
||||
@@ -44,7 +44,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If an alarm is armed night"
|
||||
"name": "Alarm is armed night"
|
||||
},
|
||||
"is_armed_vacation": {
|
||||
"description": "Tests if one or more alarms are armed in vacation mode.",
|
||||
@@ -54,7 +54,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If an alarm is armed vacation"
|
||||
"name": "Alarm is armed vacation"
|
||||
},
|
||||
"is_disarmed": {
|
||||
"description": "Tests if one or more alarms are disarmed.",
|
||||
@@ -64,7 +64,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If an alarm is disarmed"
|
||||
"name": "Alarm is disarmed"
|
||||
},
|
||||
"is_triggered": {
|
||||
"description": "Tests if one or more alarms are triggered.",
|
||||
@@ -74,7 +74,7 @@
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If an alarm is triggered"
|
||||
"name": "Alarm is triggered"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
|
||||
@@ -5,9 +5,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from random import randrange
|
||||
import sys
|
||||
from typing import Any, cast
|
||||
|
||||
from pyatv import connect, exceptions, scan
|
||||
from pyatv.conf import AppleTV
|
||||
from pyatv.const import DeviceModel, Protocol
|
||||
from pyatv.convert import model_str
|
||||
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -24,11 +29,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
@@ -42,18 +43,6 @@ from .const import (
|
||||
SIGNAL_DISCONNECTED,
|
||||
)
|
||||
|
||||
if sys.version_info < (3, 14):
|
||||
from pyatv import connect, exceptions, scan
|
||||
from pyatv.conf import AppleTV
|
||||
from pyatv.const import DeviceModel, Protocol
|
||||
from pyatv.convert import model_str
|
||||
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
|
||||
else:
|
||||
|
||||
class DeviceListener:
|
||||
"""Dummy class."""
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME_TV = "Apple TV"
|
||||
@@ -64,30 +53,25 @@ BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
|
||||
|
||||
if sys.version_info < (3, 14):
|
||||
AUTH_EXCEPTIONS = (
|
||||
exceptions.AuthenticationError,
|
||||
exceptions.InvalidCredentialsError,
|
||||
exceptions.NoCredentialsError,
|
||||
)
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = (
|
||||
OSError,
|
||||
asyncio.CancelledError,
|
||||
TimeoutError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.ConnectionFailedError,
|
||||
)
|
||||
DEVICE_EXCEPTIONS = (
|
||||
exceptions.ProtocolError,
|
||||
exceptions.NoServiceError,
|
||||
exceptions.PairingError,
|
||||
exceptions.BackOffError,
|
||||
exceptions.DeviceIdMissingError,
|
||||
)
|
||||
else:
|
||||
AUTH_EXCEPTIONS = ()
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = ()
|
||||
DEVICE_EXCEPTIONS = ()
|
||||
AUTH_EXCEPTIONS = (
|
||||
exceptions.AuthenticationError,
|
||||
exceptions.InvalidCredentialsError,
|
||||
exceptions.NoCredentialsError,
|
||||
)
|
||||
CONNECTION_TIMEOUT_EXCEPTIONS = (
|
||||
OSError,
|
||||
asyncio.CancelledError,
|
||||
TimeoutError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.ConnectionFailedError,
|
||||
)
|
||||
DEVICE_EXCEPTIONS = (
|
||||
exceptions.ProtocolError,
|
||||
exceptions.NoServiceError,
|
||||
exceptions.PairingError,
|
||||
exceptions.BackOffError,
|
||||
exceptions.DeviceIdMissingError,
|
||||
)
|
||||
|
||||
|
||||
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
|
||||
@@ -95,10 +79,6 @@ type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
|
||||
"""Set up a config entry for Apple TV."""
|
||||
if sys.version_info >= (3, 14):
|
||||
raise HomeAssistantError(
|
||||
"Apple TV is not supported on Python 3.14. Please use Python 3.13."
|
||||
)
|
||||
manager = AppleTVManager(hass, entry)
|
||||
|
||||
if manager.is_on:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyatv", "srptools"],
|
||||
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
|
||||
"requirements": ["pyatv==0.17.0"],
|
||||
"zeroconf": [
|
||||
"_mediaremotetv._tcp.local.",
|
||||
"_companion-link._tcp.local.",
|
||||
|
||||
@@ -239,6 +239,15 @@ class AppleTvMediaPlayer(
|
||||
"""
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def volume_device_update(
|
||||
self, output_device: OutputDevice, old_level: float, new_level: float
|
||||
) -> None:
|
||||
"""Output device volume was updated.
|
||||
|
||||
This is a callback function from pyatv.interface.AudioListener.
|
||||
"""
|
||||
|
||||
@callback
|
||||
def outputdevices_update(
|
||||
self, old_devices: list[OutputDevice], new_devices: list[OutputDevice]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a satellite is idle"
|
||||
"name": "Satellite is idle"
|
||||
},
|
||||
"is_listening": {
|
||||
"description": "Tests if one or more Assist satellites are listening.",
|
||||
@@ -24,7 +24,7 @@
|
||||
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a satellite is listening"
|
||||
"name": "Satellite is listening"
|
||||
},
|
||||
"is_processing": {
|
||||
"description": "Tests if one or more Assist satellites are processing.",
|
||||
@@ -34,7 +34,7 @@
|
||||
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a satellite is processing"
|
||||
"name": "Satellite is processing"
|
||||
},
|
||||
"is_responding": {
|
||||
"description": "Tests if one or more Assist satellites are responding.",
|
||||
@@ -44,7 +44,7 @@
|
||||
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a satellite is responding"
|
||||
"name": "Satellite is responding"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
|
||||
@@ -56,7 +56,7 @@ from homeassistant.core import (
|
||||
valid_entity_id,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
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 (
|
||||
@@ -127,6 +127,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"assist_satellite",
|
||||
"fan",
|
||||
"light",
|
||||
"siren",
|
||||
}
|
||||
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
@@ -553,7 +554,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
automation_id: str | None,
|
||||
name: str,
|
||||
trigger_config: list[ConfigType],
|
||||
cond_func: IfAction | None,
|
||||
condition: IfAction | None,
|
||||
action_script: Script,
|
||||
initial_state: bool | None,
|
||||
variables: ScriptVariables | None,
|
||||
@@ -566,7 +567,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
self._attr_name = name
|
||||
self._trigger_config = trigger_config
|
||||
self._async_detach_triggers: CALLBACK_TYPE | None = None
|
||||
self._cond_func = cond_func
|
||||
self._condition = condition
|
||||
self.action_script = action_script
|
||||
self.action_script.change_listener = self.async_write_ha_state
|
||||
self._initial_state = initial_state
|
||||
@@ -601,6 +602,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Return a set of referenced labels."""
|
||||
referenced = self.action_script.referenced_labels
|
||||
|
||||
if self._condition is not None:
|
||||
for conf in self._condition.config:
|
||||
referenced |= condition_helper.async_extract_targets(
|
||||
conf, ATTR_LABEL_ID
|
||||
)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
|
||||
return referenced
|
||||
@@ -610,6 +617,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Return a set of referenced floors."""
|
||||
referenced = self.action_script.referenced_floors
|
||||
|
||||
if self._condition is not None:
|
||||
for conf in self._condition.config:
|
||||
referenced |= condition_helper.async_extract_targets(
|
||||
conf, ATTR_FLOOR_ID
|
||||
)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
|
||||
return referenced
|
||||
@@ -619,6 +632,10 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Return a set of referenced areas."""
|
||||
referenced = self.action_script.referenced_areas
|
||||
|
||||
if self._condition is not None:
|
||||
for conf in self._condition.config:
|
||||
referenced |= condition_helper.async_extract_targets(conf, ATTR_AREA_ID)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
|
||||
return referenced
|
||||
@@ -635,9 +652,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Return a set of referenced devices."""
|
||||
referenced = self.action_script.referenced_devices
|
||||
|
||||
if self._cond_func is not None:
|
||||
for conf in self._cond_func.config:
|
||||
referenced |= condition.async_extract_devices(conf)
|
||||
if self._condition is not None:
|
||||
for conf in self._condition.config:
|
||||
referenced |= condition_helper.async_extract_devices(conf)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
referenced |= set(_trigger_extract_devices(conf))
|
||||
@@ -649,9 +666,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Return a set of referenced entities."""
|
||||
referenced = self.action_script.referenced_entities
|
||||
|
||||
if self._cond_func is not None:
|
||||
for conf in self._cond_func.config:
|
||||
referenced |= condition.async_extract_entities(conf)
|
||||
if self._condition is not None:
|
||||
for conf in self._condition.config:
|
||||
referenced |= condition_helper.async_extract_entities(conf)
|
||||
|
||||
for conf in self._trigger_config:
|
||||
for entity_id in _trigger_extract_entities(conf):
|
||||
@@ -771,8 +788,8 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
|
||||
if (
|
||||
not skip_condition
|
||||
and self._cond_func is not None
|
||||
and not self._cond_func(variables)
|
||||
and self._condition is not None
|
||||
and not self._condition(variables)
|
||||
):
|
||||
self._logger.debug(
|
||||
"Conditions not met, aborting automation. Condition summary: %s",
|
||||
@@ -1034,12 +1051,12 @@ async def _create_automation_entities(
|
||||
)
|
||||
|
||||
if CONF_CONDITIONS in config_block:
|
||||
cond_func = await _async_process_if(hass, name, config_block)
|
||||
condition = await _async_process_if(hass, name, config_block)
|
||||
|
||||
if cond_func is None:
|
||||
if condition is None:
|
||||
continue
|
||||
else:
|
||||
cond_func = None
|
||||
condition = None
|
||||
|
||||
# Add trigger variables to variables
|
||||
variables = None
|
||||
@@ -1057,7 +1074,7 @@ async def _create_automation_entities(
|
||||
automation_id,
|
||||
name,
|
||||
config_block[CONF_TRIGGERS],
|
||||
cond_func,
|
||||
condition,
|
||||
action_script,
|
||||
initial_state,
|
||||
variables,
|
||||
@@ -1199,7 +1216,7 @@ async def _async_process_if(
|
||||
if_configs = config[CONF_CONDITIONS]
|
||||
|
||||
try:
|
||||
if_action = await condition.async_conditions_from_config(
|
||||
if_action = await condition_helper.async_conditions_from_config(
|
||||
hass, if_configs, LOGGER, name
|
||||
)
|
||||
except HomeAssistantError as ex:
|
||||
|
||||
@@ -85,9 +85,9 @@
|
||||
}
|
||||
},
|
||||
"moving": {
|
||||
"default": "mdi:arrow-right",
|
||||
"default": "mdi:octagon",
|
||||
"state": {
|
||||
"on": "mdi:octagon"
|
||||
"on": "mdi:arrow-right"
|
||||
}
|
||||
},
|
||||
"occupancy": {
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.9.0"],
|
||||
"requirements": ["hass-nabucasa==1.11.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.3.4"]
|
||||
"requirements": ["compit-inext-api==0.4.2"]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DOMAIN = "deconz"
|
||||
|
||||
HASSIO_CONFIGURATION_URL = "homeassistant://hassio/ingress/core_deconz"
|
||||
HASSIO_CONFIGURATION_URL = "homeassistant://app/core_deconz"
|
||||
|
||||
CONF_BRIDGE_ID = "bridgeid"
|
||||
CONF_GROUP_ID_BASE = "group_id_base"
|
||||
|
||||
@@ -1034,7 +1034,7 @@ def _async_setup_device_registry(
|
||||
and dashboard.data
|
||||
and dashboard.data.get(device_info.name)
|
||||
):
|
||||
configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}"
|
||||
configuration_url = f"homeassistant://app/{dashboard.addon_slug}"
|
||||
|
||||
manufacturer = "espressif"
|
||||
if device_info.manufacturer:
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"name": "[%key:component::fan::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a fan is off"
|
||||
"name": "Fan is off"
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Tests if one or more fans are on.",
|
||||
@@ -24,7 +24,7 @@
|
||||
"name": "[%key:component::fan::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a fan is on"
|
||||
"name": "Fan is on"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfirefly==0.1.11"]
|
||||
"requirements": ["pyfirefly==0.1.12"]
|
||||
}
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260107.1"]
|
||||
"requirements": ["home-assistant-frontend==20260107.2"]
|
||||
}
|
||||
|
||||
@@ -7,20 +7,11 @@
|
||||
"benzene": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"nitrogen_dioxide": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"nitrogen_monoxide": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"non_methane_hydrocarbons": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"ozone": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"sulphur_dioxide": {
|
||||
"default": "mdi:molecule"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_air_quality_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google_air_quality_api==2.1.2"]
|
||||
"requirements": ["google_air_quality_api==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -114,6 +118,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.co.concentration.units,
|
||||
exists_fn=lambda x: "co" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.co.concentration.value,
|
||||
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
key="nh3",
|
||||
@@ -141,16 +146,16 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
key="no2",
|
||||
translation_key="nitrogen_dioxide",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.NITROGEN_DIOXIDE,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.no2.concentration.units,
|
||||
exists_fn=lambda x: "no2" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.no2.concentration.value,
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
key="o3",
|
||||
translation_key="ozone",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.OZONE,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.o3.concentration.units,
|
||||
exists_fn=lambda x: "o3" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.o3.concentration.value,
|
||||
@@ -173,8 +178,8 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
|
||||
),
|
||||
AirQualitySensorEntityDescription(
|
||||
key="so2",
|
||||
translation_key="sulphur_dioxide",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.SULPHUR_DIOXIDE,
|
||||
native_unit_of_measurement_fn=lambda x: x.pollutants.so2.concentration.units,
|
||||
exists_fn=lambda x: "so2" in {p.code for p in x.pollutants},
|
||||
value_fn=lambda x: x.pollutants.so2.concentration.value,
|
||||
|
||||
@@ -205,21 +205,12 @@
|
||||
"so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
|
||||
}
|
||||
},
|
||||
"nitrogen_dioxide": {
|
||||
"name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
|
||||
},
|
||||
"nitrogen_monoxide": {
|
||||
"name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]"
|
||||
},
|
||||
"non_methane_hydrocarbons": {
|
||||
"name": "Non-methane hydrocarbons"
|
||||
},
|
||||
"ozone": {
|
||||
"name": "[%key:component::sensor::entity_component::ozone::name%]"
|
||||
},
|
||||
"sulphur_dioxide": {
|
||||
"name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
|
||||
},
|
||||
"uaqi": {
|
||||
"name": "Universal Air Quality Index"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["google-genai==1.56.0"]
|
||||
"requirements": ["google-genai==1.59.0"]
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@
|
||||
"invalid_credentials": "Input is incomplete. You must provide either your login details or an API token",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "[%key:common::config_flow::initiate_flow::account%]"
|
||||
},
|
||||
"step": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
|
||||
@@ -16,7 +16,7 @@ from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.components import panel_custom
|
||||
from homeassistant.components import frontend, panel_custom
|
||||
from homeassistant.components.homeassistant import async_set_stop_handler
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
@@ -292,6 +292,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
return False
|
||||
|
||||
async_load_websocket_api(hass)
|
||||
frontend.async_register_built_in_panel(hass, "app")
|
||||
|
||||
host = os.environ["SUPERVISOR"]
|
||||
websession = async_get_clientsession(hass)
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from homeassistant.components import frontend, panel_custom
|
||||
from homeassistant.components import frontend
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.const import ATTR_ICON
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -33,7 +33,7 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio: HassIO) -> None:
|
||||
# _register_panel never suspends and is only
|
||||
# a coroutine because it would be a breaking change
|
||||
# to make it a normal function
|
||||
await _register_panel(hass, addon, data)
|
||||
_register_panel(hass, addon, data)
|
||||
|
||||
|
||||
class HassIOAddonPanel(HomeAssistantView):
|
||||
@@ -58,7 +58,7 @@ class HassIOAddonPanel(HomeAssistantView):
|
||||
data = panels[addon]
|
||||
|
||||
# Register panel
|
||||
await _register_panel(self.hass, addon, data)
|
||||
_register_panel(self.hass, addon, data)
|
||||
return web.Response()
|
||||
|
||||
async def delete(self, request: web.Request, addon: str) -> web.Response:
|
||||
@@ -76,18 +76,14 @@ class HassIOAddonPanel(HomeAssistantView):
|
||||
return {}
|
||||
|
||||
|
||||
async def _register_panel(
|
||||
hass: HomeAssistant, addon: str, data: dict[str, Any]
|
||||
) -> None:
|
||||
def _register_panel(hass: HomeAssistant, addon: str, data: dict[str, Any]):
|
||||
"""Init coroutine to register the panel."""
|
||||
await panel_custom.async_register_panel(
|
||||
frontend.async_register_built_in_panel(
|
||||
hass,
|
||||
"app",
|
||||
frontend_url_path=addon,
|
||||
webcomponent_name="hassio-main",
|
||||
sidebar_title=data[ATTR_TITLE],
|
||||
sidebar_icon=data[ATTR_ICON],
|
||||
js_url="/api/hassio/app/entrypoint.js",
|
||||
embed_iframe=True,
|
||||
require_admin=data[ATTR_ADMIN],
|
||||
config={"ingress": addon},
|
||||
config={"addon": addon},
|
||||
)
|
||||
|
||||
@@ -19,6 +19,8 @@ from .const import DOMAIN
|
||||
from .coordinator import HDFuryConfigEntry
|
||||
from .entity import HDFuryEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HDFuryButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hdfury",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hdfury==1.3.1"]
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["hdfury==1.4.2"]
|
||||
}
|
||||
|
||||
@@ -35,11 +35,11 @@ rules:
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Integration has no authentication flow.
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -20,6 +20,8 @@ from .const import DOMAIN
|
||||
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
|
||||
from .entity import HDFuryEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HDFurySelectEntityDescription(SelectEntityDescription):
|
||||
@@ -77,13 +79,11 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities: list[HDFuryEntity] = []
|
||||
|
||||
for description in SELECT_PORTS:
|
||||
if description.key not in coordinator.data.info:
|
||||
continue
|
||||
|
||||
entities.append(HDFurySelect(coordinator, description))
|
||||
entities: list[HDFuryEntity] = [
|
||||
HDFurySelect(coordinator, description)
|
||||
for description in SELECT_PORTS
|
||||
if description.key in coordinator.data.info
|
||||
]
|
||||
|
||||
# Add OPMODE select if present
|
||||
if "opmode" in coordinator.data.info:
|
||||
|
||||
@@ -8,6 +8,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import HDFuryConfigEntry
|
||||
from .entity import HDFuryEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="RX0",
|
||||
|
||||
@@ -16,6 +16,8 @@ from .const import DOMAIN
|
||||
from .coordinator import HDFuryConfigEntry
|
||||
from .entity import HDFuryEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class HDFurySwitchEntityDescription(SwitchEntityDescription):
|
||||
|
||||
@@ -6,10 +6,7 @@ from dataclasses import dataclass
|
||||
from pyHomee.const import AttributeType, NodeState
|
||||
from pyHomee.model import HomeeAttribute, HomeeNode
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
@@ -17,17 +14,10 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
HOMEE_UNIT_TO_HA_UNIT,
|
||||
OPEN_CLOSE_MAP,
|
||||
OPEN_CLOSE_MAP_REVERSED,
|
||||
@@ -109,11 +99,6 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
AttributeType.CURRENT_VALVE_POSITION: HomeeSensorEntityDescription(
|
||||
key="valve_position",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
AttributeType.DAWN: HomeeSensorEntityDescription(
|
||||
key="dawn",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
@@ -294,57 +279,12 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
|
||||
"""Get list of related automations and scripts."""
|
||||
used_in = automations_with_entity(hass, entity_id)
|
||||
used_in += scripts_with_entity(hass, entity_id)
|
||||
return used_in
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the homee platform for the sensor components."""
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
def add_deprecated_entity(
|
||||
attribute: HomeeAttribute, description: HomeeSensorEntityDescription
|
||||
) -> list[HomeeSensor]:
|
||||
"""Add deprecated entities."""
|
||||
deprecated_entities: list[HomeeSensor] = []
|
||||
entity_uid = f"{config_entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}"
|
||||
if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, entity_uid):
|
||||
entity_entry = ent_reg.async_get(entity_id)
|
||||
if entity_entry and entity_entry.disabled:
|
||||
ent_reg.async_remove(entity_id)
|
||||
async_delete_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_entity_{entity_uid}",
|
||||
)
|
||||
elif entity_entry:
|
||||
deprecated_entities.append(
|
||||
HomeeSensor(attribute, config_entry, description)
|
||||
)
|
||||
if entity_used_in(hass, entity_id):
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_entity_{entity_uid}",
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_entity",
|
||||
translation_placeholders={
|
||||
"name": str(
|
||||
entity_entry.name or entity_entry.original_name
|
||||
),
|
||||
"entity": entity_id,
|
||||
},
|
||||
)
|
||||
return deprecated_entities
|
||||
|
||||
async def add_sensor_entities(
|
||||
config_entry: HomeeConfigEntry,
|
||||
@@ -362,19 +302,13 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
# Node attributes that are sensors.
|
||||
for attribute in node.attributes:
|
||||
if attribute.type == AttributeType.CURRENT_VALVE_POSITION:
|
||||
entities.extend(
|
||||
add_deprecated_entity(
|
||||
attribute, SENSOR_DESCRIPTIONS[attribute.type]
|
||||
)
|
||||
)
|
||||
elif attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable:
|
||||
entities.append(
|
||||
HomeeSensor(
|
||||
attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
|
||||
)
|
||||
)
|
||||
entities.extend(
|
||||
HomeeSensor(
|
||||
attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
|
||||
)
|
||||
for attribute in node.attributes
|
||||
if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable
|
||||
)
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -495,11 +495,5 @@
|
||||
"invalid_preset_mode": {
|
||||
"message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_entity": {
|
||||
"description": "The Homee entity `{entity}` is deprecated and will be removed in release 2025.12.\nThe valve is available directly in the respective climate entity.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.",
|
||||
"title": "The Homee {name} entity is deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
|
||||
|
||||
from .const import DOMAIN, UPDATE_INTERVAL
|
||||
from .entity import AqualinkEntity
|
||||
@@ -66,7 +67,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
|
||||
username = entry.data[CONF_USERNAME]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
|
||||
aqualink = AqualinkClient(username, password, httpx_client=get_async_client(hass))
|
||||
aqualink = AqualinkClient(
|
||||
username,
|
||||
password,
|
||||
httpx_client=get_async_client(hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2),
|
||||
)
|
||||
try:
|
||||
await aqualink.login()
|
||||
except AqualinkServiceException as login_exception:
|
||||
|
||||
@@ -15,6 +15,7 @@ import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -36,7 +37,11 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
async with AqualinkClient(
|
||||
username, password, httpx_client=get_async_client(self.hass)
|
||||
username,
|
||||
password,
|
||||
httpx_client=get_async_client(
|
||||
self.hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2
|
||||
),
|
||||
):
|
||||
pass
|
||||
except AqualinkServiceUnauthorizedException:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["keyrings.alt", "pyicloud"],
|
||||
"requirements": ["pyicloud==2.2.0"]
|
||||
"requirements": ["pyicloud==2.3.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["imgw_pib==1.6.0"]
|
||||
"requirements": ["imgw_pib==2.0.1"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError
|
||||
from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorTimeoutError
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -11,8 +11,9 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
|
||||
|
||||
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
|
||||
|
||||
@@ -28,8 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
try:
|
||||
await device.connect(True)
|
||||
except JvcProjectorConnectError as err:
|
||||
await device.connect()
|
||||
except JvcProjectorTimeoutError as err:
|
||||
await device.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to {entry.data[CONF_HOST]}"
|
||||
@@ -50,6 +51,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool:
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect)
|
||||
)
|
||||
|
||||
await async_migrate_entities(hass, entry, coordinator)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
@@ -60,3 +63,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await entry.runtime_data.device.disconnect()
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entities(
|
||||
hass: HomeAssistant,
|
||||
config_entry: JVCConfigEntry,
|
||||
coordinator: JvcProjectorDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Migrate old entities as needed."""
|
||||
|
||||
@callback
|
||||
def _update_entry(entry: RegistryEntry) -> dict[str, str] | None:
|
||||
"""Fix unique_id of power binary_sensor entry."""
|
||||
if entry.domain == Platform.BINARY_SENSOR and ":" not in entry.unique_id:
|
||||
if "_power" in entry.unique_id:
|
||||
return {"new_unique_id": f"{coordinator.unique_id}_power"}
|
||||
return None
|
||||
|
||||
await async_migrate_entries(hass, config_entry.entry_id, _update_entry)
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from jvcprojector import const
|
||||
from jvcprojector import command as cmd
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import POWER
|
||||
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
|
||||
from .entity import JvcProjectorEntity
|
||||
|
||||
ON_STATUS = (const.ON, const.WARMING)
|
||||
ON_STATUS = (cmd.Power.ON, cmd.Power.WARMING)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -21,14 +22,13 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the JVC Projector platform from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([JvcBinarySensor(coordinator)])
|
||||
|
||||
|
||||
class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity):
|
||||
"""The entity class for JVC Projector Binary Sensor."""
|
||||
|
||||
_attr_translation_key = "jvc_power"
|
||||
_attr_translation_key = "power"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -36,9 +36,9 @@ class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity):
|
||||
) -> None:
|
||||
"""Initialize the JVC Projector sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.device.mac}_power"
|
||||
self._attr_unique_id = f"{coordinator.unique_id}_power"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the JVC is on."""
|
||||
return self.coordinator.data["power"] in ON_STATUS
|
||||
"""Return true if the JVC Projector is on."""
|
||||
return self.coordinator.data[POWER] in ON_STATUS
|
||||
|
||||
@@ -5,7 +5,12 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError
|
||||
from jvcprojector import (
|
||||
JvcProjector,
|
||||
JvcProjectorAuthError,
|
||||
JvcProjectorTimeoutError,
|
||||
command as cmd,
|
||||
)
|
||||
from jvcprojector.projector import DEFAULT_PORT
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -40,7 +45,7 @@ class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
mac = await get_mac_address(host, port, password)
|
||||
except InvalidHost:
|
||||
errors["base"] = "invalid_host"
|
||||
except JvcProjectorConnectError:
|
||||
except JvcProjectorTimeoutError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except JvcProjectorAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
@@ -91,7 +96,7 @@ class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
await get_mac_address(host, port, password)
|
||||
except JvcProjectorConnectError:
|
||||
except JvcProjectorTimeoutError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except JvcProjectorAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
@@ -115,7 +120,7 @@ async def get_mac_address(host: str, port: int, password: str | None) -> str:
|
||||
"""Get device mac address for config flow."""
|
||||
device = JvcProjector(host, port=port, password=password)
|
||||
try:
|
||||
await device.connect(True)
|
||||
await device.connect()
|
||||
return await device.get(cmd.MacAddress)
|
||||
finally:
|
||||
await device.disconnect()
|
||||
return device.mac
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
NAME = "JVC Projector"
|
||||
DOMAIN = "jvc_projector"
|
||||
MANUFACTURER = "JVC"
|
||||
|
||||
POWER = "power"
|
||||
INPUT = "input"
|
||||
SOURCE = "source"
|
||||
|
||||
@@ -4,22 +4,21 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from jvcprojector import (
|
||||
JvcProjector,
|
||||
JvcProjectorAuthError,
|
||||
JvcProjectorConnectError,
|
||||
const,
|
||||
JvcProjectorTimeoutError,
|
||||
command as cmd,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import NAME
|
||||
from .const import INPUT, NAME, POWER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,26 +45,33 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
|
||||
update_interval=INTERVAL_SLOW,
|
||||
)
|
||||
|
||||
self.device = device
|
||||
self.unique_id = format_mac(device.mac)
|
||||
self.device: JvcProjector = device
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert config_entry.unique_id is not None
|
||||
self.unique_id = config_entry.unique_id
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Get the latest state data."""
|
||||
state: dict[str, str | None] = {
|
||||
POWER: None,
|
||||
INPUT: None,
|
||||
}
|
||||
|
||||
try:
|
||||
state = await self.device.get_state()
|
||||
except JvcProjectorConnectError as err:
|
||||
state[POWER] = await self.device.get(cmd.Power)
|
||||
|
||||
if state[POWER] == cmd.Power.ON:
|
||||
state[INPUT] = await self.device.get(cmd.Input)
|
||||
|
||||
except JvcProjectorTimeoutError as err:
|
||||
raise UpdateFailed(f"Unable to connect to {self.device.host}") from err
|
||||
except JvcProjectorAuthError as err:
|
||||
raise ConfigEntryAuthFailed("Password authentication failed") from err
|
||||
|
||||
old_interval = self.update_interval
|
||||
|
||||
if state[const.POWER] != const.STANDBY:
|
||||
if state[POWER] != cmd.Power.STANDBY:
|
||||
self.update_interval = INTERVAL_FAST
|
||||
else:
|
||||
self.update_interval = INTERVAL_SLOW
|
||||
|
||||
if self.update_interval != old_interval:
|
||||
_LOGGER.debug("Changed update interval to %s", self.update_interval)
|
||||
|
||||
return state
|
||||
|
||||
@@ -26,7 +26,7 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
|
||||
|
||||
self._attr_unique_id = coordinator.unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.unique_id)},
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
name=NAME,
|
||||
model=self.device.model,
|
||||
manufacturer=MANUFACTURER,
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==1.1.3"]
|
||||
"requirements": ["pyjvcprojector==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -7,54 +7,62 @@ from collections.abc import Iterable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from jvcprojector import const
|
||||
from jvcprojector import command as cmd
|
||||
|
||||
from homeassistant.components.remote import RemoteEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import POWER
|
||||
from .coordinator import JVCConfigEntry
|
||||
from .entity import JvcProjectorEntity
|
||||
|
||||
COMMANDS = {
|
||||
"menu": const.REMOTE_MENU,
|
||||
"up": const.REMOTE_UP,
|
||||
"down": const.REMOTE_DOWN,
|
||||
"left": const.REMOTE_LEFT,
|
||||
"right": const.REMOTE_RIGHT,
|
||||
"ok": const.REMOTE_OK,
|
||||
"back": const.REMOTE_BACK,
|
||||
"mpc": const.REMOTE_MPC,
|
||||
"hide": const.REMOTE_HIDE,
|
||||
"info": const.REMOTE_INFO,
|
||||
"input": const.REMOTE_INPUT,
|
||||
"cmd": const.REMOTE_CMD,
|
||||
"advanced_menu": const.REMOTE_ADVANCED_MENU,
|
||||
"picture_mode": const.REMOTE_PICTURE_MODE,
|
||||
"color_profile": const.REMOTE_COLOR_PROFILE,
|
||||
"lens_control": const.REMOTE_LENS_CONTROL,
|
||||
"setting_memory": const.REMOTE_SETTING_MEMORY,
|
||||
"gamma_settings": const.REMOTE_GAMMA_SETTINGS,
|
||||
"hdmi_1": const.REMOTE_HDMI_1,
|
||||
"hdmi_2": const.REMOTE_HDMI_2,
|
||||
"mode_1": const.REMOTE_MODE_1,
|
||||
"mode_2": const.REMOTE_MODE_2,
|
||||
"mode_3": const.REMOTE_MODE_3,
|
||||
"mode_4": const.REMOTE_MODE_4,
|
||||
"mode_5": const.REMOTE_MODE_5,
|
||||
"mode_6": const.REMOTE_MODE_6,
|
||||
"mode_7": const.REMOTE_MODE_7,
|
||||
"mode_8": const.REMOTE_MODE_8,
|
||||
"mode_9": const.REMOTE_MODE_9,
|
||||
"mode_10": const.REMOTE_MODE_10,
|
||||
"lens_ap": const.REMOTE_LENS_AP,
|
||||
"gamma": const.REMOTE_GAMMA,
|
||||
"color_temp": const.REMOTE_COLOR_TEMP,
|
||||
"natural": const.REMOTE_NATURAL,
|
||||
"cinema": const.REMOTE_CINEMA,
|
||||
"anamo": const.REMOTE_ANAMO,
|
||||
"3d_format": const.REMOTE_3D_FORMAT,
|
||||
COMMANDS: list[str] = [
|
||||
cmd.Remote.MENU,
|
||||
cmd.Remote.UP,
|
||||
cmd.Remote.DOWN,
|
||||
cmd.Remote.LEFT,
|
||||
cmd.Remote.RIGHT,
|
||||
cmd.Remote.OK,
|
||||
cmd.Remote.BACK,
|
||||
cmd.Remote.MPC,
|
||||
cmd.Remote.HIDE,
|
||||
cmd.Remote.INFO,
|
||||
cmd.Remote.INPUT,
|
||||
cmd.Remote.CMD,
|
||||
cmd.Remote.ADVANCED_MENU,
|
||||
cmd.Remote.PICTURE_MODE,
|
||||
cmd.Remote.COLOR_PROFILE,
|
||||
cmd.Remote.LENS_CONTROL,
|
||||
cmd.Remote.SETTING_MEMORY,
|
||||
cmd.Remote.GAMMA_SETTINGS,
|
||||
cmd.Remote.HDMI1,
|
||||
cmd.Remote.HDMI2,
|
||||
cmd.Remote.MODE_1,
|
||||
cmd.Remote.MODE_2,
|
||||
cmd.Remote.MODE_3,
|
||||
cmd.Remote.MODE_4,
|
||||
cmd.Remote.MODE_5,
|
||||
cmd.Remote.MODE_6,
|
||||
cmd.Remote.MODE_7,
|
||||
cmd.Remote.MODE_8,
|
||||
cmd.Remote.MODE_9,
|
||||
cmd.Remote.MODE_10,
|
||||
cmd.Remote.GAMMA,
|
||||
cmd.Remote.NATURAL,
|
||||
cmd.Remote.CINEMA,
|
||||
cmd.Remote.COLOR_TEMP,
|
||||
cmd.Remote.ANAMORPHIC,
|
||||
cmd.Remote.LENS_APERTURE,
|
||||
cmd.Remote.V3D_FORMAT,
|
||||
]
|
||||
|
||||
RENAMED_COMMANDS: dict[str, str] = {
|
||||
"anamo": cmd.Remote.ANAMORPHIC,
|
||||
"lens_ap": cmd.Remote.LENS_APERTURE,
|
||||
"hdmi1": cmd.Remote.HDMI1,
|
||||
"hdmi2": cmd.Remote.HDMI2,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -77,25 +85,34 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity):
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if entity is on."""
|
||||
return self.coordinator.data["power"] in [const.ON, const.WARMING]
|
||||
"""Return True if the entity is on."""
|
||||
return self.coordinator.data[POWER] in (cmd.Power.ON, cmd.Power.WARMING)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
await self.device.power_on()
|
||||
await self.device.set(cmd.Power, cmd.Power.ON)
|
||||
await asyncio.sleep(1)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
await self.device.power_off()
|
||||
await self.device.set(cmd.Power, cmd.Power.OFF)
|
||||
await asyncio.sleep(1)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Send a remote command to the device."""
|
||||
for cmd in command:
|
||||
if cmd not in COMMANDS:
|
||||
raise HomeAssistantError(f"{cmd} is not a known command")
|
||||
_LOGGER.debug("Sending command '%s'", cmd)
|
||||
await self.device.remote(COMMANDS[cmd])
|
||||
for send_command in command:
|
||||
# Legacy name replace
|
||||
if send_command in RENAMED_COMMANDS:
|
||||
send_command = RENAMED_COMMANDS[send_command]
|
||||
|
||||
# Legacy name fixup
|
||||
if "_" in send_command:
|
||||
send_command = send_command.replace("_", "-")
|
||||
|
||||
if send_command not in COMMANDS:
|
||||
raise HomeAssistantError(f"{send_command} is not a known command")
|
||||
|
||||
_LOGGER.debug("Sending command '%s'", send_command)
|
||||
await self.device.remote(send_command)
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from jvcprojector import JvcProjector, const
|
||||
from jvcprojector import JvcProjector, command as cmd
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -23,16 +23,12 @@ class JvcProjectorSelectDescription(SelectEntityDescription):
|
||||
command: Callable[[JvcProjector, str], Awaitable[None]]
|
||||
|
||||
|
||||
OPTIONS: Final[dict[str, dict[str, str]]] = {
|
||||
"input": {const.HDMI1: const.REMOTE_HDMI_1, const.HDMI2: const.REMOTE_HDMI_2}
|
||||
}
|
||||
|
||||
SELECTS: Final[list[JvcProjectorSelectDescription]] = [
|
||||
JvcProjectorSelectDescription(
|
||||
key="input",
|
||||
translation_key="input",
|
||||
options=list(OPTIONS["input"]),
|
||||
command=lambda device, option: device.remote(OPTIONS["input"][option]),
|
||||
options=[cmd.Input.HDMI1, cmd.Input.HDMI2],
|
||||
command=lambda device, option: device.set(cmd.Input, option),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from jvcprojector import const
|
||||
from jvcprojector import command as cmd
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -23,11 +23,11 @@ JVC_SENSORS = (
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
options=[
|
||||
const.STANDBY,
|
||||
const.ON,
|
||||
const.WARMING,
|
||||
const.COOLING,
|
||||
const.ERROR,
|
||||
cmd.Power.STANDBY,
|
||||
cmd.Power.ON,
|
||||
cmd.Power.WARMING,
|
||||
cmd.Power.COOLING,
|
||||
cmd.Power.ERROR,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"jvc_power": {
|
||||
"power": {
|
||||
"name": "[%key:component::binary_sensor::entity_component::power::name%]"
|
||||
}
|
||||
},
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
"sensor": {
|
||||
"jvc_power_status": {
|
||||
"name": "Power status",
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"cooling": "Cooling",
|
||||
"error": "[%key:common::state::error%]",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["kostal"],
|
||||
"requirements": ["pykoplenti==1.3.0"]
|
||||
"requirements": ["pykoplenti==1.5.0"]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_custom_components
|
||||
|
||||
from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
|
||||
from .helpers import async_is_preview_feature_enabled, async_listen
|
||||
from .helpers import (
|
||||
async_is_preview_feature_enabled,
|
||||
async_listen,
|
||||
async_update_preview_feature,
|
||||
)
|
||||
from .models import (
|
||||
EventLabsUpdatedData,
|
||||
LabPreviewFeature,
|
||||
@@ -37,6 +41,7 @@ __all__ = [
|
||||
"EventLabsUpdatedData",
|
||||
"async_is_preview_feature_enabled",
|
||||
"async_listen",
|
||||
"async_update_preview_feature",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -61,3 +61,32 @@ def async_listen(
|
||||
listener()
|
||||
|
||||
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)
|
||||
|
||||
|
||||
async def async_update_preview_feature(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
preview_feature: str,
|
||||
enabled: bool,
|
||||
) -> None:
|
||||
"""Update a lab preview feature state."""
|
||||
labs_data = hass.data[LABS_DATA]
|
||||
|
||||
preview_feature_id = f"{domain}.{preview_feature}"
|
||||
|
||||
if preview_feature_id not in labs_data.preview_features:
|
||||
raise ValueError(f"Preview feature {preview_feature_id} not found")
|
||||
|
||||
if enabled:
|
||||
labs_data.data.preview_feature_status.add((domain, preview_feature))
|
||||
else:
|
||||
labs_data.data.preview_feature_status.discard((domain, preview_feature))
|
||||
|
||||
await labs_data.store.async_save(labs_data.data.to_store_format())
|
||||
|
||||
event_data: EventLabsUpdatedData = {
|
||||
"domain": domain,
|
||||
"preview_feature": preview_feature,
|
||||
"enabled": enabled,
|
||||
}
|
||||
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
|
||||
|
||||
@@ -8,12 +8,14 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.backup import async_get_manager
|
||||
from homeassistant.const import EVENT_LABS_UPDATED
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import LABS_DATA
|
||||
from .helpers import async_is_preview_feature_enabled, async_listen
|
||||
from .models import EventLabsUpdatedData
|
||||
from .helpers import (
|
||||
async_is_preview_feature_enabled,
|
||||
async_listen,
|
||||
async_update_preview_feature,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -95,19 +97,7 @@ async def websocket_update_preview_feature(
|
||||
)
|
||||
return
|
||||
|
||||
if enabled:
|
||||
labs_data.data.preview_feature_status.add((domain, preview_feature))
|
||||
else:
|
||||
labs_data.data.preview_feature_status.discard((domain, preview_feature))
|
||||
|
||||
await labs_data.store.async_save(labs_data.data.to_store_format())
|
||||
|
||||
event_data: EventLabsUpdatedData = {
|
||||
"domain": domain,
|
||||
"preview_feature": preview_feature,
|
||||
"enabled": enabled,
|
||||
}
|
||||
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
|
||||
await async_update_preview_feature(hass, domain, preview_feature, enabled)
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"title": "Lawn mower",
|
||||
"triggers": {
|
||||
"docked": {
|
||||
"description": "Triggers after one or more lawn mowers return to dock.",
|
||||
"description": "Triggers after one or more lawn mowers have returned to dock.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"name": "[%key:component::light::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a light is off"
|
||||
"name": "Light is off"
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Tests if one or more lights are on.",
|
||||
@@ -59,7 +59,7 @@
|
||||
"name": "[%key:component::light::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "If a light is on"
|
||||
"name": "Light is on"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
"""Provides triggers for lights."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateAttributeChangedTriggerBase,
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
|
||||
Trigger,
|
||||
make_entity_numerical_state_attribute_changed_trigger,
|
||||
make_entity_numerical_state_attribute_crossed_threshold_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
from . import ATTR_BRIGHTNESS
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
def _convert_uint8_to_percentage(value: Any) -> float:
|
||||
"""Convert a uint8 value (0-255) to a percentage (0-100)."""
|
||||
return (float(value) / 255.0) * 100.0
|
||||
|
||||
|
||||
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
|
||||
"""Trigger for brightness changed."""
|
||||
|
||||
_domain = DOMAIN
|
||||
_attribute = ATTR_BRIGHTNESS
|
||||
|
||||
_converter = staticmethod(_convert_uint8_to_percentage)
|
||||
|
||||
|
||||
class BrightnessCrossedThresholdTrigger(
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase
|
||||
):
|
||||
"""Trigger for brightness crossed threshold."""
|
||||
|
||||
_domain = DOMAIN
|
||||
_attribute = ATTR_BRIGHTNESS
|
||||
_converter = staticmethod(_convert_uint8_to_percentage)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"brightness_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_BRIGHTNESS
|
||||
),
|
||||
"brightness_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_BRIGHTNESS
|
||||
),
|
||||
"brightness_changed": BrightnessChangedTrigger,
|
||||
"brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
max: 100
|
||||
min: 0
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
|
||||
@@ -27,6 +27,7 @@ SCAN_INTERVAL = timedelta(minutes=30)
|
||||
|
||||
AUTHORITIES = [
|
||||
"Barking and Dagenham",
|
||||
"Barnet",
|
||||
"Bexley",
|
||||
"Brent",
|
||||
"Bromley",
|
||||
@@ -49,11 +50,13 @@ AUTHORITIES = [
|
||||
"Lambeth",
|
||||
"Lewisham",
|
||||
"Merton",
|
||||
"Newham",
|
||||
"Redbridge",
|
||||
"Richmond",
|
||||
"Southwark",
|
||||
"Sutton",
|
||||
"Tower Hamlets",
|
||||
"Waltham Forest",
|
||||
"Wandsworth",
|
||||
"Westminster",
|
||||
]
|
||||
|
||||
@@ -28,7 +28,7 @@ from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData
|
||||
from .services import async_setup_services
|
||||
from .utils import construct_mastodon_username, create_mastodon_client
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
128
homeassistant/components/mastodon/binary_sensor.py
Normal file
128
homeassistant/components/mastodon/binary_sensor.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Binary sensor platform for the Mastodon integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from mastodon.Mastodon import Account
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import MastodonConfigEntry
|
||||
from .entity import MastodonEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class MastodonBinarySensor(StrEnum):
|
||||
"""Mastodon binary sensors."""
|
||||
|
||||
BOT = "bot"
|
||||
SUSPENDED = "suspended"
|
||||
DISCOVERABLE = "discoverable"
|
||||
LOCKED = "locked"
|
||||
INDEXABLE = "indexable"
|
||||
LIMITED = "limited"
|
||||
MEMORIAL = "memorial"
|
||||
MOVED = "moved"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MastodonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Mastodon binary sensor description."""
|
||||
|
||||
is_on_fn: Callable[[Account], bool | None]
|
||||
|
||||
|
||||
ENTITY_DESCRIPTIONS: tuple[MastodonBinarySensorEntityDescription, ...] = (
|
||||
MastodonBinarySensorEntityDescription(
|
||||
key=MastodonBinarySensor.BOT,
|
||||
translation_key=MastodonBinarySensor.BOT,
|
||||
is_on_fn=lambda account: account.bot,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
MastodonBinarySensorEntityDescription(
|
||||
key=MastodonBinarySensor.DISCOVERABLE,
|
||||
translation_key=MastodonBinarySensor.DISCOVERABLE,
|
||||
is_on_fn=lambda account: account.discoverable,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
MastodonBinarySensorEntityDescription(
|
||||
key=MastodonBinarySensor.LOCKED,
|
||||
translation_key=MastodonBinarySensor.LOCKED,
|
||||
is_on_fn=lambda account: account.locked,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
MastodonBinarySensorEntityDescription(
|
||||
key=MastodonBinarySensor.MOVED,
|
||||
translation_key=MastodonBinarySensor.MOVED,
|
||||
is_on_fn=lambda account: account.moved is not None,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
MastodonBinarySensorEntityDescription(
|
||||
key=MastodonBinarySensor.INDEXABLE,
|
||||
translation_key=MastodonBinarySensor.INDEXABLE,
|
||||
is_on_fn=lambda account: account.indexable,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
MastodonBinarySensorEntityDescription(
|
||||
key=MastodonBinarySensor.LIMITED,
|
||||
translation_key=MastodonBinarySensor.LIMITED,
|
||||
is_on_fn=lambda account: account.limited is True,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
MastodonBinarySensorEntityDescription(
|
||||
key=MastodonBinarySensor.MEMORIAL,
|
||||
translation_key=MastodonBinarySensor.MEMORIAL,
|
||||
is_on_fn=lambda account: account.memorial is True,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
MastodonBinarySensorEntityDescription(
|
||||
key=MastodonBinarySensor.SUSPENDED,
|
||||
translation_key=MastodonBinarySensor.SUSPENDED,
|
||||
is_on_fn=lambda account: account.suspended is True,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MastodonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the binary sensor platform."""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
async_add_entities(
|
||||
MastodonBinarySensorEntity(
|
||||
coordinator=coordinator,
|
||||
entity_description=entity_description,
|
||||
data=entry,
|
||||
)
|
||||
for entity_description in ENTITY_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class MastodonBinarySensorEntity(MastodonEntity, BinarySensorEntity):
|
||||
"""Mastodon binary sensor entity."""
|
||||
|
||||
entity_description: MastodonBinarySensorEntityDescription
|
||||
|
||||
@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)
|
||||
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bot": { "default": "mdi:robot" },
|
||||
"discoverable": { "default": "mdi:magnify-scan" },
|
||||
"indexable": { "default": "mdi:search-web" },
|
||||
"limited": { "default": "mdi:account-cancel" },
|
||||
"locked": {
|
||||
"default": "mdi:account-lock",
|
||||
"state": { "off": "mdi:account-lock-open" }
|
||||
},
|
||||
"memorial": { "default": "mdi:candle" },
|
||||
"moved": { "default": "mdi:truck-delivery" },
|
||||
"suspended": { "default": "mdi:account-off" }
|
||||
},
|
||||
"sensor": {
|
||||
"followers": {
|
||||
"default": "mdi:account-multiple"
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bot": { "name": "Bot" },
|
||||
"discoverable": { "name": "Discoverable" },
|
||||
"indexable": { "name": "Indexable" },
|
||||
"limited": { "name": "Limited" },
|
||||
"locked": { "name": "Locked" },
|
||||
"memorial": { "name": "Memorial" },
|
||||
"moved": { "name": "Moved" },
|
||||
"suspended": { "name": "Suspended" }
|
||||
},
|
||||
"sensor": {
|
||||
"followers": {
|
||||
"name": "Followers",
|
||||
|
||||
@@ -489,6 +489,7 @@ DISCOVERY_SCHEMAS = [
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
key="WindowCoveringConfigStatusOperational",
|
||||
translation_key="config_status_operational",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
# unset Operational bit from ConfigStatus bitmap means problem
|
||||
|
||||
@@ -442,6 +442,9 @@ DISCOVERY_SCHEMAS = [
|
||||
key="PowerSourceBatVoltage",
|
||||
translation_key="battery_voltage",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
# Battery voltages are low-voltage diagnostics; use 2 decimals in volts
|
||||
# to provide finer granularity than mains-level voltage sensors.
|
||||
suggested_display_precision=2,
|
||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
"boost_state": {
|
||||
"name": "Boost state"
|
||||
},
|
||||
"config_status_operational": {
|
||||
"name": "Configuration status"
|
||||
},
|
||||
"dishwasher_alarm_inflow": {
|
||||
"name": "Inflow alarm"
|
||||
},
|
||||
|
||||
@@ -192,7 +192,7 @@ class MaxCubeClimate(ClimateEntity):
|
||||
self._set_target(None, temp)
|
||||
|
||||
@property
|
||||
def preset_mode(self):
|
||||
def preset_mode(self) -> str:
|
||||
"""Return the current preset mode."""
|
||||
if self._device.mode == MAX_DEVICE_MODE_MANUAL:
|
||||
if self._device.target_temperature == self._device.comfort_temperature:
|
||||
|
||||
@@ -50,7 +50,7 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Check connection to the Mealie API."""
|
||||
assert self.host is not None
|
||||
|
||||
if "/hassio/ingress/" in self.host:
|
||||
if "/app/" in self.host:
|
||||
return {"base": "ingress_url"}, None
|
||||
|
||||
client = MealieClient(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiomealie==1.1.1"]
|
||||
"requirements": ["aiomealie==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ from enum import StrEnum
|
||||
import logging
|
||||
|
||||
from dns.resolver import LifetimeTimeout
|
||||
from mcstatus import BedrockServer, JavaServer
|
||||
from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse
|
||||
from mcstatus import BedrockServer, JavaServer, LegacyServer
|
||||
from mcstatus.responses import (
|
||||
BedrockStatusResponse,
|
||||
JavaStatusResponse,
|
||||
LegacyStatusResponse,
|
||||
)
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -43,6 +47,7 @@ class MinecraftServerType(StrEnum):
|
||||
|
||||
BEDROCK_EDITION = "Bedrock Edition"
|
||||
JAVA_EDITION = "Java Edition"
|
||||
LEGACY_JAVA_EDITION = "Legacy Java Edition"
|
||||
|
||||
|
||||
class MinecraftServerAddressError(Exception):
|
||||
@@ -60,7 +65,7 @@ class MinecraftServerNotInitializedError(Exception):
|
||||
class MinecraftServer:
|
||||
"""Minecraft Server wrapper class for 3rd party library mcstatus."""
|
||||
|
||||
_server: BedrockServer | JavaServer | None
|
||||
_server: BedrockServer | JavaServer | LegacyServer | None
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, server_type: MinecraftServerType, address: str
|
||||
@@ -76,10 +81,12 @@ class MinecraftServer:
|
||||
try:
|
||||
if self._server_type == MinecraftServerType.JAVA_EDITION:
|
||||
self._server = await JavaServer.async_lookup(self._address)
|
||||
else:
|
||||
elif self._server_type == MinecraftServerType.BEDROCK_EDITION:
|
||||
self._server = await self._hass.async_add_executor_job(
|
||||
BedrockServer.lookup, self._address
|
||||
)
|
||||
else:
|
||||
self._server = await LegacyServer.async_lookup(self._address)
|
||||
except (ValueError, LifetimeTimeout) as error:
|
||||
raise MinecraftServerAddressError(
|
||||
f"Lookup of '{self._address}' failed: {self._get_error_message(error)}"
|
||||
@@ -112,7 +119,9 @@ class MinecraftServer:
|
||||
|
||||
async def async_get_data(self) -> MinecraftServerData:
|
||||
"""Get updated data from the server, supporting both Java and Bedrock Edition servers."""
|
||||
status_response: BedrockStatusResponse | JavaStatusResponse
|
||||
status_response: (
|
||||
BedrockStatusResponse | JavaStatusResponse | LegacyStatusResponse
|
||||
)
|
||||
|
||||
if self._server is None:
|
||||
raise MinecraftServerNotInitializedError(
|
||||
@@ -128,8 +137,10 @@ class MinecraftServer:
|
||||
|
||||
if isinstance(status_response, JavaStatusResponse):
|
||||
data = self._extract_java_data(status_response)
|
||||
else:
|
||||
elif isinstance(status_response, BedrockStatusResponse):
|
||||
data = self._extract_bedrock_data(status_response)
|
||||
else:
|
||||
data = self._extract_legacy_data(status_response)
|
||||
|
||||
return data
|
||||
|
||||
@@ -169,6 +180,19 @@ class MinecraftServer:
|
||||
map_name=status_response.map_name,
|
||||
)
|
||||
|
||||
def _extract_legacy_data(
|
||||
self, status_response: LegacyStatusResponse
|
||||
) -> MinecraftServerData:
|
||||
"""Extract legacy Java Edition server data out of status response."""
|
||||
return MinecraftServerData(
|
||||
latency=status_response.latency,
|
||||
motd=status_response.motd.to_plain(),
|
||||
players_max=status_response.players.max,
|
||||
players_online=status_response.players.online,
|
||||
protocol_version=status_response.version.protocol,
|
||||
version=status_response.version.name,
|
||||
)
|
||||
|
||||
def _get_error_message(self, error: BaseException) -> str:
|
||||
"""Get error message of an exception."""
|
||||
if not str(error):
|
||||
|
||||
@@ -84,4 +84,5 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"minimum_minecraft_version": "1.4"},
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"domain": "minecraft_server",
|
||||
"name": "Minecraft Server",
|
||||
"codeowners": ["@elmurato"],
|
||||
"codeowners": ["@elmurato", "@zachdeibert"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/minecraft_server",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["dnspython", "mcstatus"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["mcstatus==12.0.6"]
|
||||
"requirements": ["mcstatus==12.1.0"]
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ SENSOR_DESCRIPTIONS = [
|
||||
supported_server_types={
|
||||
MinecraftServerType.JAVA_EDITION,
|
||||
MinecraftServerType.BEDROCK_EDITION,
|
||||
MinecraftServerType.LEGACY_JAVA_EDITION,
|
||||
},
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
@@ -76,6 +77,7 @@ SENSOR_DESCRIPTIONS = [
|
||||
supported_server_types={
|
||||
MinecraftServerType.JAVA_EDITION,
|
||||
MinecraftServerType.BEDROCK_EDITION,
|
||||
MinecraftServerType.LEGACY_JAVA_EDITION,
|
||||
},
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -89,6 +91,7 @@ SENSOR_DESCRIPTIONS = [
|
||||
supported_server_types={
|
||||
MinecraftServerType.JAVA_EDITION,
|
||||
MinecraftServerType.BEDROCK_EDITION,
|
||||
MinecraftServerType.LEGACY_JAVA_EDITION,
|
||||
},
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
@@ -102,6 +105,7 @@ SENSOR_DESCRIPTIONS = [
|
||||
supported_server_types={
|
||||
MinecraftServerType.JAVA_EDITION,
|
||||
MinecraftServerType.BEDROCK_EDITION,
|
||||
MinecraftServerType.LEGACY_JAVA_EDITION,
|
||||
},
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
@@ -113,6 +117,7 @@ SENSOR_DESCRIPTIONS = [
|
||||
supported_server_types={
|
||||
MinecraftServerType.JAVA_EDITION,
|
||||
MinecraftServerType.BEDROCK_EDITION,
|
||||
MinecraftServerType.LEGACY_JAVA_EDITION,
|
||||
},
|
||||
),
|
||||
MinecraftServerSensorEntityDescription(
|
||||
@@ -124,6 +129,7 @@ SENSOR_DESCRIPTIONS = [
|
||||
supported_server_types={
|
||||
MinecraftServerType.JAVA_EDITION,
|
||||
MinecraftServerType.BEDROCK_EDITION,
|
||||
MinecraftServerType.LEGACY_JAVA_EDITION,
|
||||
},
|
||||
),
|
||||
MinecraftServerSensorEntityDescription(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version 1.7."
|
||||
"cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version {minimum_minecraft_version}."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
|
||||
@@ -73,15 +73,6 @@ SHARED_OPTIONS = [
|
||||
CONF_STATE_TOPIC,
|
||||
]
|
||||
|
||||
MQTT_ORIGIN_INFO_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_SW_VERSION): cv.string,
|
||||
vol.Optional(CONF_SUPPORT_URL): cv.configuration_url,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
_MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema(
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Support for Ness D8X/D16X devices."""
|
||||
|
||||
from collections import namedtuple
|
||||
import datetime
|
||||
import logging
|
||||
from typing import NamedTuple
|
||||
|
||||
from nessclient import ArmingMode, ArmingState, Client
|
||||
import voluptuous as vol
|
||||
@@ -25,11 +25,12 @@ from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "ness_alarm"
|
||||
DATA_NESS = "ness_alarm"
|
||||
DATA_NESS: HassKey[Client] = HassKey(DOMAIN)
|
||||
|
||||
CONF_DEVICE_PORT = "port"
|
||||
CONF_INFER_ARMING_STATE = "infer_arming_state"
|
||||
@@ -44,7 +45,13 @@ DEFAULT_INFER_ARMING_STATE = False
|
||||
SIGNAL_ZONE_CHANGED = "ness_alarm.zone_changed"
|
||||
SIGNAL_ARMING_STATE_CHANGED = "ness_alarm.arming_state_changed"
|
||||
|
||||
ZoneChangedData = namedtuple("ZoneChangedData", ["zone_id", "state"]) # noqa: PYI024
|
||||
|
||||
class ZoneChangedData(NamedTuple):
|
||||
"""Data for a zone state change."""
|
||||
|
||||
zone_id: int
|
||||
state: bool
|
||||
|
||||
|
||||
DEFAULT_ZONE_TYPE = BinarySensorDeviceClass.MOTION
|
||||
ZONE_SCHEMA = vol.Schema(
|
||||
|
||||
@@ -33,18 +33,14 @@ async def async_setup_platform(
|
||||
|
||||
configured_zones = discovery_info[CONF_ZONES]
|
||||
|
||||
devices = []
|
||||
|
||||
for zone_config in configured_zones:
|
||||
zone_type = zone_config[CONF_ZONE_TYPE]
|
||||
zone_name = zone_config[CONF_ZONE_NAME]
|
||||
zone_id = zone_config[CONF_ZONE_ID]
|
||||
device = NessZoneBinarySensor(
|
||||
zone_id=zone_id, name=zone_name, zone_type=zone_type
|
||||
async_add_entities(
|
||||
NessZoneBinarySensor(
|
||||
zone_id=zone_config[CONF_ZONE_ID],
|
||||
name=zone_config[CONF_ZONE_NAME],
|
||||
zone_type=zone_config[CONF_ZONE_TYPE],
|
||||
)
|
||||
devices.append(device)
|
||||
|
||||
async_add_entities(devices)
|
||||
for zone_config in configured_zones
|
||||
)
|
||||
|
||||
|
||||
class NessZoneBinarySensor(BinarySensorEntity):
|
||||
@@ -52,12 +48,14 @@ class NessZoneBinarySensor(BinarySensorEntity):
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, zone_id, name, zone_type):
|
||||
def __init__(
|
||||
self, zone_id: int, name: str, zone_type: BinarySensorDeviceClass
|
||||
) -> None:
|
||||
"""Initialize the binary_sensor."""
|
||||
self._zone_id = zone_id
|
||||
self._name = name
|
||||
self._type = zone_type
|
||||
self._state = 0
|
||||
self._attr_name = name
|
||||
self._attr_device_class = zone_type
|
||||
self._attr_is_on = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@@ -67,24 +65,9 @@ class NessZoneBinarySensor(BinarySensorEntity):
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._state == 1
|
||||
|
||||
@property
|
||||
def device_class(self) -> BinarySensorDeviceClass:
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
return self._type
|
||||
|
||||
@callback
|
||||
def _handle_zone_change(self, data: ZoneChangedData):
|
||||
def _handle_zone_change(self, data: ZoneChangedData) -> None:
|
||||
"""Handle zone state update."""
|
||||
if self._zone_id == data.zone_id:
|
||||
self._state = data.state
|
||||
self._attr_is_on = data.state
|
||||
self.async_write_ha_state()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user