Compare commits

..

93 Commits

Author SHA1 Message Date
Paulus Schoutsen
dd4d1c2d6f Update .claude/skills/integrations/SKILL.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-21 23:19:53 -05:00
Paulus Schoutsen
13b5f30fd1 Make description more important 2026-01-21 23:15:28 -05:00
Paulus Schoutsen
fbb01ba253 Split out integration skill from CLAUDE.md 2026-01-21 23:12:36 -05:00
J. Nick Koston
83a53dea94 Fix SSL context mutation by httpx/httpcore with ALPN protocol bucketing (#161330) 2026-01-21 16:53:38 -10:00
Joost Lekkerkerker
4fb89e68a7 Add integration_type hub to sanix (#161322) 2026-01-21 23:18:32 +01:00
Glenn de Haan
5202ddf095 Bump hdfury to 1.4.2 (#161401) 2026-01-21 23:06:06 +01:00
Marc Mueller
f7d7a4502e Update ruff to 0.14.13 (#161399) 2026-01-21 22:43:26 +01:00
Petro31
c7417d77b5 Update template select test framework (#161389) 2026-01-21 22:31:00 +01:00
Petro31
22018f1f80 Update template number tests to new framework (#161395) 2026-01-21 22:30:13 +01:00
Raphael Hehl
22c6704d81 Fix detection of multiple smart object types in single event (#161189)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-21 22:22:34 +01:00
Raphael Hehl
0552934b3c Bump uiprotect to 10.0.1 (#161397)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-01-21 22:20:33 +01:00
Joost Lekkerkerker
bbe1d28e88 Refactor GitHub tests to patch the library instead (#160568) 2026-01-21 22:09:56 +01:00
Robert Resch
b700a27c8f Enable apple tv on Python 3.14 (#161396) 2026-01-21 21:56:51 +01:00
Joost Lekkerkerker
0566a668a9 Add translation for add entry to RDW (#161329) 2026-01-21 21:28:27 +01:00
Marc Mueller
94f636bc2d Update pyatv to 0.17.0 (#161394) 2026-01-21 21:22:26 +01:00
Manu
a6e7546142 Add support for sequence ID to publish action in ntfy integration (#161342) 2026-01-21 17:41:46 +00:00
Thomas55555
493319894b Use device_class for O3 in Google Air Quality (#161380) 2026-01-21 17:34:46 +01:00
Erik Montnemery
987396722b Adjust entity condition strings (#161055) 2026-01-21 16:56:47 +01:00
epenet
4f52b0363d Reorder unit conversion classes alphabetically (#161364) 2026-01-21 15:53:43 +00:00
Daniel Hjelseth Høyer
52e18ed6f6 Simplify tibber config (#160903) 2026-01-21 15:42:25 +01:00
Abílio Costa
4180175fd3 Improve automation variable name (#161340) 2026-01-21 14:27:18 +00:00
Maciej Bieniek
e39ee8cae7 Bump imgw_pib to 2.0.1 (#161376) 2026-01-21 15:26:29 +01:00
epenet
c214193087 Reorder recorder constants alphabetically (#161363) 2026-01-21 14:07:24 +01:00
Paulus Schoutsen
2d84847be5 Migrate apps added to sidebar to use new app panel (#161265)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-21 14:59:13 +02:00
Joost Lekkerkerker
0d69fd4535 Add integration_type hub to rainbird (#161303) 2026-01-21 13:42:24 +01:00
Joost Lekkerkerker
56f556864c Add integration_type hub to sensibo (#161326) 2026-01-21 13:41:18 +01:00
Manu
c1b03dc553 Bump aiontfy to 0.7.0 (#161341) 2026-01-21 13:40:56 +01:00
epenet
07e76578e6 Add translation for add entry to Renault (#161361) 2026-01-21 13:40:25 +01:00
Manu
bc45fd4e45 Add translation for add entry to Habitica integration (#161372) 2026-01-21 13:39:56 +01:00
Abílio Costa
0ea03f549c Support target conditions in script relation extraction (#161338) 2026-01-21 12:01:15 +00:00
Robert Resch
0ee46dbf5d Replace deprecated test-results-action action with codecov-action (#159202) 2026-01-21 12:33:32 +01:00
Thomas55555
e12f394f8e Bump google-air-quality-api to 3.0.0 (#161347) 2026-01-21 08:49:20 +01:00
Thomas55555
b40046264d Add ppb as a valid UOM for sensor/number Ozone device class (#159328)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-21 08:32:23 +01:00
Thomas55555
22afa1d248 Use device_class for NO2 in Google Air Quality (#161359) 2026-01-21 07:51:35 +01:00
Josef Zweck
8920ffbcdb Add translation for add entry to onedrive (#161336) 2026-01-21 07:06:22 +01:00
Thomas55555
a447c1b42e Use SO2 device_class in Google Air Quality (#161349) 2026-01-21 06:55:02 +01:00
Raphael Hehl
50211f75ed Bump uiprotect to 10.0.0 (#161350) 2026-01-21 00:59:46 +01:00
Thomas55555
27117c9d17 Add ppb as a valid UOM for sensor/number NO2 device class (#159426)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-01-20 22:35:11 +00:00
Scott K Logan
7c4cdd57b6 Set integration_type for rainforest_raven to 'hub' (#161343) 2026-01-20 21:46:47 +01:00
Josef Zweck
6af5698645 Bump onedrive-personal-sdk to 0.1.1 (#161337) 2026-01-20 20:14:58 +00:00
Erik Montnemery
75db2cde40 Improve light brightness triggers (#161233) 2026-01-20 20:14:15 +00:00
stegm
329dd05434 Bump pykoplenti to 1.5.0 (#161305) 2026-01-20 21:12:49 +01:00
Joost Lekkerkerker
53c53d03e0 Add integration_type hub to rituals_perfume_genie (#161312) 2026-01-20 21:10:11 +01:00
Joost Lekkerkerker
360b394d03 Add integration_type hub to rfxtrx (#161311) 2026-01-20 21:09:09 +01:00
Joost Lekkerkerker
a663d55632 Add integration_type device to renson (#161310) 2026-01-20 21:07:50 +01:00
Joost Lekkerkerker
3fd266a513 Add integration_type hub to rehlko (#161309) 2026-01-20 21:07:21 +01:00
Joost Lekkerkerker
442c1d6242 Add integration_type hub to refoss (#161308) 2026-01-20 21:06:51 +01:00
Joost Lekkerkerker
0e2aae02f6 Add integration_type device to rapt_ble (#161307) 2026-01-20 21:04:45 +01:00
Joost Lekkerkerker
3227a6e49f Add integration_type device to rainforest_raven (#161306) 2026-01-20 21:04:10 +01:00
Joost Lekkerkerker
9d0cfb628b Add integration_type device to radiotherm (#161302) 2026-01-20 21:00:50 +01:00
Joost Lekkerkerker
4578fe0260 Add integration_type device to rabbitair (#161300) 2026-01-20 20:55:41 +01:00
Joost Lekkerkerker
0d92708108 Add integration_type device to qnap_qsw (#161299) 2026-01-20 20:54:58 +01:00
Joost Lekkerkerker
cceb50071b Add integration_type device to ruuvitag_ble (#161319) 2026-01-20 20:53:14 +01:00
Joost Lekkerkerker
62f296c9dd Add integration_type device to ruuvi_gateway (#161318) 2026-01-20 20:52:32 +01:00
Joost Lekkerkerker
ea1f280494 Add integration_type hub to russound_rio (#161317) 2026-01-20 20:48:32 +01:00
Joost Lekkerkerker
67108a2fc8 Add integration_type service to rova (#161316) 2026-01-20 20:47:36 +01:00
Joost Lekkerkerker
1ccbd5124e Add integration_type hub to roon (#161315) 2026-01-20 20:47:07 +01:00
Joost Lekkerkerker
818af90a7b Add integration_type device to roomba (#161314) 2026-01-20 20:45:35 +01:00
Joost Lekkerkerker
23bc78fa25 Add integration_type device to romy (#161313) 2026-01-20 20:44:38 +01:00
Josef Zweck
0b1cc7638f Enable smart chunk size in onedrive (#161170) 2026-01-20 20:41:48 +01:00
Joost Lekkerkerker
c291a2fbc1 Add translation for add entry to NYT Games (#161327) 2026-01-20 20:35:50 +01:00
Joost Lekkerkerker
7379a4ff4b Add integration_type hub to sense (#161325) 2026-01-20 20:33:18 +01:00
Joost Lekkerkerker
ddcf5cb749 Add integration_type hub to schlage (#161323) 2026-01-20 20:29:23 +01:00
Joost Lekkerkerker
4b10a542b0 Add integration_type hub to rympro (#161320) 2026-01-20 20:27:35 +01:00
Joost Lekkerkerker
beea9fa74b Add integration_type service to sabnzbd (#161321) 2026-01-20 20:20:54 +01:00
Joost Lekkerkerker
ce8fd16456 Add translation for add entry to Twitch (#161332) 2026-01-20 20:11:20 +01:00
Joost Lekkerkerker
2172d15489 Add translation for add entry to SmartThings (#161331) 2026-01-20 20:10:47 +01:00
Joost Lekkerkerker
0cfa0ed670 Add translation for add entry to Withings (#161333) 2026-01-20 20:07:54 +01:00
Joost Lekkerkerker
f6839913d8 Add translation for add entry to Youtube (#161334) 2026-01-20 20:07:33 +01:00
Manu
8fa01497ee Add translation for add entry to Xbox integration (#161296) 2026-01-20 19:14:49 +01:00
Abílio Costa
e077c65a77 Support target conditions in automation relation extraction (#161016) 2026-01-20 17:34:21 +00:00
Manu
7c49656fa8 Add translation for add entry to PlayStation Network integration (#161298) 2026-01-20 18:29:23 +01:00
Erik Montnemery
1730479c8d Remove reference of removed stub_blueprint_populate fixture from siren tests (#161294) 2026-01-20 16:16:22 +01:00
Thomas55555
bc28c8fd3c Add ppb as a valid uom for sensor/number CO device class (#159554) 2026-01-20 16:07:24 +01:00
Erik Montnemery
c3616fd5df Add siren conditions (#161021) 2026-01-20 16:05:21 +01:00
epenet
6b97f2ac06 Use shorthand attributes in wyoming TTS (#161286) 2026-01-20 15:59:49 +01:00
Erik Montnemery
deefcbcbe4 Remove stub_blueprint_populate test fixture (#161288) 2026-01-20 15:46:06 +01:00
Samuel Xiao
e84aeb9f99 Switchbot Cloud: Add new supported Lock (#161276) 2026-01-20 15:27:27 +01:00
epenet
ade3d8a657 Pass timestamps to Tuya wrapper skip_update (#161271) 2026-01-20 15:24:49 +01:00
Krisjanis Lejejs
a65d9032ff Bump hass-nabucasa from 1.10.0 to 1.11.0 (#161283) 2026-01-20 14:50:00 +01:00
Martin Hjelmare
b950a4eaf4 Fix nobo_hub options flow unload mocking (#161287) 2026-01-20 14:49:46 +01:00
Joost Lekkerkerker
3fe91751f5 Make add entry translatable (#159901) 2026-01-20 14:25:33 +01:00
Erwin Douna
6ee58b96ca Bump pyfirefly to 0.1.12 (#161278) 2026-01-20 13:51:09 +01:00
Erik Montnemery
d1404e7905 Simplify logic in condition tests (#161239) 2026-01-20 10:39:47 +01:00
Paulus Schoutsen
7c34191813 Use new app panel instead of ingress page (#161264)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-01-20 10:54:56 +02:00
PolarBearEs
7540d04779 Remove duplicated MQTT_ORIGIN_INFO_SCHEMA in schemas.py (#161263) 2026-01-20 08:41:40 +01:00
mettolen
d828130670 Bump pysaunum to 0.3.0 (#161255) 2026-01-20 08:31:48 +01:00
Adalberto Garcia Garces
2ec6c08bd7 Add 18 new Tuya device fixtures (#161225) 2026-01-20 07:33:59 +01:00
Thomas55555
48852bab7a Add ppb as a valid UOM for sensor/number SO2 device class (#159431) 2026-01-19 23:32:45 +00:00
Andres Ruiz
7d370f4513 Bump waterfurnace to 1.4.0 (#161244) 2026-01-19 20:33:29 +01:00
Aleksandr Oleinikov
9d97791faf Change default model for Ollama to qwen3:4b-instruct (#161202)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-19 12:56:18 -05:00
Norbert Rittel
4fe8982b68 Clarify description of lawn_mower.docked trigger (#161238) 2026-01-19 17:48:40 +00:00
Krisjanis Lejejs
8248ade211 Bump hass-nabucasa from 1.9.0 to 1.10.0 (#161226) 2026-01-19 15:02:09 +01:00
300 changed files with 11601 additions and 3447 deletions

View File

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

View File

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

View File

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

View File

@@ -2,49 +2,6 @@
This repository contains the core of Home Assistant, a Python 3 based home automation application.
## Integration Quality Scale
Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply:
### Quality Scale Levels
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
- **Silver**: Enhanced functionality
- **Gold**: Advanced features
- **Platinum**: Highest quality standards
### How Rules Apply
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
2. **Bronze Rules**: Always required for any integration with quality scale
3. **Higher Tier Rules**: Only apply if integration targets that tier or higher
4. **Rule Status**: Check `quality_scale.yaml` in integration folder for:
- `done`: Rule implemented
- `exempt`: Rule doesn't apply (with reason in comment)
- `todo`: Rule needs implementation
### Example `quality_scale.yaml` Structure
```yaml
rules:
# Bronze (mandatory)
config-flow: done
entity-unique-id: done
action-setup:
status: exempt
comment: Integration does not register custom actions.
# Silver (if targeting Silver+)
entity-unavailable: done
parallel-updates: done
# Gold (if targeting Gold+)
devices: done
diagnostics: done
# Platinum (if targeting Platinum)
strict-typing: done
```
**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules.
## Code Review Guidelines
**When reviewing code, do NOT comment on:**
@@ -91,73 +48,6 @@ rules:
- Use sentence case for titles and messages (capitalize only the first word and proper nouns)
- Avoid abbreviations when possible
## Code Organization
### Core Locations
- Shared constants: `homeassistant/const.py` (use these instead of hardcoding)
- Integration structure:
- `homeassistant/components/{domain}/const.py` - Constants
- `homeassistant/components/{domain}/models.py` - Data models
- `homeassistant/components/{domain}/coordinator.py` - Update coordinator
- `homeassistant/components/{domain}/config_flow.py` - Configuration flow
- `homeassistant/components/{domain}/{platform}.py` - Platform implementations
### Common Modules
- **coordinator.py**: Centralize data fetching logic
```python
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
```
- **entity.py**: Base entity definitions to reduce duplication
```python
class MyEntity(CoordinatorEntity[MyCoordinator]):
_attr_has_entity_name = True
```
### Runtime Data Storage
- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data
```python
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
client = MyClient(entry.data[CONF_HOST])
entry.runtime_data = client
```
### Manifest Requirements
- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements`
- **Integration Types**: `device`, `hub`, `service`, `system`, `helper`
- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`)
- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb`
- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`)
### Config Flow Patterns
- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1`
- **Unique ID Management**:
```python
await self.async_set_unique_id(device_unique_id)
self._abort_if_unique_id_configured()
```
- **Error Handling**: Define errors in `strings.json` under `config.error`
- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.)
### Integration Ownership
- **manifest.json**: Add GitHub usernames to `codeowners`:
```json
{
"domain": "my_integration",
"name": "My Integration",
"codeowners": ["@me"]
}
```
### Documentation Standards
- **File Headers**: Short and concise
```python
@@ -183,19 +73,6 @@ rules:
- No blocking calls
- Group executor jobs when possible - switching between event loop and executor is expensive
### Async Dependencies (Platinum)
- **Requirement**: All dependencies must use asyncio
- Ensures efficient task handling without thread context switching
### WebSession Injection (Platinum)
- **Pass WebSession**: Support passing web sessions to dependencies
```python
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
"""Set up integration from config entry."""
client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass))
```
- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx)
### Blocking Operations
- **Use Executor**: For blocking I/O operations
```python
@@ -215,200 +92,6 @@ rules:
- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads
- **Registry Changes**: Must be done in event loop thread
### Data Update Coordinator
- **Standard Pattern**: Use for efficient data management
```python
class MyCoordinator(DataUpdateCoordinator):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
self.client = client
async def _async_update_data(self):
try:
return await self.client.fetch_data()
except ApiError as err:
raise UpdateFailed(f"API communication error: {err}")
```
- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues
- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended
## Integration Guidelines
### Configuration Flow
- **UI Setup Required**: All integrations must support configuration via UI
- **Manifest**: Set `"config_flow": true` in `manifest.json`
- **Data Storage**:
- Connection-critical config: Store in `ConfigEntry.data`
- Non-critical settings: Store in `ConfigEntry.options`
- **Validation**: Always validate user input before creating entries
- **Config Entry Naming**:
- ❌ Do NOT allow users to set config entry names in config flows
- Names are automatically generated or can be customized later in UI
- ✅ Exception: Helper integrations MAY allow custom names in config flow
- **Connection Testing**: Test device/service connection during config flow:
```python
try:
await client.get_data()
except MyException:
errors["base"] = "cannot_connect"
```
- **Duplicate Prevention**: Prevent duplicate configurations:
```python
# Using unique ID
await self.async_set_unique_id(identifier)
self._abort_if_unique_id_configured()
# Using unique data
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
```
### Reauthentication Support
- **Required Method**: Implement `async_step_reauth` in config flow
- **Credential Updates**: Allow users to update credentials without re-adding
- **Validation**: Verify account matches existing unique ID:
```python
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}
)
```
### Reconfiguration Flow
- **Purpose**: Allow configuration updates without removing device
- **Implementation**: Add `async_step_reconfigure` method
- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch`
### Device Discovery
- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.)
```json
{
"zeroconf": ["_mydevice._tcp.local."]
}
```
- **Discovery Handler**: Implement appropriate `async_step_*` method:
```python
async def async_step_zeroconf(self, discovery_info):
"""Handle zeroconf discovery."""
await self.async_set_unique_id(discovery_info.properties["serialno"])
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
```
- **Network Updates**: Use discovery to update dynamic IP addresses
### Network Discovery Implementation
- **Zeroconf/mDNS**: Use async instances
```python
aiozc = await zeroconf.async_get_async_instance(hass)
```
- **SSDP Discovery**: Register callbacks with cleanup
```python
entry.async_on_unload(
ssdp.async_register_callback(
hass, _async_discovered_device,
{"st": "urn:schemas-upnp-org:device:ZonePlayer:1"}
)
)
```
### Bluetooth Integration
- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies
- **Connectable**: Set `"connectable": true` for connection-required devices
- **Scanner Usage**: Always use shared scanner instance
```python
scanner = bluetooth.async_get_scanner()
entry.async_on_unload(
bluetooth.async_register_callback(
hass, _async_discovered_device,
{"service_uuid": "example_uuid"},
bluetooth.BluetoothScanningMode.ACTIVE
)
)
```
- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts
### Setup Validation
- **Test Before Setup**: Verify integration can be set up in `async_setup_entry`
- **Exception Handling**:
- `ConfigEntryNotReady`: Device offline or temporary failure
- `ConfigEntryAuthFailed`: Authentication issues
- `ConfigEntryError`: Unresolvable setup problems
### Config Entry Unloading
- **Required**: Implement `async_unload_entry` for runtime removal/reload
- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms`
- **Cleanup**: Register callbacks with `entry.async_on_unload`:
```python
async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
entry.runtime_data.listener() # Clean up resources
return unload_ok
```
### Service Actions
- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry`
- **Validation**: Check config entry existence and loaded state:
```python
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def service_action(call: ServiceCall) -> ServiceResponse:
if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])):
raise ServiceValidationError("Entry not found")
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError("Entry not loaded")
```
- **Exception Handling**: Raise appropriate exceptions:
```python
# For invalid input
if end_date < start_date:
raise ServiceValidationError("End date must be after start date")
# For service errors
try:
await client.set_schedule(start_date, end_date)
except MyConnectionError as err:
raise HomeAssistantError("Could not connect to the schedule") from err
```
### Service Registration Patterns
- **Entity Services**: Register on platform setup
```python
platform.async_register_entity_service(
"my_entity_service",
{vol.Required("parameter"): cv.string},
"handle_service_method"
)
```
- **Service Schema**: Always validate input
```python
SERVICE_SCHEMA = vol.Schema({
vol.Required("entity_id"): cv.entity_ids,
vol.Required("parameter"): cv.string,
vol.Optional("timeout", default=30): cv.positive_int,
})
```
- **Services File**: Create `services.yaml` with descriptions and field definitions
### Polling
- Use update coordinator pattern when possible
- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries
- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input
- **Minimum Intervals**:
- Local network: 5 seconds
- Cloud services: 60 seconds
- **Parallel Updates**: Specify number of concurrent updates:
```python
PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device
# OR
PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only)
```
### Error Handling
- **Exception Types**: Choose most specific exception available
- `ServiceValidationError`: User input errors (preferred over `ValueError`)
@@ -440,7 +123,7 @@ rules:
except DeviceError:
_LOGGER.error("Failed to get data")
return
# ✅ Process data outside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
@@ -452,14 +135,14 @@ rules:
data = await device.get_data()
except Exception: # Too broad
_LOGGER.error("Failed")
# ✅ Allowed in config flow for robustness
async def async_step_user(self, user_input=None):
try:
await self._test_connection(user_input)
except Exception: # Allowed here
errors["base"] = "unknown"
# ✅ Allowed in background tasks
async def _background_refresh():
try:
@@ -494,7 +177,7 @@ rules:
- **Implementation Pattern**:
```python
_unavailable_logged: bool = False
if not self._unavailable_logged:
_LOGGER.info("The sensor is unavailable: %s", ex)
self._unavailable_logged = True
@@ -504,346 +187,6 @@ rules:
self._unavailable_logged = False
```
## Entity Development
### Unique IDs
- **Required**: Every entity must have a unique ID for registry tracking
- Must be unique per platform (not per integration)
- Don't include integration domain or platform in ID
- **Implementation**:
```python
class MySensor(SensorEntity):
def __init__(self, device_id: str) -> None:
self._attr_unique_id = f"{device_id}_temperature"
```
**Acceptable ID Sources**:
- Device serial numbers
- MAC addresses (formatted using `format_mac` from device registry)
- Physical identifiers (printed/EEPROM)
- Config entry ID as last resort: `f"{entry.entry_id}-battery"`
**Never Use**:
- IP addresses, hostnames, URLs
- Device names
- Email addresses, usernames
### Entity Descriptions
- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation
- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability
- **Bad pattern**:
```python
SensorEntityDescription(
key="temperature",
name="Temperature",
value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long
)
```
- **Good pattern**:
```python
SensorEntityDescription(
key="temperature",
name="Temperature",
value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda
round(data["temp_value"] * 1.8 + 32, 1)
if data.get("temp_value") is not None
else None
),
)
```
### Entity Naming
- **Use has_entity_name**: Set `_attr_has_entity_name = True`
- **For specific fields**:
```python
class MySensor(SensorEntity):
_attr_has_entity_name = True
def __init__(self, device: Device, field: str) -> None:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
)
self._attr_name = field # e.g., "temperature", "humidity"
```
- **For device itself**: Set `_attr_name = None`
### Event Lifecycle Management
- **Subscribe in `async_added_to_hass`**:
```python
async def async_added_to_hass(self) -> None:
"""Subscribe to events."""
self.async_on_remove(
self.client.events.subscribe("my_event", self._handle_event)
)
```
- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove`
- Never subscribe in `__init__` or other methods
### State Handling
- Unknown values: Use `None` (not "unknown" or "unavailable")
- Availability: Implement `available()` property instead of using "unavailable" state
### Entity Availability
- **Mark Unavailable**: When data cannot be fetched from device/service
- **Coordinator Pattern**:
```python
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.identifier in self.coordinator.data
```
- **Direct Update Pattern**:
```python
async def async_update(self) -> None:
"""Update entity."""
try:
data = await self.client.get_data()
except MyException:
self._attr_available = False
else:
self._attr_available = True
self._attr_native_value = data.value
```
### Extra State Attributes
- All attribute keys must always be present
- Unknown values: Use `None`
- Provide descriptive attributes
## Device Management
### Device Registry
- **Create Devices**: Group related entities under devices
- **Device Info**: Provide comprehensive metadata:
```python
_attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, device.mac)},
identifiers={(DOMAIN, device.id)},
name=device.name,
manufacturer="My Company",
model="My Sensor",
sw_version=device.version,
)
```
- For services: Add `entry_type=DeviceEntryType.SERVICE`
### Dynamic Device Addition
- **Auto-detect New Devices**: After initial setup
- **Implementation Pattern**:
```python
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices])
entry.async_on_unload(coordinator.async_add_listener(_check_device))
```
### Stale Device Removal
- **Auto-remove**: When devices disappear from hub/account
- **Device Registry Update**:
```python
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
```
- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed
## Diagnostics and Repairs
### Integration Diagnostics
- **Required**: Implement diagnostic data collection
- **Implementation**:
```python
TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": entry.runtime_data.data,
}
```
- **Security**: Never expose passwords, tokens, or sensitive coordinates
### Repair Issues
- **Actionable Issues Required**: All repair issues must be actionable for end users
- **Issue Content Requirements**:
- Clearly explain what is happening
- Provide specific steps users need to take to resolve the issue
- Use friendly, helpful language
- Include relevant context (device names, error details, etc.)
- **Implementation**:
```python
ir.async_create_issue(
hass,
DOMAIN,
"outdated_version",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.ERROR,
translation_key="outdated_version",
)
```
- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`:
```json
{
"issues": {
"outdated_version": {
"title": "Device firmware is outdated",
"description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant."
}
}
}
```
- **String Content Must Include**:
- What the problem is
- Why it matters
- Exact steps to resolve (numbered list when multiple steps)
- What to expect after following the steps
- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps
- **Severity Guidelines**:
- `CRITICAL`: Reserved for extreme scenarios only
- `ERROR`: Requires immediate user attention
- `WARNING`: Indicates future potential breakage
- **Additional Attributes**:
```python
ir.async_create_issue(
hass, DOMAIN, "issue_id",
breaks_in_ha_version="2024.1.0",
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.ERROR,
translation_key="issue_description",
)
```
- Only create issues for problems users can potentially resolve
### Entity Categories
- **Required**: Assign appropriate category to entities
- **Implementation**: Set `_attr_entity_category`
```python
class MySensor(SensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
```
- Categories include: `DIAGNOSTIC` for system/technical information
### Device Classes
- **Use When Available**: Set appropriate device class for entity type
```python
class MyTemperatureSensor(SensorEntity):
_attr_device_class = SensorDeviceClass.TEMPERATURE
```
- Provides context for: unit conversion, voice control, UI representation
### Disabled by Default
- **Disable Noisy/Less Popular Entities**: Reduce resource usage
```python
class MySignalStrengthSensor(SensorEntity):
_attr_entity_registry_enabled_default = False
```
- Target: frequently changing states, technical diagnostics
### Entity Translations
- **Required with has_entity_name**: Support international users
- **Implementation**:
```python
class MySensor(SensorEntity):
_attr_has_entity_name = True
_attr_translation_key = "phase_voltage"
```
- Create `strings.json` with translations:
```json
{
"entity": {
"sensor": {
"phase_voltage": {
"name": "Phase voltage"
}
}
}
}
```
### Exception Translations (Gold)
- **Translatable Errors**: Use translation keys for user-facing exceptions
- **Implementation**:
```python
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_date_before_start_date",
)
```
- Add to `strings.json`:
```json
{
"exceptions": {
"end_date_before_start_date": {
"message": "The end date cannot be before the start date."
}
}
}
```
### Icon Translations (Gold)
- **Dynamic Icons**: Support state and range-based icon selection
- **State-based Icons**:
```json
{
"entity": {
"sensor": {
"tree_pollen": {
"default": "mdi:tree",
"state": {
"high": "mdi:tree-outline"
}
}
}
}
}
```
- **Range-based Icons** (for numeric values):
```json
{
"entity": {
"sensor": {
"battery_level": {
"default": "mdi:battery-unknown",
"range": {
"0": "mdi:battery-outline",
"90": "mdi:battery-90",
"100": "mdi:battery"
}
}
}
}
}
```
## Testing Requirements
- **Location**: `tests/components/{domain}/`
- **Coverage Requirement**: Above 95% test coverage for all modules
- **Best Practices**:
- Use pytest fixtures from `tests.common`
- Mock all external dependencies
- Use snapshots for complex data structures
- Follow existing test patterns
### Config Flow Testing
- **100% Coverage Required**: All config flow paths must be tested
- **Test Scenarios**:
- All flow initiation methods (user, discovery, import)
- Successful configuration paths
- Error recovery scenarios
- Prevention of duplicate entries
- Flow completion after errors
## Development Commands
### Code Quality & Linting
@@ -855,15 +198,6 @@ rules:
- **MyPy on specific integration**: `mypy homeassistant/components/my_integration`
### Testing
- **Integration-specific tests** (recommended):
```bash
pytest ./tests/components/<integration_domain> \
--cov=homeassistant.components.<integration_domain> \
--cov-report term-missing \
--durations-min=1 \
--durations=0 \
--numprocesses=auto
```
- **Quick test of changed files**: `pytest --timeout=10 --picked`
- **Update test snapshots**: Add `--snapshot-update` to pytest command
- ⚠️ Omit test results after using `--snapshot-update`
@@ -872,62 +206,27 @@ rules:
### Dependencies & Requirements
- **Update generated files after dependency changes**: `python -m script.gen_requirements_all`
- **Install all Python requirements**:
- **Install all Python requirements**:
```bash
uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt
```
- **Install test requirements only**:
- **Install test requirements only**:
```bash
uv pip install -r requirements_test_all.txt -r requirements.txt
```
### Translations
- **Update translations after strings.json changes**:
- **Update translations after strings.json changes**:
```bash
python -m script.translations develop --all
```
### Project Validation
- **Run hassfest** (checks project structure and updates generated files):
- **Run hassfest** (checks project structure and updates generated files):
```bash
python -m script.hassfest
```
### File Locations
- **Integration code**: `./homeassistant/components/<integration_domain>/`
- **Integration tests**: `./tests/components/<integration_domain>/`
## Integration Templates
### Standard Integration Structure
```
homeassistant/components/my_integration/
├── __init__.py # Entry point with async_setup_entry
├── manifest.json # Integration metadata and dependencies
├── const.py # Domain and constants
├── config_flow.py # UI configuration flow
├── coordinator.py # Data update coordinator (if needed)
├── entity.py # Base entity class (if shared patterns)
├── sensor.py # Sensor platform
├── strings.json # User-facing text and translations
├── services.yaml # Service definitions (if applicable)
└── quality_scale.yaml # Quality scale rule status
```
### Quality Scale Progression
- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows
- **Silver → Gold**: Add device management, diagnostics, translations
- **Gold → Platinum**: Add strict typing, async dependencies, websession injection
### Minimal Integration Checklist
- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.)
- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry`
- [ ] `config_flow.py` with UI configuration support
- [ ] `const.py` with `DOMAIN` constant
- [ ] `strings.json` with at least config flow text
- [ ] Platform files (`sensor.py`, etc.) as needed
- [ ] `quality_scale.yaml` with rule status tracking
## Common Anti-Patterns & Best Practices
### ❌ **Avoid These Patterns**
@@ -968,7 +267,7 @@ try:
response = await client.get_data() # Can throw
# ❌ Data processing should be outside try block
temperature = response["temperature"] / 10
humidity = response["humidity"]
humidity = response["humidity"]
self._attr_native_value = temperature
except ClientError:
_LOGGER.error("Failed to fetch data")
@@ -1016,157 +315,10 @@ class MyCoordinator(DataUpdateCoordinator[MyData]):
# ✅ Integration determines interval based on device capabilities, connection type, etc.
interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=interval,
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
```
## Testing Patterns
### Testing Best Practices
- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead
- **Use snapshot testing** - For verifying entity states and attributes
- **Test through integration setup** - Don't test entities in isolation
- **Mock external APIs** - Use fixtures with realistic JSON data
- **Verify registries** - Ensure entities are properly registered with devices
### Config Flow Testing Template
```python
async def test_user_flow_success(hass, mock_api):
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
# Test form submission
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "My Device"
assert result["data"] == TEST_USER_INPUT
async def test_flow_connection_error(hass, mock_api_error):
"""Test connection error handling."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TEST_USER_INPUT
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
```
### Entity Testing Patterns
```python
@pytest.fixture
def platforms() -> list[Platform]:
"""Overridden fixture to specify platforms to test."""
return [Platform.SENSOR] # Or another specific platform as needed.
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
# Ensure entities are correctly assigned to device
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "device_unique_id")}
)
assert device_entry
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity_entry in entity_entries:
assert entity_entry.device_id == device_entry.id
```
### Mock Patterns
```python
# Modern integration fixture setup
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="My Integration",
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"},
unique_id="device_unique_id",
)
@pytest.fixture
def mock_device_api() -> Generator[MagicMock]:
"""Return a mocked device API."""
with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock:
api = api_mock.return_value
api.get_data.return_value = MyDeviceData.from_json(
load_fixture("device_data.json", DOMAIN)
)
yield api
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return PLATFORMS
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_device_api: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
```
## Debugging & Troubleshooting
### Common Issues & Solutions
- **Integration won't load**: Check `manifest.json` syntax and required fields
- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation
- **Config flow errors**: Check `strings.json` entries and error handling
- **Discovery not working**: Verify manifest discovery configuration and callbacks
- **Tests failing**: Check mock setup and async context
### Debug Logging Setup
```python
# Enable debug logging in tests
caplog.set_level(logging.DEBUG, logger="my_integration")
# In integration code - use proper logging
_LOGGER = logging.getLogger(__name__)
_LOGGER.debug("Processing data: %s", data) # Use lazy logging
```
### Validation Commands
```bash
# Check specific integration
python -m script.hassfest --integration-path homeassistant/components/my_integration
# Validate quality scale
# Check quality_scale.yaml against current rules
# Run integration tests with coverage
pytest ./tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing
```

View File

@@ -555,14 +555,14 @@ jobs:
run: |
. venv/bin/activate
python --version
pylint homeassistant
pylint --ignore-missing-annotations=y homeassistant
- name: Run pylint (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
run: |
. venv/bin/activate
python --version
pylint homeassistant/components/${{ needs.info.outputs.integrations_glob }}
pylint --ignore-missing-annotations=y homeassistant/components/${{ needs.info.outputs.integrations_glob }}
pylint-tests:
name: Check pylint on tests
@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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": {

View File

@@ -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:

View File

@@ -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.",

View File

@@ -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]

View File

@@ -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": {

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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:

View File

@@ -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": {

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.11"]
"requirements": ["pyfirefly==0.1.12"]
}

View File

@@ -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"
}
}
}

View File

@@ -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"]
}

View File

@@ -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,

View File

@@ -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"
},

View File

@@ -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": {

View File

@@ -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},
)

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["hdfury==1.3.1"]
"requirements": ["hdfury==1.4.2"]
}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["kostal"],
"requirements": ["pykoplenti==1.3.0"]
"requirements": ["pykoplenti==1.5.0"]
}

View File

@@ -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%]",

View File

@@ -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": {

View File

@@ -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),
}

View File

@@ -22,7 +22,10 @@
number:
selector:
number:
max: 100
min: 0
mode: box
unit_of_measurement: "%"
entity:
selector:
entity:

View File

@@ -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(

View File

@@ -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(
{

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aionfty"],
"quality_scale": "platinum",
"requirements": ["aiontfy==0.6.1"]
"requirements": ["aiontfy==0.7.0"]
}

View File

@@ -43,6 +43,7 @@ ATTR_ICON = "icon"
ATTR_MARKDOWN = "markdown"
ATTR_PRIORITY = "priority"
ATTR_TAGS = "tags"
ATTR_SEQUENCE_ID = "sequence_id"
SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema(
{
@@ -60,6 +61,7 @@ SERVICE_PUBLISH_SCHEMA = cv.make_entity_service_schema(
vol.Optional(ATTR_EMAIL): vol.Email(),
vol.Optional(ATTR_CALL): cv.string,
vol.Optional(ATTR_ICON): vol.All(vol.Url(), vol.Coerce(URL)),
vol.Optional(ATTR_SEQUENCE_ID): cv.string,
}
)

View File

@@ -88,3 +88,8 @@ publish:
type: url
autocomplete: url
example: https://example.org/logo.png
sequence_id:
required: false
selector:
text:
example: "Mc3otamDNcpJ"

View File

@@ -1,6 +1,7 @@
{
"common": {
"add_topic_description": "Set up a topic for notifications.",
"sequence_id": "Sequence ID",
"topic": "Topic"
},
"config": {
@@ -171,6 +172,9 @@
"icon": { "name": "Icon" },
"message": { "name": "Message" },
"priority": { "name": "Priority" },
"sequence_id": {
"name": "[%key:component::ntfy::common::sequence_id%]"
},
"tags": { "name": "Tags" },
"time": { "name": "Time" },
"title": { "name": "Title" },
@@ -356,6 +360,10 @@
"description": "All messages have a priority that defines how urgently your phone notifies you, depending on the configured vibration patterns, notification sounds, and visibility in the notification drawer or pop-over.",
"name": "Message priority"
},
"sequence_id": {
"description": "Enter a message or sequence ID to update an existing notification, or specify a sequence ID to reference later when updating, clearing (mark as read and dismiss), or deleting a notification.",
"name": "[%key:component::ntfy::common::sequence_id%]"
},
"tags": {
"description": "Add tags or emojis to the notification. Emojis (using shortcodes like smile) will appear in the notification title or message. Other tags will be displayed below the notification content.",
"name": "Tags/Emojis"

View File

@@ -125,7 +125,7 @@ class NumberDeviceClass(StrEnum):
CO = "carbon_monoxide"
"""Carbon Monoxide gas concentration.
Unit of measurement: `ppm` (parts per million), `mg/m³`, `μg/m³`
Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `mg/m³`, `μg/m³`
"""
CO2 = "carbon_dioxide"
@@ -247,7 +247,7 @@ class NumberDeviceClass(StrEnum):
NITROGEN_DIOXIDE = "nitrogen_dioxide"
"""Amount of NO2.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
NITROGEN_MONOXIDE = "nitrogen_monoxide"
@@ -265,7 +265,7 @@ class NumberDeviceClass(StrEnum):
OZONE = "ozone"
"""Amount of O3.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
PH = "ph"
@@ -373,7 +373,7 @@ class NumberDeviceClass(StrEnum):
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
TEMPERATURE = "temperature"
@@ -483,6 +483,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.BATTERY: {PERCENTAGE},
NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
NumberDeviceClass.CO: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
@@ -516,10 +517,16 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.ILLUMINANCE: {LIGHT_LUX},
NumberDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
NumberDeviceClass.MOISTURE: {PERCENTAGE},
NumberDeviceClass.NITROGEN_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.NITROGEN_DIOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.OZONE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.OZONE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.PH: {None},
NumberDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
@@ -545,7 +552,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
},
NumberDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
NumberDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
NumberDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.SULPHUR_DIOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.TEMPERATURE: set(UnitOfTemperature),
NumberDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {

View File

@@ -8,6 +8,9 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"user": {
"data": {

View File

@@ -158,7 +158,7 @@ MODEL_NAMES = [ # https://ollama.com/library
"yi",
"zephyr",
]
DEFAULT_MODEL = "qwen3:4b"
DEFAULT_MODEL = "qwen3:4b-instruct"
DEFAULT_CONVERSATION_NAME = "Ollama Conversation"
DEFAULT_AI_TASK_NAME = "Ollama AI Task"

View File

@@ -178,6 +178,7 @@ class OneDriveBackupAgent(BackupAgent):
file,
upload_chunk_size=upload_chunk_size,
session=async_get_clientsession(self._hass),
smart_chunk_size=True,
)
except HashMismatchError as err:
raise BackupAgentError(

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.1.0"]
"requirements": ["onedrive-personal-sdk==0.1.1"]
}

View File

@@ -25,6 +25,9 @@
"folder_creation_error": "Failed to create folder",
"folder_rename_error": "Failed to rename folder"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"folder_name": {
"data": {

View File

@@ -13,6 +13,9 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"reauth_confirm": {
"data": {

View File

@@ -9,6 +9,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/qnap_qsw",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["aioqsw"],
"requirements": ["aioqsw==0.4.2"]

View File

@@ -5,6 +5,7 @@
"codeowners": ["@rabbit-air"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rabbitair",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["python-rabbitair==0.0.8"],
"zeroconf": ["_rabbitair._udp.local."]

View File

@@ -13,6 +13,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/radiotherm",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["radiotherm"],
"requirements": ["radiotherm==2.1.0"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@konikvranik", "@allenporter"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rainbird",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyrainbird"],
"requirements": ["pyrainbird==6.0.1"]

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/rainforest_raven",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["aioraven==0.7.1"],
"usb": [

View File

@@ -15,6 +15,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/rapt_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["rapt-ble==0.1.2"]
}

View File

@@ -7,6 +7,9 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown_license_plate": "Unknown license plate"
},
"initiate_flow": {
"user": "Add vehicle"
},
"step": {
"user": {
"data": {

View File

@@ -59,11 +59,14 @@ from homeassistant.util.unit_conversion import (
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
SulphurDioxideConcentrationConverter,
TemperatureConverter,
TemperatureDeltaConverter,
UnitlessRatioConverter,
@@ -224,6 +227,9 @@ _PRIMARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
_SECONDARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
CarbonMonoxideConcentrationConverter,
NitrogenDioxideConcentrationConverter,
OzoneConcentrationConverter,
SulphurDioxideConcentrationConverter,
TemperatureDeltaConverter,
]

View File

@@ -33,11 +33,14 @@ from homeassistant.util.unit_conversion import (
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
SulphurDioxideConcentrationConverter,
TemperatureConverter,
TemperatureDeltaConverter,
UnitlessRatioConverter,
@@ -84,21 +87,28 @@ UNIT_SCHEMA = vol.Schema(
vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS),
vol.Optional("duration"): vol.In(DurationConverter.VALID_UNITS),
vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS),
vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS),
vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS),
vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS),
vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS),
vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS),
vol.Optional("nitrogen_dioxide"): vol.In(
NitrogenDioxideConcentrationConverter.VALID_UNITS
),
vol.Optional("ozone"): vol.In(OzoneConcentrationConverter.VALID_UNITS),
vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS),
vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS),
vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS),
vol.Optional("reactive_power"): vol.In(ReactivePowerConverter.VALID_UNITS),
vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS),
vol.Optional("sulphur_dioxide"): vol.In(
SulphurDioxideConcentrationConverter.VALID_UNITS
),
vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS),
vol.Optional("temperature_delta"): vol.In(
TemperatureDeltaConverter.VALID_UNITS
),
vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS),
vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS),
vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS),
vol.Optional("volume_flow_rate"): vol.In(VolumeFlowRateConverter.VALID_UNITS),
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@ashionky"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/refoss",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["refoss-ha==1.2.5"],
"single_config_entry": true

View File

@@ -10,6 +10,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/rehlko",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aiokem"],
"quality_scale": "silver",

View File

@@ -12,6 +12,9 @@
"invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"kamereon": {
"data": {

View File

@@ -4,6 +4,7 @@
"codeowners": ["@jimmyd-be"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/renson",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["renson-endura-delta==1.7.2"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rfxtrx",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["RFXtrx"],
"requirements": ["pyRFXtrx==0.31.1"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@milanmeu", "@frenck", "@quebulm"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyrituals"],
"requirements": ["pyrituals==0.0.7"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@xeniter"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/romy",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["romy==0.0.10"],
"zeroconf": ["_aicu-http._tcp.local."]

View File

@@ -22,6 +22,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/roomba",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["paho_mqtt", "roombapy"],
"requirements": ["roombapy==1.9.0"],

View File

@@ -4,6 +4,7 @@
"codeowners": ["@pavoni"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/roon",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["roonapi"],
"requirements": ["roonapi==0.1.6"]

View File

@@ -4,6 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rova",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["rova"],
"requirements": ["rova==0.4.1"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@noahhusby"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiorussound"],
"quality_scale": "silver",

View File

@@ -10,6 +10,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/ruuvi_gateway",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["aioruuvigateway==0.1.0"]
}

View File

@@ -15,6 +15,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["ruuvitag-ble==0.4.0"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@OnFreund", "@elad-bar", "@maorcc"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rympro",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["pyrympro==0.0.9"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@shaiu", "@jpbede"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sabnzbd",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pysabnzbd"],
"quality_scale": "bronze",

View File

@@ -4,6 +4,7 @@
"codeowners": ["@tomaszsluszniak"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sanix",
"integration_type": "device",
"iot_class": "cloud_polling",
"requirements": ["sanix==1.0.6"]
}

View File

@@ -44,6 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
entry.runtime_data.client.close()
await entry.runtime_data.client.async_close()
return unload_ok

View File

@@ -51,7 +51,7 @@ async def validate_input(data: dict[str, Any]) -> None:
# Try to read data to verify communication
await client.async_get_data()
finally:
client.close()
await client.async_close()
class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["pysaunum"],
"quality_scale": "platinum",
"requirements": ["pysaunum==0.2.0"]
"requirements": ["pysaunum==0.3.0"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@dknowles2"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/schlage",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["pyschlage==2025.9.0"]
}

View File

@@ -18,6 +18,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/sense",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
"requirements": ["sense-energy==0.13.8"]

View File

@@ -12,6 +12,7 @@
"homekit": {
"models": ["Sensibo"]
},
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pysensibo"],
"quality_scale": "platinum",

View File

@@ -63,11 +63,14 @@ from homeassistant.util.unit_conversion import (
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
SulphurDioxideConcentrationConverter,
TemperatureConverter,
TemperatureDeltaConverter,
UnitlessRatioConverter,
@@ -158,7 +161,7 @@ class SensorDeviceClass(StrEnum):
CO = "carbon_monoxide"
"""Carbon Monoxide gas concentration.
Unit of measurement: `ppm` (parts per million), `mg/m³`, `μg/m³`
Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `mg/m³`, `μg/m³`
"""
CO2 = "carbon_dioxide"
@@ -282,7 +285,7 @@ class SensorDeviceClass(StrEnum):
NITROGEN_DIOXIDE = "nitrogen_dioxide"
"""Amount of NO2.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
NITROGEN_MONOXIDE = "nitrogen_monoxide"
@@ -300,7 +303,7 @@ class SensorDeviceClass(StrEnum):
OZONE = "ozone"
"""Amount of O3.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion),`μg/m³`
"""
PH = "ph"
@@ -409,7 +412,7 @@ class SensorDeviceClass(StrEnum):
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
TEMPERATURE = "temperature"
@@ -562,6 +565,8 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter,
SensorDeviceClass.ENERGY_STORAGE: EnergyConverter,
SensorDeviceClass.GAS: VolumeConverter,
SensorDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter,
SensorDeviceClass.OZONE: OzoneConcentrationConverter,
SensorDeviceClass.POWER: PowerConverter,
SensorDeviceClass.POWER_FACTOR: UnitlessRatioConverter,
SensorDeviceClass.PRECIPITATION: DistanceConverter,
@@ -569,6 +574,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
SensorDeviceClass.PRESSURE: PressureConverter,
SensorDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter,
SensorDeviceClass.REACTIVE_POWER: ReactivePowerConverter,
SensorDeviceClass.SULPHUR_DIOXIDE: SulphurDioxideConcentrationConverter,
SensorDeviceClass.SPEED: SpeedConverter,
SensorDeviceClass.TEMPERATURE: TemperatureConverter,
SensorDeviceClass.TEMPERATURE_DELTA: TemperatureDeltaConverter,
@@ -595,6 +601,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.BATTERY: {PERCENTAGE},
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
SensorDeviceClass.CO: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
@@ -628,10 +635,16 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.ILLUMINANCE: {LIGHT_LUX},
SensorDeviceClass.IRRADIANCE: set(UnitOfIrradiance),
SensorDeviceClass.MOISTURE: {PERCENTAGE},
SensorDeviceClass.NITROGEN_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.NITROGEN_DIOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.OZONE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.OZONE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.PH: {None},
SensorDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
@@ -657,7 +670,10 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
},
SensorDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
SensorDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
SensorDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.SULPHUR_DIOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
SensorDeviceClass.TEMPERATURE: set(UnitOfTemperature),
SensorDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {

View File

@@ -0,0 +1,17 @@
"""Provides conditions for sirens."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from . import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the siren conditions."""
return CONDITIONS

View File

@@ -0,0 +1,17 @@
.condition_common: &condition_common
target:
entity:
domain: siren
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_off": {
"condition": "mdi:bullhorn-outline"
},
"is_on": {
"condition": "mdi:bullhorn"
}
},
"entity_component": {
"_": {
"default": "mdi:bullhorn"

View File

@@ -1,8 +1,32 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted sirens.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted sirens to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_off": {
"description": "Tests if one or more sirens are off.",
"fields": {
"behavior": {
"description": "[%key:component::siren::common::condition_behavior_description%]",
"name": "[%key:component::siren::common::condition_behavior_name%]"
}
},
"name": "If a siren is off"
},
"is_on": {
"description": "Tests if one or more sirens are on.",
"fields": {
"behavior": {
"description": "[%key:component::siren::common::condition_behavior_description%]",
"name": "[%key:component::siren::common::condition_behavior_name%]"
}
},
"name": "If a siren is on"
}
},
"entity_component": {
"_": {
"name": "[%key:component::siren::title%]",
@@ -18,6 +42,12 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -18,6 +18,9 @@
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"pick_implementation": {
"data": {

View File

@@ -16,6 +16,9 @@
"create_entry": {
"default": "Successfully authenticated with Spotify."
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"pick_implementation": {
"data": {

View File

@@ -185,6 +185,9 @@ async def make_device_data(
"Smart Lock Lite",
"Smart Lock Pro",
"Smart Lock Ultra",
"Smart Lock Vision",
"Smart Lock Vision Pro",
"Smart Lock Pro Wifi",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id

View File

@@ -92,6 +92,18 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
"Smart Lock Vision": (
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
"Smart Lock Vision Pro": (
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
"Smart Lock Pro Wifi": (
CALIBRATION_DESCRIPTION,
DOOR_OPEN_DESCRIPTION,
),
"Curtain": (CALIBRATION_DESCRIPTION,),
"Curtain3": (CALIBRATION_DESCRIPTION,),
"Roller Shade": (CALIBRATION_DESCRIPTION,),

View File

@@ -46,7 +46,7 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity):
"""Set attributes from coordinator data."""
if coord_data := self.coordinator.data:
self._attr_is_locked = coord_data["lockState"] == "locked"
if self.__model in LockV2Commands.get_supported_devices():
if self.__model != "Smart Lock Lite":
self._attr_supported_features = LockEntityFeature.OPEN
async def async_lock(self, **kwargs: Any) -> None:

View File

@@ -225,6 +225,9 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Smart Lock Lite": (BATTERY_DESCRIPTION,),
"Smart Lock Pro": (BATTERY_DESCRIPTION,),
"Smart Lock Ultra": (BATTERY_DESCRIPTION,),
"Smart Lock Vision": (BATTERY_DESCRIPTION,),
"Smart Lock Vision Pro": (BATTERY_DESCRIPTION,),
"Smart Lock Pro Wifi": (BATTERY_DESCRIPTION,),
"Relay Switch 2PM": (
RELAY_SWITCH_2PM_POWER_DESCRIPTION,
RELAY_SWITCH_2PM_VOLTAGE_DESCRIPTION,

View File

@@ -49,8 +49,8 @@ DEFAULT_NAME = "Template Select"
SELECT_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_OPTIONS): cv.template,
vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Required(ATTR_OPTIONS): cv.template,
vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_STATE): cv.template,
}
)

View File

@@ -8,7 +8,6 @@ import logging
import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError
import tibber
from tibber import data_api as tibber_data_api
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
@@ -23,13 +22,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import (
AUTH_IMPLEMENTATION,
CONF_LEGACY_ACCESS_TOKEN,
DATA_HASS_CONFIG,
DOMAIN,
TibberConfigEntry,
)
from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEntry
from .coordinator import TibberDataAPICoordinator
from .services import async_setup_services
@@ -44,24 +37,23 @@ _LOGGER = logging.getLogger(__name__)
class TibberRuntimeData:
"""Runtime data for Tibber API entries."""
tibber_connection: tibber.Tibber
session: OAuth2Session
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
_client: tibber_data_api.TibberDataAPI | None = None
_client: tibber.Tibber | None = None
async def async_get_client(
self, hass: HomeAssistant
) -> tibber_data_api.TibberDataAPI:
"""Return an authenticated Tibber Data API client."""
async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber:
"""Return an authenticated Tibber client."""
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
if self._client is None:
self._client = tibber_data_api.TibberDataAPI(
access_token,
self._client = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
self._client.set_access_token(access_token)
return self._client
@@ -88,32 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
translation_key="data_api_reauth_required",
)
tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN],
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect()
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
) as err:
raise ConfigEntryNotReady("Unable to connect") from err
except tibber.InvalidLoginError as exp:
_LOGGER.error("Failed to login. %s", exp)
return False
except tibber.FatalHttpExceptionError:
return False
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
@@ -135,10 +101,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
raise ConfigEntryNotReady from err
entry.runtime_data = TibberRuntimeData(
tibber_connection=tibber_connection,
session=session,
)
tibber_connection = await entry.runtime_data.async_get_client(hass)
async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect()
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
) as err:
raise ConfigEntryNotReady("Unable to connect") from err
except tibber.InvalidLoginError as err:
raise ConfigEntryAuthFailed("Invalid login credentials") from err
except tibber.FatalHttpExceptionError as err:
raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err
coordinator = TibberDataAPICoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data.data_api_coordinator = coordinator
@@ -154,5 +139,6 @@ async def async_unload_entry(
if unload_ok := await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
):
await config_entry.runtime_data.tibber_connection.rt_disconnect()
tibber_connection = await config_entry.runtime_data.async_get_client(hass)
await tibber_connection.rt_disconnect()
return unload_ok

View File

@@ -8,21 +8,16 @@ from typing import Any
import aiohttp
import tibber
from tibber import data_api as tibber_data_api
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import CONF_LEGACY_ACCESS_TOKEN, DATA_API_DEFAULT_SCOPES, DOMAIN
from .const import DATA_API_DEFAULT_SCOPES, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_LEGACY_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout"
ERR_CLIENT = "cannot_connect"
ERR_TOKEN = "invalid_access_token"
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
_LOGGER = logging.getLogger(__name__)
@@ -36,8 +31,7 @@ class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._access_token: str | None = None
self._title = ""
self._oauth_data: dict[str, Any] | None = None
@property
def logger(self) -> logging.Logger:
@@ -52,114 +46,70 @@ class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors={},
)
self._access_token = user_input[CONF_LEGACY_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=self._access_token,
websession=async_get_clientsession(self.hass),
)
self._title = tibber_connection.name or "Tibber"
errors: dict[str, str] = {}
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT
if errors:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
await self.async_set_unique_id(tibber_connection.user_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"title": reauth_entry.title},
)
else:
self._abort_if_unique_id_configured()
return await self.async_step_pick_implementation()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a reauth flow."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication by reusing the user step."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
)
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Finalize the OAuth flow and create the config entry."""
if self._access_token is None:
return self.async_abort(reason="missing_configuration")
self._oauth_data = data
return await self._async_validate_and_create()
data[CONF_LEGACY_ACCESS_TOKEN] = self._access_token
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle connection error retry."""
if user_input is not None:
return await self._async_validate_and_create()
return self.async_show_form(step_id="connection_error")
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
data_api_client = tibber_data_api.TibberDataAPI(
access_token,
async def _async_validate_and_create(self) -> ConfigFlowResult:
"""Validate the OAuth token and create the config entry."""
assert self._oauth_data is not None
access_token = self._oauth_data[CONF_TOKEN][CONF_ACCESS_TOKEN]
tibber_connection = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(self.hass),
)
try:
await data_api_client.get_userinfo()
except (aiohttp.ClientError, TimeoutError):
return self.async_abort(reason="cannot_connect")
await tibber_connection.update_info()
except TimeoutError:
return await self.async_step_connection_error()
except tibber.InvalidLoginError:
return self.async_abort(reason=ERR_TOKEN)
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
):
return await self.async_step_connection_error()
except tibber.FatalHttpExceptionError:
return self.async_abort(reason=ERR_CLIENT)
await self.async_set_unique_id(tibber_connection.user_id)
title = tibber_connection.name or "Tibber"
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"title": reauth_entry.title},
)
return self.async_update_reload_and_abort(
reauth_entry,
data=data,
title=self._title,
data=self._oauth_data,
title=title,
)
return self.async_create_entry(title=self._title, data=data)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=title, data=self._oauth_data)

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
if TYPE_CHECKING:
from . import TibberRuntimeData
@@ -13,8 +12,6 @@ if TYPE_CHECKING:
type TibberConfigEntry = ConfigEntry[TibberRuntimeData]
CONF_LEGACY_ACCESS_TOKEN = CONF_ACCESS_TOKEN
AUTH_IMPLEMENTATION = "auth_implementation"
DATA_HASS_CONFIG = "tibber_hass_config"
DOMAIN = "tibber"

View File

@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, cast
from aiohttp.client_exceptions import ClientError
import tibber
from tibber.data_api import TibberDataAPI, TibberDevice
from tibber.data_api import TibberDevice
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -230,28 +230,26 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
return device_sensors.get(sensor_id)
return None
async def _async_get_client(self) -> TibberDataAPI:
"""Get the Tibber Data API client with error handling."""
async def _async_get_client(self) -> tibber.Tibber:
"""Get the Tibber client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(
f"Unable to create Tibber Data API client: {err}"
) from err
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()
devices = await client.get_all_devices()
devices = await client.data_api.get_all_devices()
self._build_sensor_lookup(devices)
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
try:
devices: dict[str, TibberDevice] = await client.update_devices()
devices: dict[str, TibberDevice] = await client.data_api.update_devices()
except tibber.exceptions.RateLimitExceededError as err:
raise UpdateFailed(
f"Rate limit exceeded, retry after {err.retry_after} seconds",

View File

@@ -15,6 +15,7 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
runtime = config_entry.runtime_data
tibber_connection = await runtime.async_get_client(hass)
result: dict[str, Any] = {
"homes": [
{
@@ -24,7 +25,7 @@ async def async_get_config_entry_diagnostics(
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
}
for home in runtime.tibber_connection.get_homes(only_active=False)
for home in tibber_connection.get_homes(only_active=False)
]
}

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import tibber
from homeassistant.components.notify import (
ATTR_TITLE_DEFAULT,
NotifyEntity,
@@ -37,7 +39,9 @@ class TibberNotificationEntity(NotifyEntity):
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to Tibber devices."""
tibber_connection = self._entry.runtime_data.tibber_connection
tibber_connection: tibber.Tibber = (
await self._entry.runtime_data.async_get_client(self.hass)
)
try:
await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message

View File

@@ -605,7 +605,7 @@ async def _async_setup_graphql_sensors(
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = entry.runtime_data.tibber_connection
tibber_connection = await entry.runtime_data.async_get_client(hass)
entity_registry = er.async_get(hass)

View File

@@ -42,7 +42,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
tibber_connection = entries[0].runtime_data.tibber_connection
tibber_connection = await entries[0].runtime_data.async_get_client(call.hass)
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")

View File

@@ -2,26 +2,21 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"connection_error": {
"description": "Could not connect to Tibber. Check your internet connection and try again.",
"title": "Connection failed"
},
"reauth_confirm": {
"description": "Reconnect your Tibber account to refresh access.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"description": "Enter your access token from {url}"
}
}
},

View File

@@ -165,7 +165,7 @@ class DeviceListener(SharingDeviceListener):
self,
device: CustomerDevice,
updated_status_properties: list[str] | None = None,
dp_timestamps: dict | None = None,
dp_timestamps: dict[str, int] | None = None,
) -> None:
"""Update device status with optional DP timestamps."""
LOGGER.debug(

View File

@@ -471,9 +471,11 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
async def _handle_state_update(
self,
updated_status_properties: list[str] | None,
dp_timestamps: dict | None = None,
dp_timestamps: dict[str, int] | None,
) -> None:
"""Handle state update, only if this entity's dpcode was actually updated."""
if self._dpcode_wrapper.skip_update(self.device, updated_status_properties):
if self._dpcode_wrapper.skip_update(
self.device, updated_status_properties, dp_timestamps
):
return
self.async_write_ha_state()

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