mirror of
https://github.com/home-assistant/core.git
synced 2025-11-10 19:40:11 +00:00
Compare commits
257 Commits
openai-mod
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
192a2c1beb | ||
|
|
54ecb94db1 | ||
|
|
1ead01bc9a | ||
|
|
389a1251a1 | ||
|
|
8d27ca1e21 | ||
|
|
a76af50c10 | ||
|
|
09b91bd76a | ||
|
|
736d582d04 | ||
|
|
8114df4219 | ||
|
|
8193259e02 | ||
|
|
6306baa3c9 | ||
|
|
d481a694f1 | ||
|
|
edca3fc0b7 | ||
|
|
daea76c2f1 | ||
|
|
160b61e0b9 | ||
|
|
fc900a632a | ||
|
|
1b58809655 | ||
|
|
223c34056d | ||
|
|
99ee56a4dd | ||
|
|
91be25a292 | ||
|
|
a21af78aa1 | ||
|
|
70cfdfa231 | ||
|
|
a5b075af68 | ||
|
|
c4d4ef884e | ||
|
|
ba4e7e50e0 | ||
|
|
dd0b23afb0 | ||
|
|
779f0afcc4 | ||
|
|
d8016f7f41 | ||
|
|
25169e9075 | ||
|
|
260ca70785 | ||
|
|
69e3a5bc34 | ||
|
|
1a75a88c76 | ||
|
|
6c2a662838 | ||
|
|
749fc318ca | ||
|
|
828f979c78 | ||
|
|
1eb6d5fe32 | ||
|
|
5930ac6425 | ||
|
|
15e45df8a7 | ||
|
|
a79d2da9a3 | ||
|
|
ac86f2e2ba | ||
|
|
03ee97d38f | ||
|
|
06233b5134 | ||
|
|
9d66b19c03 | ||
|
|
bb6bcfdd01 | ||
|
|
8e9e304608 | ||
|
|
6b641411a0 | ||
|
|
6f8214bbb4 | ||
|
|
f66e83f33e | ||
|
|
2ee82e1d6f | ||
|
|
0dd1e0cabb | ||
|
|
45ae34cc0e | ||
|
|
73e578b168 | ||
|
|
52ee5d53ee | ||
|
|
62713b1371 | ||
|
|
c4c4463c63 | ||
|
|
7e2fd6e47b | ||
|
|
9f45801409 | ||
|
|
aaec243bf4 | ||
|
|
b67e85e8da | ||
|
|
25407c0f4b | ||
|
|
09e7d8d1a5 | ||
|
|
ff7c125334 | ||
|
|
3d6f868cbc | ||
|
|
378c3af9df | ||
|
|
c7271d1af9 | ||
|
|
87400c6a17 | ||
|
|
692a1119a6 | ||
|
|
2e728eb7de | ||
|
|
45ec9c7dad | ||
|
|
62ee1fbc64 | ||
|
|
3c1aa9d9de | ||
|
|
bf568b22d7 | ||
|
|
596f6cd216 | ||
|
|
cf05f1046d | ||
|
|
7f9be420d2 | ||
|
|
dda46e7e0b | ||
|
|
b1dd742a57 | ||
|
|
5af4290b77 | ||
|
|
8339516fb4 | ||
|
|
aa1314c1d5 | ||
|
|
92ad922ddc | ||
|
|
e518e7beac | ||
|
|
483d814a8f | ||
|
|
8f795f021c | ||
|
|
d823b574c0 | ||
|
|
49bd15718c | ||
|
|
d3f18c1678 | ||
|
|
5ef17c8588 | ||
|
|
e8b8d31027 | ||
|
|
978ee3870c | ||
|
|
b3862591ea | ||
|
|
1895db0ddd | ||
|
|
ee2cf961f6 | ||
|
|
9a364ec729 | ||
|
|
96529ec245 | ||
|
|
8fc8220924 | ||
|
|
386f709fd3 | ||
|
|
d088fccb88 | ||
|
|
2a5448835f | ||
|
|
a71eecaaa4 | ||
|
|
46d810b9f9 | ||
|
|
48c4240a5d | ||
|
|
bf05c23414 | ||
|
|
db1e6a0d98 | ||
|
|
4ad35e8421 | ||
|
|
850e04d9aa | ||
|
|
95c5a91f01 | ||
|
|
140f56aeaa | ||
|
|
40ce228c9c | ||
|
|
18c5437fe7 | ||
|
|
ebad1ff4cc | ||
|
|
a68e722c92 | ||
|
|
05935bbc01 | ||
|
|
c67636b4f6 | ||
|
|
777b3128bb | ||
|
|
ab6cd0eb41 | ||
|
|
f35558413a | ||
|
|
e30d405625 | ||
|
|
622cce03a1 | ||
|
|
1fa9141ce1 | ||
|
|
a060f7486f | ||
|
|
dbb5730389 | ||
|
|
431b2aa1d5 | ||
|
|
c99d81a554 | ||
|
|
ff4dc393cf | ||
|
|
d384bee576 | ||
|
|
f0cb5d5480 | ||
|
|
725799c73e | ||
|
|
dc6d2e3e84 | ||
|
|
4a7d06a68a | ||
|
|
cd800da357 | ||
|
|
4c8ab8eb64 | ||
|
|
60f4d29d60 | ||
|
|
68b7d09476 | ||
|
|
c3eb6dea11 | ||
|
|
f428ffde87 | ||
|
|
fa207860a0 | ||
|
|
959c3a8a99 | ||
|
|
254ccca4e5 | ||
|
|
5b08724d81 | ||
|
|
ea2b3b3ff3 | ||
|
|
a33760bc1a | ||
|
|
4ea7ad52b1 | ||
|
|
dac75d1902 | ||
|
|
0e9ced3c00 | ||
|
|
22d0fbcbd2 | ||
|
|
57b641b97d | ||
|
|
27bd6d2e38 | ||
|
|
427e5d81df | ||
|
|
b6bd92ed19 | ||
|
|
7976729e76 | ||
|
|
5aa0d0dc81 | ||
|
|
e1501d7510 | ||
|
|
be5109fddf | ||
|
|
c5cf9b07b7 | ||
|
|
002b7c6789 | ||
|
|
e017dc80a0 | ||
|
|
aab7381553 | ||
|
|
cbf4409db3 | ||
|
|
56fb59e48e | ||
|
|
971bd56bee | ||
|
|
b2710c1bce | ||
|
|
a069b59efc | ||
|
|
02eb1dd533 | ||
|
|
b3130c7929 | ||
|
|
aad1dbecb4 | ||
|
|
65109ea000 | ||
|
|
356ac74fa5 | ||
|
|
f3513f7f29 | ||
|
|
4bbb94f43d | ||
|
|
c1fa721a57 | ||
|
|
e3ffb41650 | ||
|
|
123cce6d96 | ||
|
|
6920dec352 | ||
|
|
f7cc260336 | ||
|
|
b7da31a021 | ||
|
|
95d4dc678c | ||
|
|
7e9da052ca | ||
|
|
59ece455d9 | ||
|
|
3ba144c8b2 | ||
|
|
456f992b7e | ||
|
|
0675e34c62 | ||
|
|
190c98f5a8 | ||
|
|
c6bb26be89 | ||
|
|
d57c5ffa8f | ||
|
|
68889e1790 | ||
|
|
8fdc50a29f | ||
|
|
5656b4c20d | ||
|
|
b6edcc9422 | ||
|
|
7a3eb53453 | ||
|
|
11a2c73e8a | ||
|
|
1644484c92 | ||
|
|
8e0a89dc2f | ||
|
|
9e4b8df344 | ||
|
|
69fdc1d269 | ||
|
|
56e0aa103d | ||
|
|
caf0492009 | ||
|
|
c6d0aad3d3 | ||
|
|
1f59b735c6 | ||
|
|
87af9fc8ba | ||
|
|
691a0ca065 | ||
|
|
80384b89a5 | ||
|
|
f7672985ed | ||
|
|
d4374dbcc7 | ||
|
|
c4ddcd64c8 | ||
|
|
c802430066 | ||
|
|
649fbfc729 | ||
|
|
80c52ad8ea | ||
|
|
150d4716fa | ||
|
|
dc2736580f | ||
|
|
f1272ef513 | ||
|
|
3c2fa023b4 | ||
|
|
5cf5be8c9c | ||
|
|
63b21fda1a | ||
|
|
d87379d083 | ||
|
|
0990cef917 | ||
|
|
962ad99c20 | ||
|
|
9c9836defd | ||
|
|
e951fc401c | ||
|
|
00e2a177a5 | ||
|
|
b6d316c8f2 | ||
|
|
b8425de0d0 | ||
|
|
d51a44acbc | ||
|
|
435465e569 | ||
|
|
3b047859f9 | ||
|
|
91cdf1a367 | ||
|
|
2377b136f3 | ||
|
|
186c4e7038 | ||
|
|
d303a7d17e | ||
|
|
14f059c766 | ||
|
|
4a10370932 | ||
|
|
672ffa5984 | ||
|
|
3d3f2527cb | ||
|
|
5c3b279f95 | ||
|
|
59bcf1167a | ||
|
|
b4d789f8e2 | ||
|
|
f4ca56052b | ||
|
|
74f9549431 | ||
|
|
9650727515 | ||
|
|
c965da6559 | ||
|
|
9077965214 | ||
|
|
2b7992e849 | ||
|
|
5d6b02f470 | ||
|
|
a274961593 | ||
|
|
4e163c4591 | ||
|
|
3ffec2a655 | ||
|
|
c646658643 | ||
|
|
342b4c3442 | ||
|
|
eb58c10e5e | ||
|
|
f42e7d982f | ||
|
|
898ef43750 | ||
|
|
f806e6ba49 | ||
|
|
c23bfb1b39 | ||
|
|
a2ffe32b02 | ||
|
|
0f32b6331d | ||
|
|
9a4959560e | ||
|
|
41ab7b346c |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
|||||||
CACHE_VERSION: 4
|
CACHE_VERSION: 4
|
||||||
UV_CACHE_VERSION: 1
|
UV_CACHE_VERSION: 1
|
||||||
MYPY_CACHE_VERSION: 1
|
MYPY_CACHE_VERSION: 1
|
||||||
HA_SHORT_VERSION: "2025.8"
|
HA_SHORT_VERSION: "2025.9"
|
||||||
DEFAULT_PYTHON: "3.13"
|
DEFAULT_PYTHON: "3.13"
|
||||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||||
# 10.3 is the oldest supported version
|
# 10.3 is the oldest supported version
|
||||||
|
|||||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
|||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3.29.4
|
uses: github/codeql-action/init@v3.29.5
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.29.4
|
uses: github/codeql-action/analyze@v3.29.5
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ homeassistant.components.air_quality.*
|
|||||||
homeassistant.components.airgradient.*
|
homeassistant.components.airgradient.*
|
||||||
homeassistant.components.airly.*
|
homeassistant.components.airly.*
|
||||||
homeassistant.components.airnow.*
|
homeassistant.components.airnow.*
|
||||||
|
homeassistant.components.airos.*
|
||||||
homeassistant.components.airq.*
|
homeassistant.components.airq.*
|
||||||
homeassistant.components.airthings.*
|
homeassistant.components.airthings.*
|
||||||
homeassistant.components.airthings_ble.*
|
homeassistant.components.airthings_ble.*
|
||||||
@@ -501,6 +502,7 @@ homeassistant.components.tag.*
|
|||||||
homeassistant.components.tailscale.*
|
homeassistant.components.tailscale.*
|
||||||
homeassistant.components.tailwind.*
|
homeassistant.components.tailwind.*
|
||||||
homeassistant.components.tami4.*
|
homeassistant.components.tami4.*
|
||||||
|
homeassistant.components.tankerkoenig.*
|
||||||
homeassistant.components.tautulli.*
|
homeassistant.components.tautulli.*
|
||||||
homeassistant.components.tcp.*
|
homeassistant.components.tcp.*
|
||||||
homeassistant.components.technove.*
|
homeassistant.components.technove.*
|
||||||
@@ -546,6 +548,7 @@ homeassistant.components.valve.*
|
|||||||
homeassistant.components.velbus.*
|
homeassistant.components.velbus.*
|
||||||
homeassistant.components.vlc_telnet.*
|
homeassistant.components.vlc_telnet.*
|
||||||
homeassistant.components.vodafone_station.*
|
homeassistant.components.vodafone_station.*
|
||||||
|
homeassistant.components.volvo.*
|
||||||
homeassistant.components.wake_on_lan.*
|
homeassistant.components.wake_on_lan.*
|
||||||
homeassistant.components.wake_word.*
|
homeassistant.components.wake_word.*
|
||||||
homeassistant.components.wallbox.*
|
homeassistant.components.wallbox.*
|
||||||
|
|||||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -67,6 +67,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/airly/ @bieniu
|
/tests/components/airly/ @bieniu
|
||||||
/homeassistant/components/airnow/ @asymworks
|
/homeassistant/components/airnow/ @asymworks
|
||||||
/tests/components/airnow/ @asymworks
|
/tests/components/airnow/ @asymworks
|
||||||
|
/homeassistant/components/airos/ @CoMPaTech
|
||||||
|
/tests/components/airos/ @CoMPaTech
|
||||||
/homeassistant/components/airq/ @Sibgatulin @dl2080
|
/homeassistant/components/airq/ @Sibgatulin @dl2080
|
||||||
/tests/components/airq/ @Sibgatulin @dl2080
|
/tests/components/airq/ @Sibgatulin @dl2080
|
||||||
/homeassistant/components/airthings/ @danielhiversen @LaStrada
|
/homeassistant/components/airthings/ @danielhiversen @LaStrada
|
||||||
@@ -1706,6 +1708,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/voip/ @balloob @synesthesiam @jaminh
|
/tests/components/voip/ @balloob @synesthesiam @jaminh
|
||||||
/homeassistant/components/volumio/ @OnFreund
|
/homeassistant/components/volumio/ @OnFreund
|
||||||
/tests/components/volumio/ @OnFreund
|
/tests/components/volumio/ @OnFreund
|
||||||
|
/homeassistant/components/volvo/ @thomasddn
|
||||||
|
/tests/components/volvo/ @thomasddn
|
||||||
/homeassistant/components/volvooncall/ @molobrakos
|
/homeassistant/components/volvooncall/ @molobrakos
|
||||||
/tests/components/volvooncall/ @molobrakos
|
/tests/components/volvooncall/ @molobrakos
|
||||||
/homeassistant/components/vulcan/ @Antoni-Czaplicki
|
/homeassistant/components/vulcan/ @Antoni-Czaplicki
|
||||||
|
|||||||
5
homeassistant/brands/frient.json
Normal file
5
homeassistant/brands/frient.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"domain": "frient",
|
||||||
|
"name": "Frient",
|
||||||
|
"iot_standards": ["zigbee"]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "third_reality",
|
"domain": "third_reality",
|
||||||
"name": "Third Reality",
|
"name": "Third Reality",
|
||||||
"iot_standards": ["zigbee"]
|
"iot_standards": ["matter", "zigbee"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "ubiquiti",
|
"domain": "ubiquiti",
|
||||||
"name": "Ubiquiti",
|
"name": "Ubiquiti",
|
||||||
"integrations": ["unifi", "unifi_direct", "unifiled", "unifiprotect"]
|
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
|
||||||
}
|
}
|
||||||
|
|||||||
42
homeassistant/components/airos/__init__.py
Normal file
42
homeassistant/components/airos/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""The Ubiquiti airOS integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from airos.airos8 import AirOS
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
|
||||||
|
|
||||||
|
_PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
|
||||||
|
"""Set up Ubiquiti airOS from a config entry."""
|
||||||
|
|
||||||
|
# By default airOS 8 comes with self-signed SSL certificates,
|
||||||
|
# with no option in the web UI to change or upload a custom certificate.
|
||||||
|
session = async_get_clientsession(hass, verify_ssl=False)
|
||||||
|
|
||||||
|
airos_device = AirOS(
|
||||||
|
host=entry.data[CONF_HOST],
|
||||||
|
username=entry.data[CONF_USERNAME],
|
||||||
|
password=entry.data[CONF_PASSWORD],
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||||
82
homeassistant/components/airos/config_flow.py
Normal file
82
homeassistant/components/airos/config_flow.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Config flow for the Ubiquiti airOS integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from airos.exceptions import (
|
||||||
|
ConnectionAuthenticationError,
|
||||||
|
ConnectionSetupError,
|
||||||
|
DataMissingError,
|
||||||
|
DeviceConnectionError,
|
||||||
|
KeyDataMissingError,
|
||||||
|
)
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import AirOS
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_HOST): str,
|
||||||
|
vol.Required(CONF_USERNAME, default="ubnt"): str,
|
||||||
|
vol.Required(CONF_PASSWORD): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Ubiquiti airOS."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self,
|
||||||
|
user_input: dict[str, Any] | None = None,
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
# By default airOS 8 comes with self-signed SSL certificates,
|
||||||
|
# with no option in the web UI to change or upload a custom certificate.
|
||||||
|
session = async_get_clientsession(self.hass, verify_ssl=False)
|
||||||
|
|
||||||
|
airos_device = AirOS(
|
||||||
|
host=user_input[CONF_HOST],
|
||||||
|
username=user_input[CONF_USERNAME],
|
||||||
|
password=user_input[CONF_PASSWORD],
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await airos_device.login()
|
||||||
|
airos_data = await airos_device.status()
|
||||||
|
|
||||||
|
except (
|
||||||
|
ConnectionSetupError,
|
||||||
|
DeviceConnectionError,
|
||||||
|
):
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except (ConnectionAuthenticationError, DataMissingError):
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except KeyDataMissingError:
|
||||||
|
errors["base"] = "key_data_missing"
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
await self.async_set_unique_id(airos_data.derived.mac)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=airos_data.host.hostname, data=user_input
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
9
homeassistant/components/airos/const.py
Normal file
9
homeassistant/components/airos/const.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Constants for the Ubiquiti airOS integration."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
DOMAIN = "airos"
|
||||||
|
|
||||||
|
SCAN_INTERVAL = timedelta(minutes=1)
|
||||||
|
|
||||||
|
MANUFACTURER = "Ubiquiti"
|
||||||
66
homeassistant/components/airos/coordinator.py
Normal file
66
homeassistant/components/airos/coordinator.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""DataUpdateCoordinator for AirOS."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from airos.airos8 import AirOS, AirOSData
|
||||||
|
from airos.exceptions import (
|
||||||
|
ConnectionAuthenticationError,
|
||||||
|
ConnectionSetupError,
|
||||||
|
DataMissingError,
|
||||||
|
DeviceConnectionError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryError
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DOMAIN, SCAN_INTERVAL
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
|
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
|
||||||
|
"""Class to manage fetching AirOS data from single endpoint."""
|
||||||
|
|
||||||
|
config_entry: AirOSConfigEntry
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
self.airos_device = airos_device
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
config_entry=config_entry,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=SCAN_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> AirOSData:
|
||||||
|
"""Fetch data from AirOS."""
|
||||||
|
try:
|
||||||
|
await self.airos_device.login()
|
||||||
|
return await self.airos_device.status()
|
||||||
|
except (ConnectionAuthenticationError,) as err:
|
||||||
|
_LOGGER.exception("Error authenticating with airOS device")
|
||||||
|
raise ConfigEntryError(
|
||||||
|
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||||
|
) from err
|
||||||
|
except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err:
|
||||||
|
_LOGGER.error("Error connecting to airOS device: %s", err)
|
||||||
|
raise UpdateFailed(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="cannot_connect",
|
||||||
|
) from err
|
||||||
|
except (DataMissingError,) as err:
|
||||||
|
_LOGGER.error("Expected data not returned by airOS device: %s", err)
|
||||||
|
raise UpdateFailed(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="error_data_missing",
|
||||||
|
) from err
|
||||||
36
homeassistant/components/airos/entity.py
Normal file
36
homeassistant/components/airos/entity.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Generic AirOS Entity Class."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN, MANUFACTURER
|
||||||
|
from .coordinator import AirOSDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
|
||||||
|
"""Represent a AirOS Entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator: AirOSDataUpdateCoordinator) -> None:
|
||||||
|
"""Initialise the gateway."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
|
||||||
|
airos_data = self.coordinator.data
|
||||||
|
|
||||||
|
configuration_url: str | None = (
|
||||||
|
f"https://{coordinator.config_entry.data[CONF_HOST]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
connections={(CONNECTION_NETWORK_MAC, airos_data.derived.mac)},
|
||||||
|
configuration_url=configuration_url,
|
||||||
|
identifiers={(DOMAIN, str(airos_data.host.device_id))},
|
||||||
|
manufacturer=MANUFACTURER,
|
||||||
|
model=airos_data.host.devmodel,
|
||||||
|
name=airos_data.host.hostname,
|
||||||
|
sw_version=airos_data.host.fwversion,
|
||||||
|
)
|
||||||
10
homeassistant/components/airos/manifest.json
Normal file
10
homeassistant/components/airos/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"domain": "airos",
|
||||||
|
"name": "Ubiquiti airOS",
|
||||||
|
"codeowners": ["@CoMPaTech"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"quality_scale": "bronze",
|
||||||
|
"requirements": ["airos==0.2.1"]
|
||||||
|
}
|
||||||
72
homeassistant/components/airos/quality_scale.yaml
Normal file
72
homeassistant/components/airos/quality_scale.yaml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
rules:
|
||||||
|
# Bronze
|
||||||
|
action-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: airOS does not have actions
|
||||||
|
appropriate-polling: done
|
||||||
|
brands: done
|
||||||
|
common-modules: done
|
||||||
|
config-flow-test-coverage: done
|
||||||
|
config-flow: done
|
||||||
|
dependency-transparency: done
|
||||||
|
docs-actions:
|
||||||
|
status: exempt
|
||||||
|
comment: airOS does not have actions
|
||||||
|
docs-high-level-description: done
|
||||||
|
docs-installation-instructions: done
|
||||||
|
docs-removal-instructions: done
|
||||||
|
entity-event-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: local_polling without events
|
||||||
|
entity-unique-id: done
|
||||||
|
has-entity-name: done
|
||||||
|
runtime-data: done
|
||||||
|
test-before-configure: done
|
||||||
|
test-before-setup: done
|
||||||
|
unique-config-entry: done
|
||||||
|
|
||||||
|
# Silver
|
||||||
|
action-exceptions:
|
||||||
|
status: exempt
|
||||||
|
comment: airOS does not have actions
|
||||||
|
config-entry-unloading: done
|
||||||
|
docs-configuration-parameters: done
|
||||||
|
docs-installation-parameters: done
|
||||||
|
entity-unavailable: todo
|
||||||
|
integration-owner: done
|
||||||
|
log-when-unavailable: todo
|
||||||
|
parallel-updates: todo
|
||||||
|
reauthentication-flow: todo
|
||||||
|
test-coverage: done
|
||||||
|
|
||||||
|
# Gold
|
||||||
|
devices: done
|
||||||
|
diagnostics: todo
|
||||||
|
discovery-update-info: todo
|
||||||
|
discovery: todo
|
||||||
|
docs-data-update: done
|
||||||
|
docs-examples: todo
|
||||||
|
docs-known-limitations: done
|
||||||
|
docs-supported-devices: done
|
||||||
|
docs-supported-functions: todo
|
||||||
|
docs-troubleshooting: done
|
||||||
|
docs-use-cases: todo
|
||||||
|
dynamic-devices: todo
|
||||||
|
entity-category: done
|
||||||
|
entity-device-class: done
|
||||||
|
entity-disabled-by-default:
|
||||||
|
status: todo
|
||||||
|
comment: prepared binary_sensors will provide this
|
||||||
|
entity-translations: done
|
||||||
|
exception-translations: done
|
||||||
|
icon-translations:
|
||||||
|
status: exempt
|
||||||
|
comment: no (custom) icons used or envisioned
|
||||||
|
reconfiguration-flow: todo
|
||||||
|
repair-issues: todo
|
||||||
|
stale-devices: todo
|
||||||
|
|
||||||
|
# Platinum
|
||||||
|
async-dependency: done
|
||||||
|
inject-websession: done
|
||||||
|
strict-typing: done
|
||||||
152
homeassistant/components/airos/sensor.py
Normal file
152
homeassistant/components/airos/sensor.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""AirOS Sensor component for Home Assistant."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from airos.data import NetRole, WirelessMode
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.const import (
|
||||||
|
PERCENTAGE,
|
||||||
|
SIGNAL_STRENGTH_DECIBELS,
|
||||||
|
UnitOfDataRate,
|
||||||
|
UnitOfFrequency,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import StateType
|
||||||
|
|
||||||
|
from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
|
||||||
|
from .entity import AirOSEntity
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WIRELESS_MODE_OPTIONS = [mode.value.replace("-", "_").lower() for mode in WirelessMode]
|
||||||
|
NETROLE_OPTIONS = [mode.value for mode in NetRole]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class AirOSSensorEntityDescription(SensorEntityDescription):
|
||||||
|
"""Describe an AirOS sensor."""
|
||||||
|
|
||||||
|
value_fn: Callable[[AirOSData], StateType]
|
||||||
|
|
||||||
|
|
||||||
|
SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="host_cpuload",
|
||||||
|
translation_key="host_cpuload",
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda data: data.host.cpuload,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
),
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="host_netrole",
|
||||||
|
translation_key="host_netrole",
|
||||||
|
device_class=SensorDeviceClass.ENUM,
|
||||||
|
value_fn=lambda data: data.host.netrole.value,
|
||||||
|
options=NETROLE_OPTIONS,
|
||||||
|
),
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="wireless_frequency",
|
||||||
|
translation_key="wireless_frequency",
|
||||||
|
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
|
||||||
|
device_class=SensorDeviceClass.FREQUENCY,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda data: data.wireless.frequency,
|
||||||
|
),
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="wireless_essid",
|
||||||
|
translation_key="wireless_essid",
|
||||||
|
value_fn=lambda data: data.wireless.essid,
|
||||||
|
),
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="wireless_mode",
|
||||||
|
translation_key="wireless_mode",
|
||||||
|
device_class=SensorDeviceClass.ENUM,
|
||||||
|
value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(),
|
||||||
|
options=WIRELESS_MODE_OPTIONS,
|
||||||
|
),
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="wireless_antenna_gain",
|
||||||
|
translation_key="wireless_antenna_gain",
|
||||||
|
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||||
|
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda data: data.wireless.antenna_gain,
|
||||||
|
),
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="wireless_throughput_tx",
|
||||||
|
translation_key="wireless_throughput_tx",
|
||||||
|
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||||
|
device_class=SensorDeviceClass.DATA_RATE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda data: data.wireless.throughput.tx,
|
||||||
|
),
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="wireless_throughput_rx",
|
||||||
|
translation_key="wireless_throughput_rx",
|
||||||
|
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||||
|
device_class=SensorDeviceClass.DATA_RATE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda data: data.wireless.throughput.rx,
|
||||||
|
),
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="wireless_polling_dl_capacity",
|
||||||
|
translation_key="wireless_polling_dl_capacity",
|
||||||
|
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||||
|
device_class=SensorDeviceClass.DATA_RATE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda data: data.wireless.polling.dl_capacity,
|
||||||
|
),
|
||||||
|
AirOSSensorEntityDescription(
|
||||||
|
key="wireless_polling_ul_capacity",
|
||||||
|
translation_key="wireless_polling_ul_capacity",
|
||||||
|
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||||
|
device_class=SensorDeviceClass.DATA_RATE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda data: data.wireless.polling.ul_capacity,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: AirOSConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the AirOS sensors from a config entry."""
|
||||||
|
coordinator = config_entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS)
|
||||||
|
|
||||||
|
|
||||||
|
class AirOSSensor(AirOSEntity, SensorEntity):
|
||||||
|
"""Representation of a Sensor."""
|
||||||
|
|
||||||
|
entity_description: AirOSSensorEntityDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: AirOSDataUpdateCoordinator,
|
||||||
|
description: AirOSSensorEntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the sensor."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> StateType:
|
||||||
|
"""Return the state of the sensor."""
|
||||||
|
return self.entity_description.value_fn(self.coordinator.data)
|
||||||
87
homeassistant/components/airos/strings.json
Normal file
87
homeassistant/components/airos/strings.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"flow_title": "Ubiquiti airOS device",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::host%]",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "IP address or hostname of the airOS device",
|
||||||
|
"username": "Administrator username for the airOS device, normally 'ubnt'",
|
||||||
|
"password": "Password configured through the UISP app or web interface"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"key_data_missing": "Expected data not returned from the device, check the documentation for supported devices",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"host_cpuload": {
|
||||||
|
"name": "CPU load"
|
||||||
|
},
|
||||||
|
"host_netrole": {
|
||||||
|
"name": "Network role",
|
||||||
|
"state": {
|
||||||
|
"bridge": "Bridge",
|
||||||
|
"router": "Router"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wireless_frequency": {
|
||||||
|
"name": "Wireless frequency"
|
||||||
|
},
|
||||||
|
"wireless_essid": {
|
||||||
|
"name": "Wireless SSID"
|
||||||
|
},
|
||||||
|
"wireless_mode": {
|
||||||
|
"name": "Wireless mode",
|
||||||
|
"state": {
|
||||||
|
"ap_ptp": "Access point",
|
||||||
|
"sta_ptp": "Station"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wireless_antenna_gain": {
|
||||||
|
"name": "Antenna gain"
|
||||||
|
},
|
||||||
|
"wireless_throughput_tx": {
|
||||||
|
"name": "Throughput transmit (actual)"
|
||||||
|
},
|
||||||
|
"wireless_throughput_rx": {
|
||||||
|
"name": "Throughput receive (actual)"
|
||||||
|
},
|
||||||
|
"wireless_polling_dl_capacity": {
|
||||||
|
"name": "Download capacity"
|
||||||
|
},
|
||||||
|
"wireless_polling_ul_capacity": {
|
||||||
|
"name": "Upload capacity"
|
||||||
|
},
|
||||||
|
"wireless_remote_hostname": {
|
||||||
|
"name": "Remote hostname"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"invalid_auth": {
|
||||||
|
"message": "[%key:common::config_flow::error::invalid_auth%]"
|
||||||
|
},
|
||||||
|
"cannot_connect": {
|
||||||
|
"message": "[%key:common::config_flow::error::cannot_connect%]"
|
||||||
|
},
|
||||||
|
"key_data_missing": {
|
||||||
|
"message": "Key data not returned from device"
|
||||||
|
},
|
||||||
|
"error_data_missing": {
|
||||||
|
"message": "Data incomplete or missing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aioairzone_cloud"],
|
"loggers": ["aioairzone_cloud"],
|
||||||
"requirements": ["aioairzone-cloud==0.7.0"]
|
"requirements": ["aioairzone-cloud==0.7.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||||
|
from .services import async_setup_services
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
@@ -12,11 +16,20 @@ PLATFORMS = [
|
|||||||
Platform.SWITCH,
|
Platform.SWITCH,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
"""Set up the Alexa Devices component."""
|
||||||
|
async_setup_services(hass)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||||
"""Set up Alexa Devices platform."""
|
"""Set up Alexa Devices platform."""
|
||||||
|
|
||||||
coordinator = AmazonDevicesCoordinator(hass, entry)
|
session = aiohttp_client.async_create_clientsession(hass)
|
||||||
|
coordinator = AmazonDevicesCoordinator(hass, entry, session)
|
||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
@@ -29,8 +42,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
|||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
coordinator = entry.runtime_data
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
|
||||||
await coordinator.api.close()
|
|
||||||
|
|
||||||
return unload_ok
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import voluptuous as vol
|
|||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import aiohttp_client
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.selector import CountrySelector
|
from homeassistant.helpers.selector import CountrySelector
|
||||||
|
|
||||||
@@ -33,18 +34,15 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
|||||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Validate the user input allows us to connect."""
|
"""Validate the user input allows us to connect."""
|
||||||
|
|
||||||
|
session = aiohttp_client.async_create_clientsession(hass)
|
||||||
api = AmazonEchoApi(
|
api = AmazonEchoApi(
|
||||||
|
session,
|
||||||
data[CONF_COUNTRY],
|
data[CONF_COUNTRY],
|
||||||
data[CONF_USERNAME],
|
data[CONF_USERNAME],
|
||||||
data[CONF_PASSWORD],
|
data[CONF_PASSWORD],
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
return await api.login_mode_interactive(data[CONF_CODE])
|
||||||
data = await api.login_mode_interactive(data[CONF_CODE])
|
|
||||||
finally:
|
|
||||||
await api.close()
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from aioamazondevices.exceptions import (
|
|||||||
CannotConnect,
|
CannotConnect,
|
||||||
CannotRetrieveData,
|
CannotRetrieveData,
|
||||||
)
|
)
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME
|
||||||
@@ -31,6 +32,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
|||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: AmazonConfigEntry,
|
entry: AmazonConfigEntry,
|
||||||
|
session: ClientSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the scanner."""
|
"""Initialize the scanner."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -41,6 +43,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
|||||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||||
)
|
)
|
||||||
self.api = AmazonEchoApi(
|
self.api = AmazonEchoApi(
|
||||||
|
session,
|
||||||
entry.data[CONF_COUNTRY],
|
entry.data[CONF_COUNTRY],
|
||||||
entry.data[CONF_USERNAME],
|
entry.data[CONF_USERNAME],
|
||||||
entry.data[CONF_PASSWORD],
|
entry.data[CONF_PASSWORD],
|
||||||
|
|||||||
@@ -38,5 +38,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"send_sound": {
|
||||||
|
"service": "mdi:cast-audio"
|
||||||
|
},
|
||||||
|
"send_text_command": {
|
||||||
|
"service": "mdi:microphone-message"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["aioamazondevices"],
|
"loggers": ["aioamazondevices"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["aioamazondevices==3.5.1"]
|
"requirements": ["aioamazondevices==4.0.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,17 +48,17 @@ rules:
|
|||||||
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
|
comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration
|
||||||
docs-data-update: done
|
docs-data-update: done
|
||||||
docs-examples: done
|
docs-examples: done
|
||||||
docs-known-limitations: todo
|
docs-known-limitations: done
|
||||||
docs-supported-devices: done
|
docs-supported-devices: done
|
||||||
docs-supported-functions: done
|
docs-supported-functions: done
|
||||||
docs-troubleshooting: todo
|
docs-troubleshooting: done
|
||||||
docs-use-cases: done
|
docs-use-cases: done
|
||||||
dynamic-devices: todo
|
dynamic-devices: todo
|
||||||
entity-category: done
|
entity-category: done
|
||||||
entity-device-class: done
|
entity-device-class: done
|
||||||
entity-disabled-by-default: done
|
entity-disabled-by-default: done
|
||||||
entity-translations: done
|
entity-translations: done
|
||||||
exception-translations: todo
|
exception-translations: done
|
||||||
icon-translations: done
|
icon-translations: done
|
||||||
reconfiguration-flow: todo
|
reconfiguration-flow: todo
|
||||||
repair-issues:
|
repair-issues:
|
||||||
@@ -70,5 +70,5 @@ rules:
|
|||||||
|
|
||||||
# Platinum
|
# Platinum
|
||||||
async-dependency: done
|
async-dependency: done
|
||||||
inject-websession: todo
|
inject-websession: done
|
||||||
strict-typing: done
|
strict-typing: done
|
||||||
|
|||||||
121
homeassistant/components/alexa_devices/services.py
Normal file
121
homeassistant/components/alexa_devices/services.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Support for services."""
|
||||||
|
|
||||||
|
from aioamazondevices.sounds import SOUNDS_LIST
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.const import ATTR_DEVICE_ID
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import AmazonConfigEntry
|
||||||
|
|
||||||
|
ATTR_TEXT_COMMAND = "text_command"
|
||||||
|
ATTR_SOUND = "sound"
|
||||||
|
ATTR_SOUND_VARIANT = "sound_variant"
|
||||||
|
SERVICE_TEXT_COMMAND = "send_text_command"
|
||||||
|
SERVICE_SOUND_NOTIFICATION = "send_sound"
|
||||||
|
|
||||||
|
SCHEMA_SOUND_SERVICE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_SOUND): cv.string,
|
||||||
|
vol.Required(ATTR_SOUND_VARIANT): cv.positive_int,
|
||||||
|
vol.Required(ATTR_DEVICE_ID): cv.string,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SCHEMA_CUSTOM_COMMAND = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_TEXT_COMMAND): cv.string,
|
||||||
|
vol.Required(ATTR_DEVICE_ID): cv.string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_get_entry_id_for_service_call(
|
||||||
|
call: ServiceCall,
|
||||||
|
) -> tuple[dr.DeviceEntry, AmazonConfigEntry]:
|
||||||
|
"""Get the entry ID related to a service call (by device ID)."""
|
||||||
|
device_registry = dr.async_get(call.hass)
|
||||||
|
device_id = call.data[ATTR_DEVICE_ID]
|
||||||
|
if (device_entry := device_registry.async_get(device_id)) is None:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="invalid_device_id",
|
||||||
|
translation_placeholders={"device_id": device_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
for entry_id in device_entry.config_entries:
|
||||||
|
if (entry := call.hass.config_entries.async_get_entry(entry_id)) is None:
|
||||||
|
continue
|
||||||
|
if entry.domain == DOMAIN:
|
||||||
|
if entry.state is not ConfigEntryState.LOADED:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="entry_not_loaded",
|
||||||
|
translation_placeholders={"entry": entry.title},
|
||||||
|
)
|
||||||
|
return (device_entry, entry)
|
||||||
|
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="config_entry_not_found",
|
||||||
|
translation_placeholders={"device_id": device_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
|
||||||
|
"""Execute action on the device."""
|
||||||
|
device, config_entry = async_get_entry_id_for_service_call(call)
|
||||||
|
assert device.serial_number
|
||||||
|
value: str = call.data[attribute]
|
||||||
|
|
||||||
|
coordinator = config_entry.runtime_data
|
||||||
|
|
||||||
|
if attribute == ATTR_SOUND:
|
||||||
|
variant: int = call.data[ATTR_SOUND_VARIANT]
|
||||||
|
pad = "_" if variant > 10 else "_0"
|
||||||
|
file = f"{value}{pad}{variant!s}"
|
||||||
|
if value not in SOUNDS_LIST or variant > SOUNDS_LIST[value]:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="invalid_sound_value",
|
||||||
|
translation_placeholders={"sound": value, "variant": str(variant)},
|
||||||
|
)
|
||||||
|
await coordinator.api.call_alexa_sound(
|
||||||
|
coordinator.data[device.serial_number], file
|
||||||
|
)
|
||||||
|
elif attribute == ATTR_TEXT_COMMAND:
|
||||||
|
await coordinator.api.call_alexa_text_command(
|
||||||
|
coordinator.data[device.serial_number], value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_send_sound_notification(call: ServiceCall) -> None:
|
||||||
|
"""Send a sound notification to a AmazonDevice."""
|
||||||
|
await _async_execute_action(call, ATTR_SOUND)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_send_text_command(call: ServiceCall) -> None:
|
||||||
|
"""Send a custom command to a AmazonDevice."""
|
||||||
|
await _async_execute_action(call, ATTR_TEXT_COMMAND)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Set up the services for the Amazon Devices integration."""
|
||||||
|
for service_name, method, schema in (
|
||||||
|
(
|
||||||
|
SERVICE_SOUND_NOTIFICATION,
|
||||||
|
async_send_sound_notification,
|
||||||
|
SCHEMA_SOUND_SERVICE,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SERVICE_TEXT_COMMAND,
|
||||||
|
async_send_text_command,
|
||||||
|
SCHEMA_CUSTOM_COMMAND,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
hass.services.async_register(DOMAIN, service_name, method, schema=schema)
|
||||||
504
homeassistant/components/alexa_devices/services.yaml
Normal file
504
homeassistant/components/alexa_devices/services.yaml
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
send_text_command:
|
||||||
|
fields:
|
||||||
|
device_id:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
device:
|
||||||
|
integration: alexa_devices
|
||||||
|
text_command:
|
||||||
|
required: true
|
||||||
|
example: "Play B.B.C. on TuneIn"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|
||||||
|
send_sound:
|
||||||
|
fields:
|
||||||
|
device_id:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
device:
|
||||||
|
integration: alexa_devices
|
||||||
|
sound_variant:
|
||||||
|
required: true
|
||||||
|
example: 1
|
||||||
|
default: 1
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 1
|
||||||
|
max: 50
|
||||||
|
sound:
|
||||||
|
required: true
|
||||||
|
example: amzn_sfx_doorbell_chime
|
||||||
|
default: amzn_sfx_doorbell_chime
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- air_horn
|
||||||
|
- air_horns
|
||||||
|
- airboat
|
||||||
|
- airport
|
||||||
|
- aliens
|
||||||
|
- amzn_sfx_airplane_takeoff_whoosh
|
||||||
|
- amzn_sfx_army_march_clank_7x
|
||||||
|
- amzn_sfx_army_march_large_8x
|
||||||
|
- amzn_sfx_army_march_small_8x
|
||||||
|
- amzn_sfx_baby_big_cry
|
||||||
|
- amzn_sfx_baby_cry
|
||||||
|
- amzn_sfx_baby_fuss
|
||||||
|
- amzn_sfx_battle_group_clanks
|
||||||
|
- amzn_sfx_battle_man_grunts
|
||||||
|
- amzn_sfx_battle_men_grunts
|
||||||
|
- amzn_sfx_battle_men_horses
|
||||||
|
- amzn_sfx_battle_noisy_clanks
|
||||||
|
- amzn_sfx_battle_yells_men
|
||||||
|
- amzn_sfx_battle_yells_men_run
|
||||||
|
- amzn_sfx_bear_groan_roar
|
||||||
|
- amzn_sfx_bear_roar_grumble
|
||||||
|
- amzn_sfx_bear_roar_small
|
||||||
|
- amzn_sfx_beep_1x
|
||||||
|
- amzn_sfx_bell_med_chime
|
||||||
|
- amzn_sfx_bell_short_chime
|
||||||
|
- amzn_sfx_bell_timer
|
||||||
|
- amzn_sfx_bicycle_bell_ring
|
||||||
|
- amzn_sfx_bird_chickadee_chirp_1x
|
||||||
|
- amzn_sfx_bird_chickadee_chirps
|
||||||
|
- amzn_sfx_bird_forest
|
||||||
|
- amzn_sfx_bird_forest_short
|
||||||
|
- amzn_sfx_bird_robin_chirp_1x
|
||||||
|
- amzn_sfx_boing_long_1x
|
||||||
|
- amzn_sfx_boing_med_1x
|
||||||
|
- amzn_sfx_boing_short_1x
|
||||||
|
- amzn_sfx_bus_drive_past
|
||||||
|
- amzn_sfx_buzz_electronic
|
||||||
|
- amzn_sfx_buzzer_loud_alarm
|
||||||
|
- amzn_sfx_buzzer_small
|
||||||
|
- amzn_sfx_car_accelerate
|
||||||
|
- amzn_sfx_car_accelerate_noisy
|
||||||
|
- amzn_sfx_car_click_seatbelt
|
||||||
|
- amzn_sfx_car_close_door_1x
|
||||||
|
- amzn_sfx_car_drive_past
|
||||||
|
- amzn_sfx_car_honk_1x
|
||||||
|
- amzn_sfx_car_honk_2x
|
||||||
|
- amzn_sfx_car_honk_3x
|
||||||
|
- amzn_sfx_car_honk_long_1x
|
||||||
|
- amzn_sfx_car_into_driveway
|
||||||
|
- amzn_sfx_car_into_driveway_fast
|
||||||
|
- amzn_sfx_car_slam_door_1x
|
||||||
|
- amzn_sfx_car_undo_seatbelt
|
||||||
|
- amzn_sfx_cat_angry_meow_1x
|
||||||
|
- amzn_sfx_cat_angry_screech_1x
|
||||||
|
- amzn_sfx_cat_long_meow_1x
|
||||||
|
- amzn_sfx_cat_meow_1x
|
||||||
|
- amzn_sfx_cat_purr
|
||||||
|
- amzn_sfx_cat_purr_meow
|
||||||
|
- amzn_sfx_chicken_cluck
|
||||||
|
- amzn_sfx_church_bell_1x
|
||||||
|
- amzn_sfx_church_bells_ringing
|
||||||
|
- amzn_sfx_clear_throat_ahem
|
||||||
|
- amzn_sfx_clock_ticking
|
||||||
|
- amzn_sfx_clock_ticking_long
|
||||||
|
- amzn_sfx_copy_machine
|
||||||
|
- amzn_sfx_cough
|
||||||
|
- amzn_sfx_crow_caw_1x
|
||||||
|
- amzn_sfx_crowd_applause
|
||||||
|
- amzn_sfx_crowd_bar
|
||||||
|
- amzn_sfx_crowd_bar_rowdy
|
||||||
|
- amzn_sfx_crowd_boo
|
||||||
|
- amzn_sfx_crowd_cheer_med
|
||||||
|
- amzn_sfx_crowd_excited_cheer
|
||||||
|
- amzn_sfx_dog_med_bark_1x
|
||||||
|
- amzn_sfx_dog_med_bark_2x
|
||||||
|
- amzn_sfx_dog_med_bark_growl
|
||||||
|
- amzn_sfx_dog_med_growl_1x
|
||||||
|
- amzn_sfx_dog_med_woof_1x
|
||||||
|
- amzn_sfx_dog_small_bark_2x
|
||||||
|
- amzn_sfx_door_open
|
||||||
|
- amzn_sfx_door_shut
|
||||||
|
- amzn_sfx_doorbell
|
||||||
|
- amzn_sfx_doorbell_buzz
|
||||||
|
- amzn_sfx_doorbell_chime
|
||||||
|
- amzn_sfx_drinking_slurp
|
||||||
|
- amzn_sfx_drum_and_cymbal
|
||||||
|
- amzn_sfx_drum_comedy
|
||||||
|
- amzn_sfx_earthquake_rumble
|
||||||
|
- amzn_sfx_electric_guitar
|
||||||
|
- amzn_sfx_electronic_beep
|
||||||
|
- amzn_sfx_electronic_major_chord
|
||||||
|
- amzn_sfx_elephant
|
||||||
|
- amzn_sfx_elevator_bell_1x
|
||||||
|
- amzn_sfx_elevator_open_bell
|
||||||
|
- amzn_sfx_fairy_melodic_chimes
|
||||||
|
- amzn_sfx_fairy_sparkle_chimes
|
||||||
|
- amzn_sfx_faucet_drip
|
||||||
|
- amzn_sfx_faucet_running
|
||||||
|
- amzn_sfx_fireplace_crackle
|
||||||
|
- amzn_sfx_fireworks
|
||||||
|
- amzn_sfx_fireworks_firecrackers
|
||||||
|
- amzn_sfx_fireworks_launch
|
||||||
|
- amzn_sfx_fireworks_whistles
|
||||||
|
- amzn_sfx_food_frying
|
||||||
|
- amzn_sfx_footsteps
|
||||||
|
- amzn_sfx_footsteps_muffled
|
||||||
|
- amzn_sfx_ghost_spooky
|
||||||
|
- amzn_sfx_glass_on_table
|
||||||
|
- amzn_sfx_glasses_clink
|
||||||
|
- amzn_sfx_horse_gallop_4x
|
||||||
|
- amzn_sfx_horse_huff_whinny
|
||||||
|
- amzn_sfx_horse_neigh
|
||||||
|
- amzn_sfx_horse_neigh_low
|
||||||
|
- amzn_sfx_horse_whinny
|
||||||
|
- amzn_sfx_human_walking
|
||||||
|
- amzn_sfx_jar_on_table_1x
|
||||||
|
- amzn_sfx_kitchen_ambience
|
||||||
|
- amzn_sfx_large_crowd_cheer
|
||||||
|
- amzn_sfx_large_fire_crackling
|
||||||
|
- amzn_sfx_laughter
|
||||||
|
- amzn_sfx_laughter_giggle
|
||||||
|
- amzn_sfx_lightning_strike
|
||||||
|
- amzn_sfx_lion_roar
|
||||||
|
- amzn_sfx_magic_blast_1x
|
||||||
|
- amzn_sfx_monkey_calls_3x
|
||||||
|
- amzn_sfx_monkey_chimp
|
||||||
|
- amzn_sfx_monkeys_chatter
|
||||||
|
- amzn_sfx_motorcycle_accelerate
|
||||||
|
- amzn_sfx_motorcycle_engine_idle
|
||||||
|
- amzn_sfx_motorcycle_engine_rev
|
||||||
|
- amzn_sfx_musical_drone_intro
|
||||||
|
- amzn_sfx_oars_splashing_rowboat
|
||||||
|
- amzn_sfx_object_on_table_2x
|
||||||
|
- amzn_sfx_ocean_wave_1x
|
||||||
|
- amzn_sfx_ocean_wave_on_rocks_1x
|
||||||
|
- amzn_sfx_ocean_wave_surf
|
||||||
|
- amzn_sfx_people_walking
|
||||||
|
- amzn_sfx_person_running
|
||||||
|
- amzn_sfx_piano_note_1x
|
||||||
|
- amzn_sfx_punch
|
||||||
|
- amzn_sfx_rain
|
||||||
|
- amzn_sfx_rain_on_roof
|
||||||
|
- amzn_sfx_rain_thunder
|
||||||
|
- amzn_sfx_rat_squeak_2x
|
||||||
|
- amzn_sfx_rat_squeaks
|
||||||
|
- amzn_sfx_raven_caw_1x
|
||||||
|
- amzn_sfx_raven_caw_2x
|
||||||
|
- amzn_sfx_restaurant_ambience
|
||||||
|
- amzn_sfx_rooster_crow
|
||||||
|
- amzn_sfx_scifi_air_escaping
|
||||||
|
- amzn_sfx_scifi_alarm
|
||||||
|
- amzn_sfx_scifi_alien_voice
|
||||||
|
- amzn_sfx_scifi_boots_walking
|
||||||
|
- amzn_sfx_scifi_close_large_explosion
|
||||||
|
- amzn_sfx_scifi_door_open
|
||||||
|
- amzn_sfx_scifi_engines_on
|
||||||
|
- amzn_sfx_scifi_engines_on_large
|
||||||
|
- amzn_sfx_scifi_engines_on_short_burst
|
||||||
|
- amzn_sfx_scifi_explosion
|
||||||
|
- amzn_sfx_scifi_explosion_2x
|
||||||
|
- amzn_sfx_scifi_incoming_explosion
|
||||||
|
- amzn_sfx_scifi_laser_gun_battle
|
||||||
|
- amzn_sfx_scifi_laser_gun_fires
|
||||||
|
- amzn_sfx_scifi_laser_gun_fires_large
|
||||||
|
- amzn_sfx_scifi_long_explosion_1x
|
||||||
|
- amzn_sfx_scifi_missile
|
||||||
|
- amzn_sfx_scifi_motor_short_1x
|
||||||
|
- amzn_sfx_scifi_open_airlock
|
||||||
|
- amzn_sfx_scifi_radar_high_ping
|
||||||
|
- amzn_sfx_scifi_radar_low
|
||||||
|
- amzn_sfx_scifi_radar_medium
|
||||||
|
- amzn_sfx_scifi_run_away
|
||||||
|
- amzn_sfx_scifi_sheilds_up
|
||||||
|
- amzn_sfx_scifi_short_low_explosion
|
||||||
|
- amzn_sfx_scifi_small_whoosh_flyby
|
||||||
|
- amzn_sfx_scifi_small_zoom_flyby
|
||||||
|
- amzn_sfx_scifi_sonar_ping_3x
|
||||||
|
- amzn_sfx_scifi_sonar_ping_4x
|
||||||
|
- amzn_sfx_scifi_spaceship_flyby
|
||||||
|
- amzn_sfx_scifi_timer_beep
|
||||||
|
- amzn_sfx_scifi_zap_backwards
|
||||||
|
- amzn_sfx_scifi_zap_electric
|
||||||
|
- amzn_sfx_sheep_baa
|
||||||
|
- amzn_sfx_sheep_bleat
|
||||||
|
- amzn_sfx_silverware_clank
|
||||||
|
- amzn_sfx_sirens
|
||||||
|
- amzn_sfx_sleigh_bells
|
||||||
|
- amzn_sfx_small_stream
|
||||||
|
- amzn_sfx_sneeze
|
||||||
|
- amzn_sfx_stream
|
||||||
|
- amzn_sfx_strong_wind_desert
|
||||||
|
- amzn_sfx_strong_wind_whistling
|
||||||
|
- amzn_sfx_subway_leaving
|
||||||
|
- amzn_sfx_subway_passing
|
||||||
|
- amzn_sfx_subway_stopping
|
||||||
|
- amzn_sfx_swoosh_cartoon_fast
|
||||||
|
- amzn_sfx_swoosh_fast_1x
|
||||||
|
- amzn_sfx_swoosh_fast_6x
|
||||||
|
- amzn_sfx_test_tone
|
||||||
|
- amzn_sfx_thunder_rumble
|
||||||
|
- amzn_sfx_toilet_flush
|
||||||
|
- amzn_sfx_trumpet_bugle
|
||||||
|
- amzn_sfx_turkey_gobbling
|
||||||
|
- amzn_sfx_typing_medium
|
||||||
|
- amzn_sfx_typing_short
|
||||||
|
- amzn_sfx_typing_typewriter
|
||||||
|
- amzn_sfx_vacuum_off
|
||||||
|
- amzn_sfx_vacuum_on
|
||||||
|
- amzn_sfx_walking_in_mud
|
||||||
|
- amzn_sfx_walking_in_snow
|
||||||
|
- amzn_sfx_walking_on_grass
|
||||||
|
- amzn_sfx_water_dripping
|
||||||
|
- amzn_sfx_water_droplets
|
||||||
|
- amzn_sfx_wind_strong_gusting
|
||||||
|
- amzn_sfx_wind_whistling_desert
|
||||||
|
- amzn_sfx_wings_flap_4x
|
||||||
|
- amzn_sfx_wings_flap_fast
|
||||||
|
- amzn_sfx_wolf_howl
|
||||||
|
- amzn_sfx_wolf_young_howl
|
||||||
|
- amzn_sfx_wooden_door
|
||||||
|
- amzn_sfx_wooden_door_creaks_long
|
||||||
|
- amzn_sfx_wooden_door_creaks_multiple
|
||||||
|
- amzn_sfx_wooden_door_creaks_open
|
||||||
|
- amzn_ui_sfx_gameshow_bridge
|
||||||
|
- amzn_ui_sfx_gameshow_countdown_loop_32s_full
|
||||||
|
- amzn_ui_sfx_gameshow_countdown_loop_64s_full
|
||||||
|
- amzn_ui_sfx_gameshow_countdown_loop_64s_minimal
|
||||||
|
- amzn_ui_sfx_gameshow_intro
|
||||||
|
- amzn_ui_sfx_gameshow_negative_response
|
||||||
|
- amzn_ui_sfx_gameshow_neutral_response
|
||||||
|
- amzn_ui_sfx_gameshow_outro
|
||||||
|
- amzn_ui_sfx_gameshow_player1
|
||||||
|
- amzn_ui_sfx_gameshow_player2
|
||||||
|
- amzn_ui_sfx_gameshow_player3
|
||||||
|
- amzn_ui_sfx_gameshow_player4
|
||||||
|
- amzn_ui_sfx_gameshow_positive_response
|
||||||
|
- amzn_ui_sfx_gameshow_tally_negative
|
||||||
|
- amzn_ui_sfx_gameshow_tally_positive
|
||||||
|
- amzn_ui_sfx_gameshow_waiting_loop_30s
|
||||||
|
- anchor
|
||||||
|
- answering_machines
|
||||||
|
- arcs_sparks
|
||||||
|
- arrows_bows
|
||||||
|
- baby
|
||||||
|
- back_up_beeps
|
||||||
|
- bars_restaurants
|
||||||
|
- baseball
|
||||||
|
- basketball
|
||||||
|
- battles
|
||||||
|
- beeps_tones
|
||||||
|
- bell
|
||||||
|
- bikes
|
||||||
|
- billiards
|
||||||
|
- board_games
|
||||||
|
- body
|
||||||
|
- boing
|
||||||
|
- books
|
||||||
|
- bow_wash
|
||||||
|
- box
|
||||||
|
- break_shatter_smash
|
||||||
|
- breaks
|
||||||
|
- brooms_mops
|
||||||
|
- bullets
|
||||||
|
- buses
|
||||||
|
- buzz
|
||||||
|
- buzz_hums
|
||||||
|
- buzzers
|
||||||
|
- buzzers_pistols
|
||||||
|
- cables_metal
|
||||||
|
- camera
|
||||||
|
- cannons
|
||||||
|
- car_alarm
|
||||||
|
- car_alarms
|
||||||
|
- car_cell_phones
|
||||||
|
- carnivals_fairs
|
||||||
|
- cars
|
||||||
|
- casino
|
||||||
|
- casinos
|
||||||
|
- cellar
|
||||||
|
- chimes
|
||||||
|
- chimes_bells
|
||||||
|
- chorus
|
||||||
|
- christmas
|
||||||
|
- church_bells
|
||||||
|
- clock
|
||||||
|
- cloth
|
||||||
|
- concrete
|
||||||
|
- construction
|
||||||
|
- construction_factory
|
||||||
|
- crashes
|
||||||
|
- crowds
|
||||||
|
- debris
|
||||||
|
- dining_kitchens
|
||||||
|
- dinosaurs
|
||||||
|
- dripping
|
||||||
|
- drops
|
||||||
|
- electric
|
||||||
|
- electrical
|
||||||
|
- elevator
|
||||||
|
- evolution_monsters
|
||||||
|
- explosions
|
||||||
|
- factory
|
||||||
|
- falls
|
||||||
|
- fax_scanner_copier
|
||||||
|
- feedback_mics
|
||||||
|
- fight
|
||||||
|
- fire
|
||||||
|
- fire_extinguisher
|
||||||
|
- fireballs
|
||||||
|
- fireworks
|
||||||
|
- fishing_pole
|
||||||
|
- flags
|
||||||
|
- football
|
||||||
|
- footsteps
|
||||||
|
- futuristic
|
||||||
|
- futuristic_ship
|
||||||
|
- gameshow
|
||||||
|
- gear
|
||||||
|
- ghosts_demons
|
||||||
|
- giant_monster
|
||||||
|
- glass
|
||||||
|
- glasses_clink
|
||||||
|
- golf
|
||||||
|
- gorilla
|
||||||
|
- grenade_lanucher
|
||||||
|
- griffen
|
||||||
|
- gyms_locker_rooms
|
||||||
|
- handgun_loading
|
||||||
|
- handgun_shot
|
||||||
|
- handle
|
||||||
|
- hands
|
||||||
|
- heartbeats_ekg
|
||||||
|
- helicopter
|
||||||
|
- high_tech
|
||||||
|
- hit_punch_slap
|
||||||
|
- hits
|
||||||
|
- horns
|
||||||
|
- horror
|
||||||
|
- hot_tub_filling_up
|
||||||
|
- human
|
||||||
|
- human_vocals
|
||||||
|
- hygene # codespell:ignore
|
||||||
|
- ice_skating
|
||||||
|
- ignitions
|
||||||
|
- infantry
|
||||||
|
- intro
|
||||||
|
- jet
|
||||||
|
- juggling
|
||||||
|
- key_lock
|
||||||
|
- kids
|
||||||
|
- knocks
|
||||||
|
- lab_equip
|
||||||
|
- lacrosse
|
||||||
|
- lamps_lanterns
|
||||||
|
- leather
|
||||||
|
- liquid_suction
|
||||||
|
- locker_doors
|
||||||
|
- machine_gun
|
||||||
|
- magic_spells
|
||||||
|
- medium_large_explosions
|
||||||
|
- metal
|
||||||
|
- modern_rings
|
||||||
|
- money_coins
|
||||||
|
- motorcycles
|
||||||
|
- movement
|
||||||
|
- moves
|
||||||
|
- nature
|
||||||
|
- oar_boat
|
||||||
|
- pagers
|
||||||
|
- paintball
|
||||||
|
- paper
|
||||||
|
- parachute
|
||||||
|
- pay_phones
|
||||||
|
- phone_beeps
|
||||||
|
- pigmy_bats
|
||||||
|
- pills
|
||||||
|
- pour_water
|
||||||
|
- power_up_down
|
||||||
|
- printers
|
||||||
|
- prison
|
||||||
|
- public_space
|
||||||
|
- racquetball
|
||||||
|
- radios_static
|
||||||
|
- rain
|
||||||
|
- rc_airplane
|
||||||
|
- rc_car
|
||||||
|
- refrigerators_freezers
|
||||||
|
- regular
|
||||||
|
- respirator
|
||||||
|
- rifle
|
||||||
|
- roller_coaster
|
||||||
|
- rollerskates_rollerblades
|
||||||
|
- room_tones
|
||||||
|
- ropes_climbing
|
||||||
|
- rotary_rings
|
||||||
|
- rowboat_canoe
|
||||||
|
- rubber
|
||||||
|
- running
|
||||||
|
- sails
|
||||||
|
- sand_gravel
|
||||||
|
- screen_doors
|
||||||
|
- screens
|
||||||
|
- seats_stools
|
||||||
|
- servos
|
||||||
|
- shoes_boots
|
||||||
|
- shotgun
|
||||||
|
- shower
|
||||||
|
- sink_faucet
|
||||||
|
- sink_filling_water
|
||||||
|
- sink_run_and_off
|
||||||
|
- sink_water_splatter
|
||||||
|
- sirens
|
||||||
|
- skateboards
|
||||||
|
- ski
|
||||||
|
- skids_tires
|
||||||
|
- sled
|
||||||
|
- slides
|
||||||
|
- small_explosions
|
||||||
|
- snow
|
||||||
|
- snowmobile
|
||||||
|
- soldiers
|
||||||
|
- splash_water
|
||||||
|
- splashes_sprays
|
||||||
|
- sports_whistles
|
||||||
|
- squeaks
|
||||||
|
- squeaky
|
||||||
|
- stairs
|
||||||
|
- steam
|
||||||
|
- submarine_diesel
|
||||||
|
- swing_doors
|
||||||
|
- switches_levers
|
||||||
|
- swords
|
||||||
|
- tape
|
||||||
|
- tape_machine
|
||||||
|
- televisions_shows
|
||||||
|
- tennis_pingpong
|
||||||
|
- textile
|
||||||
|
- throw
|
||||||
|
- thunder
|
||||||
|
- ticks
|
||||||
|
- timer
|
||||||
|
- toilet_flush
|
||||||
|
- tone
|
||||||
|
- tones_noises
|
||||||
|
- toys
|
||||||
|
- tractors
|
||||||
|
- traffic
|
||||||
|
- train
|
||||||
|
- trucks_vans
|
||||||
|
- turnstiles
|
||||||
|
- typing
|
||||||
|
- umbrella
|
||||||
|
- underwater
|
||||||
|
- vampires
|
||||||
|
- various
|
||||||
|
- video_tunes
|
||||||
|
- volcano_earthquake
|
||||||
|
- watches
|
||||||
|
- water
|
||||||
|
- water_running
|
||||||
|
- werewolves
|
||||||
|
- winches_gears
|
||||||
|
- wind
|
||||||
|
- wood
|
||||||
|
- wood_boat
|
||||||
|
- woosh
|
||||||
|
- zap
|
||||||
|
- zippers
|
||||||
|
translation_key: sound
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"data_description_country": "The country where your Amazon account is registered.",
|
"data_description_country": "The country where your Amazon account is registered.",
|
||||||
"data_description_username": "The email address of your Amazon account.",
|
"data_description_username": "The email address of your Amazon account.",
|
||||||
"data_description_password": "The password of your Amazon account.",
|
"data_description_password": "The password of your Amazon account.",
|
||||||
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
|
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported.",
|
||||||
|
"device_id_description": "The ID of the device to send the command to."
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"flow_title": "{username}",
|
"flow_title": "{username}",
|
||||||
@@ -84,12 +85,532 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"services": {
|
||||||
|
"send_sound": {
|
||||||
|
"name": "Send sound",
|
||||||
|
"description": "Sends a sound to a device",
|
||||||
|
"fields": {
|
||||||
|
"device_id": {
|
||||||
|
"name": "Device",
|
||||||
|
"description": "[%key:component::alexa_devices::common::device_id_description%]"
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"name": "Alexa Skill sound file",
|
||||||
|
"description": "The sound file to play."
|
||||||
|
},
|
||||||
|
"sound_variant": {
|
||||||
|
"name": "Sound variant",
|
||||||
|
"description": "The variant of the sound to play."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"send_text_command": {
|
||||||
|
"name": "Send text command",
|
||||||
|
"description": "Sends a text command to a device",
|
||||||
|
"fields": {
|
||||||
|
"text_command": {
|
||||||
|
"name": "Alexa text command",
|
||||||
|
"description": "The text command to send."
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "Device",
|
||||||
|
"description": "[%key:component::alexa_devices::common::device_id_description%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"sound": {
|
||||||
|
"options": {
|
||||||
|
"air_horn": "Air Horn",
|
||||||
|
"air_horns": "Air Horns",
|
||||||
|
"airboat": "Airboat",
|
||||||
|
"airport": "Airport",
|
||||||
|
"aliens": "Aliens",
|
||||||
|
"amzn_sfx_airplane_takeoff_whoosh": "Airplane Takeoff Whoosh",
|
||||||
|
"amzn_sfx_army_march_clank_7x": "Army March Clank 7x",
|
||||||
|
"amzn_sfx_army_march_large_8x": "Army March Large 8x",
|
||||||
|
"amzn_sfx_army_march_small_8x": "Army March Small 8x",
|
||||||
|
"amzn_sfx_baby_big_cry": "Baby Big Cry",
|
||||||
|
"amzn_sfx_baby_cry": "Baby Cry",
|
||||||
|
"amzn_sfx_baby_fuss": "Baby Fuss",
|
||||||
|
"amzn_sfx_battle_group_clanks": "Battle Group Clanks",
|
||||||
|
"amzn_sfx_battle_man_grunts": "Battle Man Grunts",
|
||||||
|
"amzn_sfx_battle_men_grunts": "Battle Men Grunts",
|
||||||
|
"amzn_sfx_battle_men_horses": "Battle Men Horses",
|
||||||
|
"amzn_sfx_battle_noisy_clanks": "Battle Noisy Clanks",
|
||||||
|
"amzn_sfx_battle_yells_men": "Battle Yells Men",
|
||||||
|
"amzn_sfx_battle_yells_men_run": "Battle Yells Men Run",
|
||||||
|
"amzn_sfx_bear_groan_roar": "Bear Groan Roar",
|
||||||
|
"amzn_sfx_bear_roar_grumble": "Bear Roar Grumble",
|
||||||
|
"amzn_sfx_bear_roar_small": "Bear Roar Small",
|
||||||
|
"amzn_sfx_beep_1x": "Beep 1x",
|
||||||
|
"amzn_sfx_bell_med_chime": "Bell Med Chime",
|
||||||
|
"amzn_sfx_bell_short_chime": "Bell Short Chime",
|
||||||
|
"amzn_sfx_bell_timer": "Bell Timer",
|
||||||
|
"amzn_sfx_bicycle_bell_ring": "Bicycle Bell Ring",
|
||||||
|
"amzn_sfx_bird_chickadee_chirp_1x": "Bird Chickadee Chirp 1x",
|
||||||
|
"amzn_sfx_bird_chickadee_chirps": "Bird Chickadee Chirps",
|
||||||
|
"amzn_sfx_bird_forest": "Bird Forest",
|
||||||
|
"amzn_sfx_bird_forest_short": "Bird Forest Short",
|
||||||
|
"amzn_sfx_bird_robin_chirp_1x": "Bird Robin Chirp 1x",
|
||||||
|
"amzn_sfx_boing_long_1x": "Boing Long 1x",
|
||||||
|
"amzn_sfx_boing_med_1x": "Boing Med 1x",
|
||||||
|
"amzn_sfx_boing_short_1x": "Boing Short 1x",
|
||||||
|
"amzn_sfx_bus_drive_past": "Bus Drive Past",
|
||||||
|
"amzn_sfx_buzz_electronic": "Buzz Electronic",
|
||||||
|
"amzn_sfx_buzzer_loud_alarm": "Buzzer Loud Alarm",
|
||||||
|
"amzn_sfx_buzzer_small": "Buzzer Small",
|
||||||
|
"amzn_sfx_car_accelerate": "Car Accelerate",
|
||||||
|
"amzn_sfx_car_accelerate_noisy": "Car Accelerate Noisy",
|
||||||
|
"amzn_sfx_car_click_seatbelt": "Car Click Seatbelt",
|
||||||
|
"amzn_sfx_car_close_door_1x": "Car Close Door 1x",
|
||||||
|
"amzn_sfx_car_drive_past": "Car Drive Past",
|
||||||
|
"amzn_sfx_car_honk_1x": "Car Honk 1x",
|
||||||
|
"amzn_sfx_car_honk_2x": "Car Honk 2x",
|
||||||
|
"amzn_sfx_car_honk_3x": "Car Honk 3x",
|
||||||
|
"amzn_sfx_car_honk_long_1x": "Car Honk Long 1x",
|
||||||
|
"amzn_sfx_car_into_driveway": "Car Into Driveway",
|
||||||
|
"amzn_sfx_car_into_driveway_fast": "Car Into Driveway Fast",
|
||||||
|
"amzn_sfx_car_slam_door_1x": "Car Slam Door 1x",
|
||||||
|
"amzn_sfx_car_undo_seatbelt": "Car Undo Seatbelt",
|
||||||
|
"amzn_sfx_cat_angry_meow_1x": "Cat Angry Meow 1x",
|
||||||
|
"amzn_sfx_cat_angry_screech_1x": "Cat Angry Screech 1x",
|
||||||
|
"amzn_sfx_cat_long_meow_1x": "Cat Long Meow 1x",
|
||||||
|
"amzn_sfx_cat_meow_1x": "Cat Meow 1x",
|
||||||
|
"amzn_sfx_cat_purr": "Cat Purr",
|
||||||
|
"amzn_sfx_cat_purr_meow": "Cat Purr Meow",
|
||||||
|
"amzn_sfx_chicken_cluck": "Chicken Cluck",
|
||||||
|
"amzn_sfx_church_bell_1x": "Church Bell 1x",
|
||||||
|
"amzn_sfx_church_bells_ringing": "Church Bells Ringing",
|
||||||
|
"amzn_sfx_clear_throat_ahem": "Clear Throat Ahem",
|
||||||
|
"amzn_sfx_clock_ticking": "Clock Ticking",
|
||||||
|
"amzn_sfx_clock_ticking_long": "Clock Ticking Long",
|
||||||
|
"amzn_sfx_copy_machine": "Copy Machine",
|
||||||
|
"amzn_sfx_cough": "Cough",
|
||||||
|
"amzn_sfx_crow_caw_1x": "Crow Caw 1x",
|
||||||
|
"amzn_sfx_crowd_applause": "Crowd Applause",
|
||||||
|
"amzn_sfx_crowd_bar": "Crowd Bar",
|
||||||
|
"amzn_sfx_crowd_bar_rowdy": "Crowd Bar Rowdy",
|
||||||
|
"amzn_sfx_crowd_boo": "Crowd Boo",
|
||||||
|
"amzn_sfx_crowd_cheer_med": "Crowd Cheer Med",
|
||||||
|
"amzn_sfx_crowd_excited_cheer": "Crowd Excited Cheer",
|
||||||
|
"amzn_sfx_dog_med_bark_1x": "Dog Med Bark 1x",
|
||||||
|
"amzn_sfx_dog_med_bark_2x": "Dog Med Bark 2x",
|
||||||
|
"amzn_sfx_dog_med_bark_growl": "Dog Med Bark Growl",
|
||||||
|
"amzn_sfx_dog_med_growl_1x": "Dog Med Growl 1x",
|
||||||
|
"amzn_sfx_dog_med_woof_1x": "Dog Med Woof 1x",
|
||||||
|
"amzn_sfx_dog_small_bark_2x": "Dog Small Bark 2x",
|
||||||
|
"amzn_sfx_door_open": "Door Open",
|
||||||
|
"amzn_sfx_door_shut": "Door Shut",
|
||||||
|
"amzn_sfx_doorbell": "Doorbell",
|
||||||
|
"amzn_sfx_doorbell_buzz": "Doorbell Buzz",
|
||||||
|
"amzn_sfx_doorbell_chime": "Doorbell Chime",
|
||||||
|
"amzn_sfx_drinking_slurp": "Drinking Slurp",
|
||||||
|
"amzn_sfx_drum_and_cymbal": "Drum And Cymbal",
|
||||||
|
"amzn_sfx_drum_comedy": "Drum Comedy",
|
||||||
|
"amzn_sfx_earthquake_rumble": "Earthquake Rumble",
|
||||||
|
"amzn_sfx_electric_guitar": "Electric Guitar",
|
||||||
|
"amzn_sfx_electronic_beep": "Electronic Beep",
|
||||||
|
"amzn_sfx_electronic_major_chord": "Electronic Major Chord",
|
||||||
|
"amzn_sfx_elephant": "Elephant",
|
||||||
|
"amzn_sfx_elevator_bell_1x": "Elevator Bell 1x",
|
||||||
|
"amzn_sfx_elevator_open_bell": "Elevator Open Bell",
|
||||||
|
"amzn_sfx_fairy_melodic_chimes": "Fairy Melodic Chimes",
|
||||||
|
"amzn_sfx_fairy_sparkle_chimes": "Fairy Sparkle Chimes",
|
||||||
|
"amzn_sfx_faucet_drip": "Faucet Drip",
|
||||||
|
"amzn_sfx_faucet_running": "Faucet Running",
|
||||||
|
"amzn_sfx_fireplace_crackle": "Fireplace Crackle",
|
||||||
|
"amzn_sfx_fireworks": "Fireworks",
|
||||||
|
"amzn_sfx_fireworks_firecrackers": "Fireworks Firecrackers",
|
||||||
|
"amzn_sfx_fireworks_launch": "Fireworks Launch",
|
||||||
|
"amzn_sfx_fireworks_whistles": "Fireworks Whistles",
|
||||||
|
"amzn_sfx_food_frying": "Food Frying",
|
||||||
|
"amzn_sfx_footsteps": "Footsteps",
|
||||||
|
"amzn_sfx_footsteps_muffled": "Footsteps Muffled",
|
||||||
|
"amzn_sfx_ghost_spooky": "Ghost Spooky",
|
||||||
|
"amzn_sfx_glass_on_table": "Glass On Table",
|
||||||
|
"amzn_sfx_glasses_clink": "Glasses Clink",
|
||||||
|
"amzn_sfx_horse_gallop_4x": "Horse Gallop 4x",
|
||||||
|
"amzn_sfx_horse_huff_whinny": "Horse Huff Whinny",
|
||||||
|
"amzn_sfx_horse_neigh": "Horse Neigh",
|
||||||
|
"amzn_sfx_horse_neigh_low": "Horse Neigh Low",
|
||||||
|
"amzn_sfx_horse_whinny": "Horse Whinny",
|
||||||
|
"amzn_sfx_human_walking": "Human Walking",
|
||||||
|
"amzn_sfx_jar_on_table_1x": "Jar On Table 1x",
|
||||||
|
"amzn_sfx_kitchen_ambience": "Kitchen Ambience",
|
||||||
|
"amzn_sfx_large_crowd_cheer": "Large Crowd Cheer",
|
||||||
|
"amzn_sfx_large_fire_crackling": "Large Fire Crackling",
|
||||||
|
"amzn_sfx_laughter": "Laughter",
|
||||||
|
"amzn_sfx_laughter_giggle": "Laughter Giggle",
|
||||||
|
"amzn_sfx_lightning_strike": "Lightning Strike",
|
||||||
|
"amzn_sfx_lion_roar": "Lion Roar",
|
||||||
|
"amzn_sfx_magic_blast_1x": "Magic Blast 1x",
|
||||||
|
"amzn_sfx_monkey_calls_3x": "Monkey Calls 3x",
|
||||||
|
"amzn_sfx_monkey_chimp": "Monkey Chimp",
|
||||||
|
"amzn_sfx_monkeys_chatter": "Monkeys Chatter",
|
||||||
|
"amzn_sfx_motorcycle_accelerate": "Motorcycle Accelerate",
|
||||||
|
"amzn_sfx_motorcycle_engine_idle": "Motorcycle Engine Idle",
|
||||||
|
"amzn_sfx_motorcycle_engine_rev": "Motorcycle Engine Rev",
|
||||||
|
"amzn_sfx_musical_drone_intro": "Musical Drone Intro",
|
||||||
|
"amzn_sfx_oars_splashing_rowboat": "Oars Splashing Rowboat",
|
||||||
|
"amzn_sfx_object_on_table_2x": "Object On Table 2x",
|
||||||
|
"amzn_sfx_ocean_wave_1x": "Ocean Wave 1x",
|
||||||
|
"amzn_sfx_ocean_wave_on_rocks_1x": "Ocean Wave On Rocks 1x",
|
||||||
|
"amzn_sfx_ocean_wave_surf": "Ocean Wave Surf",
|
||||||
|
"amzn_sfx_people_walking": "People Walking",
|
||||||
|
"amzn_sfx_person_running": "Person Running",
|
||||||
|
"amzn_sfx_piano_note_1x": "Piano Note 1x",
|
||||||
|
"amzn_sfx_punch": "Punch",
|
||||||
|
"amzn_sfx_rain": "Rain",
|
||||||
|
"amzn_sfx_rain_on_roof": "Rain On Roof",
|
||||||
|
"amzn_sfx_rain_thunder": "Rain Thunder",
|
||||||
|
"amzn_sfx_rat_squeak_2x": "Rat Squeak 2x",
|
||||||
|
"amzn_sfx_rat_squeaks": "Rat Squeaks",
|
||||||
|
"amzn_sfx_raven_caw_1x": "Raven Caw 1x",
|
||||||
|
"amzn_sfx_raven_caw_2x": "Raven Caw 2x",
|
||||||
|
"amzn_sfx_restaurant_ambience": "Restaurant Ambience",
|
||||||
|
"amzn_sfx_rooster_crow": "Rooster Crow",
|
||||||
|
"amzn_sfx_scifi_air_escaping": "Scifi Air Escaping",
|
||||||
|
"amzn_sfx_scifi_alarm": "Scifi Alarm",
|
||||||
|
"amzn_sfx_scifi_alien_voice": "Scifi Alien Voice",
|
||||||
|
"amzn_sfx_scifi_boots_walking": "Scifi Boots Walking",
|
||||||
|
"amzn_sfx_scifi_close_large_explosion": "Scifi Close Large Explosion",
|
||||||
|
"amzn_sfx_scifi_door_open": "Scifi Door Open",
|
||||||
|
"amzn_sfx_scifi_engines_on": "Scifi Engines On",
|
||||||
|
"amzn_sfx_scifi_engines_on_large": "Scifi Engines On Large",
|
||||||
|
"amzn_sfx_scifi_engines_on_short_burst": "Scifi Engines On Short Burst",
|
||||||
|
"amzn_sfx_scifi_explosion": "Scifi Explosion",
|
||||||
|
"amzn_sfx_scifi_explosion_2x": "Scifi Explosion 2x",
|
||||||
|
"amzn_sfx_scifi_incoming_explosion": "Scifi Incoming Explosion",
|
||||||
|
"amzn_sfx_scifi_laser_gun_battle": "Scifi Laser Gun Battle",
|
||||||
|
"amzn_sfx_scifi_laser_gun_fires": "Scifi Laser Gun Fires",
|
||||||
|
"amzn_sfx_scifi_laser_gun_fires_large": "Scifi Laser Gun Fires Large",
|
||||||
|
"amzn_sfx_scifi_long_explosion_1x": "Scifi Long Explosion 1x",
|
||||||
|
"amzn_sfx_scifi_missile": "Scifi Missile",
|
||||||
|
"amzn_sfx_scifi_motor_short_1x": "Scifi Motor Short 1x",
|
||||||
|
"amzn_sfx_scifi_open_airlock": "Scifi Open Airlock",
|
||||||
|
"amzn_sfx_scifi_radar_high_ping": "Scifi Radar High Ping",
|
||||||
|
"amzn_sfx_scifi_radar_low": "Scifi Radar Low",
|
||||||
|
"amzn_sfx_scifi_radar_medium": "Scifi Radar Medium",
|
||||||
|
"amzn_sfx_scifi_run_away": "Scifi Run Away",
|
||||||
|
"amzn_sfx_scifi_sheilds_up": "Scifi Sheilds Up",
|
||||||
|
"amzn_sfx_scifi_short_low_explosion": "Scifi Short Low Explosion",
|
||||||
|
"amzn_sfx_scifi_small_whoosh_flyby": "Scifi Small Whoosh Flyby",
|
||||||
|
"amzn_sfx_scifi_small_zoom_flyby": "Scifi Small Zoom Flyby",
|
||||||
|
"amzn_sfx_scifi_sonar_ping_3x": "Scifi Sonar Ping 3x",
|
||||||
|
"amzn_sfx_scifi_sonar_ping_4x": "Scifi Sonar Ping 4x",
|
||||||
|
"amzn_sfx_scifi_spaceship_flyby": "Scifi Spaceship Flyby",
|
||||||
|
"amzn_sfx_scifi_timer_beep": "Scifi Timer Beep",
|
||||||
|
"amzn_sfx_scifi_zap_backwards": "Scifi Zap Backwards",
|
||||||
|
"amzn_sfx_scifi_zap_electric": "Scifi Zap Electric",
|
||||||
|
"amzn_sfx_sheep_baa": "Sheep Baa",
|
||||||
|
"amzn_sfx_sheep_bleat": "Sheep Bleat",
|
||||||
|
"amzn_sfx_silverware_clank": "Silverware Clank",
|
||||||
|
"amzn_sfx_sirens": "Sirens",
|
||||||
|
"amzn_sfx_sleigh_bells": "Sleigh Bells",
|
||||||
|
"amzn_sfx_small_stream": "Small Stream",
|
||||||
|
"amzn_sfx_sneeze": "Sneeze",
|
||||||
|
"amzn_sfx_stream": "Stream",
|
||||||
|
"amzn_sfx_strong_wind_desert": "Strong Wind Desert",
|
||||||
|
"amzn_sfx_strong_wind_whistling": "Strong Wind Whistling",
|
||||||
|
"amzn_sfx_subway_leaving": "Subway Leaving",
|
||||||
|
"amzn_sfx_subway_passing": "Subway Passing",
|
||||||
|
"amzn_sfx_subway_stopping": "Subway Stopping",
|
||||||
|
"amzn_sfx_swoosh_cartoon_fast": "Swoosh Cartoon Fast",
|
||||||
|
"amzn_sfx_swoosh_fast_1x": "Swoosh Fast 1x",
|
||||||
|
"amzn_sfx_swoosh_fast_6x": "Swoosh Fast 6x",
|
||||||
|
"amzn_sfx_test_tone": "Test Tone",
|
||||||
|
"amzn_sfx_thunder_rumble": "Thunder Rumble",
|
||||||
|
"amzn_sfx_toilet_flush": "Toilet Flush",
|
||||||
|
"amzn_sfx_trumpet_bugle": "Trumpet Bugle",
|
||||||
|
"amzn_sfx_turkey_gobbling": "Turkey Gobbling",
|
||||||
|
"amzn_sfx_typing_medium": "Typing Medium",
|
||||||
|
"amzn_sfx_typing_short": "Typing Short",
|
||||||
|
"amzn_sfx_typing_typewriter": "Typing Typewriter",
|
||||||
|
"amzn_sfx_vacuum_off": "Vacuum Off",
|
||||||
|
"amzn_sfx_vacuum_on": "Vacuum On",
|
||||||
|
"amzn_sfx_walking_in_mud": "Walking In Mud",
|
||||||
|
"amzn_sfx_walking_in_snow": "Walking In Snow",
|
||||||
|
"amzn_sfx_walking_on_grass": "Walking On Grass",
|
||||||
|
"amzn_sfx_water_dripping": "Water Dripping",
|
||||||
|
"amzn_sfx_water_droplets": "Water Droplets",
|
||||||
|
"amzn_sfx_wind_strong_gusting": "Wind Strong Gusting",
|
||||||
|
"amzn_sfx_wind_whistling_desert": "Wind Whistling Desert",
|
||||||
|
"amzn_sfx_wings_flap_4x": "Wings Flap 4x",
|
||||||
|
"amzn_sfx_wings_flap_fast": "Wings Flap Fast",
|
||||||
|
"amzn_sfx_wolf_howl": "Wolf Howl",
|
||||||
|
"amzn_sfx_wolf_young_howl": "Wolf Young Howl",
|
||||||
|
"amzn_sfx_wooden_door": "Wooden Door",
|
||||||
|
"amzn_sfx_wooden_door_creaks_long": "Wooden Door Creaks Long",
|
||||||
|
"amzn_sfx_wooden_door_creaks_multiple": "Wooden Door Creaks Multiple",
|
||||||
|
"amzn_sfx_wooden_door_creaks_open": "Wooden Door Creaks Open",
|
||||||
|
"amzn_ui_sfx_gameshow_bridge": "Gameshow Bridge",
|
||||||
|
"amzn_ui_sfx_gameshow_countdown_loop_32s_full": "Gameshow Countdown Loop 32s Full",
|
||||||
|
"amzn_ui_sfx_gameshow_countdown_loop_64s_full": "Gameshow Countdown Loop 64s Full",
|
||||||
|
"amzn_ui_sfx_gameshow_countdown_loop_64s_minimal": "Gameshow Countdown Loop 64s Minimal",
|
||||||
|
"amzn_ui_sfx_gameshow_intro": "Gameshow Intro",
|
||||||
|
"amzn_ui_sfx_gameshow_negative_response": "Gameshow Negative Response",
|
||||||
|
"amzn_ui_sfx_gameshow_neutral_response": "Gameshow Neutral Response",
|
||||||
|
"amzn_ui_sfx_gameshow_outro": "Gameshow Outro",
|
||||||
|
"amzn_ui_sfx_gameshow_player1": "Gameshow Player1",
|
||||||
|
"amzn_ui_sfx_gameshow_player2": "Gameshow Player2",
|
||||||
|
"amzn_ui_sfx_gameshow_player3": "Gameshow Player3",
|
||||||
|
"amzn_ui_sfx_gameshow_player4": "Gameshow Player4",
|
||||||
|
"amzn_ui_sfx_gameshow_positive_response": "Gameshow Positive Response",
|
||||||
|
"amzn_ui_sfx_gameshow_tally_negative": "Gameshow Tally Negative",
|
||||||
|
"amzn_ui_sfx_gameshow_tally_positive": "Gameshow Tally Positive",
|
||||||
|
"amzn_ui_sfx_gameshow_waiting_loop_30s": "Gameshow Waiting Loop 30s",
|
||||||
|
"anchor": "Anchor",
|
||||||
|
"answering_machines": "Answering Machines",
|
||||||
|
"arcs_sparks": "Arcs Sparks",
|
||||||
|
"arrows_bows": "Arrows Bows",
|
||||||
|
"baby": "Baby",
|
||||||
|
"back_up_beeps": "Back Up Beeps",
|
||||||
|
"bars_restaurants": "Bars Restaurants",
|
||||||
|
"baseball": "Baseball",
|
||||||
|
"basketball": "Basketball",
|
||||||
|
"battles": "Battles",
|
||||||
|
"beeps_tones": "Beeps Tones",
|
||||||
|
"bell": "Bell",
|
||||||
|
"bikes": "Bikes",
|
||||||
|
"billiards": "Billiards",
|
||||||
|
"board_games": "Board Games",
|
||||||
|
"body": "Body",
|
||||||
|
"boing": "Boing",
|
||||||
|
"books": "Books",
|
||||||
|
"bow_wash": "Bow Wash",
|
||||||
|
"box": "Box",
|
||||||
|
"break_shatter_smash": "Break Shatter Smash",
|
||||||
|
"breaks": "Breaks",
|
||||||
|
"brooms_mops": "Brooms Mops",
|
||||||
|
"bullets": "Bullets",
|
||||||
|
"buses": "Buses",
|
||||||
|
"buzz": "Buzz",
|
||||||
|
"buzz_hums": "Buzz Hums",
|
||||||
|
"buzzers": "Buzzers",
|
||||||
|
"buzzers_pistols": "Buzzers Pistols",
|
||||||
|
"cables_metal": "Cables Metal",
|
||||||
|
"camera": "Camera",
|
||||||
|
"cannons": "Cannons",
|
||||||
|
"car_alarm": "Car Alarm",
|
||||||
|
"car_alarms": "Car Alarms",
|
||||||
|
"car_cell_phones": "Car Cell Phones",
|
||||||
|
"carnivals_fairs": "Carnivals Fairs",
|
||||||
|
"cars": "Cars",
|
||||||
|
"casino": "Casino",
|
||||||
|
"casinos": "Casinos",
|
||||||
|
"cellar": "Cellar",
|
||||||
|
"chimes": "Chimes",
|
||||||
|
"chimes_bells": "Chimes Bells",
|
||||||
|
"chorus": "Chorus",
|
||||||
|
"christmas": "Christmas",
|
||||||
|
"church_bells": "Church Bells",
|
||||||
|
"clock": "Clock",
|
||||||
|
"cloth": "Cloth",
|
||||||
|
"concrete": "Concrete",
|
||||||
|
"construction": "Construction",
|
||||||
|
"construction_factory": "Construction Factory",
|
||||||
|
"crashes": "Crashes",
|
||||||
|
"crowds": "Crowds",
|
||||||
|
"debris": "Debris",
|
||||||
|
"dining_kitchens": "Dining Kitchens",
|
||||||
|
"dinosaurs": "Dinosaurs",
|
||||||
|
"dripping": "Dripping",
|
||||||
|
"drops": "Drops",
|
||||||
|
"electric": "Electric",
|
||||||
|
"electrical": "Electrical",
|
||||||
|
"elevator": "Elevator",
|
||||||
|
"evolution_monsters": "Evolution Monsters",
|
||||||
|
"explosions": "Explosions",
|
||||||
|
"factory": "Factory",
|
||||||
|
"falls": "Falls",
|
||||||
|
"fax_scanner_copier": "Fax Scanner Copier",
|
||||||
|
"feedback_mics": "Feedback Mics",
|
||||||
|
"fight": "Fight",
|
||||||
|
"fire": "Fire",
|
||||||
|
"fire_extinguisher": "Fire Extinguisher",
|
||||||
|
"fireballs": "Fireballs",
|
||||||
|
"fireworks": "Fireworks",
|
||||||
|
"fishing_pole": "Fishing Pole",
|
||||||
|
"flags": "Flags",
|
||||||
|
"football": "Football",
|
||||||
|
"footsteps": "Footsteps",
|
||||||
|
"futuristic": "Futuristic",
|
||||||
|
"futuristic_ship": "Futuristic Ship",
|
||||||
|
"gameshow": "Gameshow",
|
||||||
|
"gear": "Gear",
|
||||||
|
"ghosts_demons": "Ghosts Demons",
|
||||||
|
"giant_monster": "Giant Monster",
|
||||||
|
"glass": "Glass",
|
||||||
|
"glasses_clink": "Glasses Clink",
|
||||||
|
"golf": "Golf",
|
||||||
|
"gorilla": "Gorilla",
|
||||||
|
"grenade_lanucher": "Grenade Lanucher",
|
||||||
|
"griffen": "Griffen",
|
||||||
|
"gyms_locker_rooms": "Gyms Locker Rooms",
|
||||||
|
"handgun_loading": "Handgun Loading",
|
||||||
|
"handgun_shot": "Handgun Shot",
|
||||||
|
"handle": "Handle",
|
||||||
|
"hands": "Hands",
|
||||||
|
"heartbeats_ekg": "Heartbeats EKG",
|
||||||
|
"helicopter": "Helicopter",
|
||||||
|
"high_tech": "High Tech",
|
||||||
|
"hit_punch_slap": "Hit Punch Slap",
|
||||||
|
"hits": "Hits",
|
||||||
|
"horns": "Horns",
|
||||||
|
"horror": "Horror",
|
||||||
|
"hot_tub_filling_up": "Hot Tub Filling Up",
|
||||||
|
"human": "Human",
|
||||||
|
"human_vocals": "Human Vocals",
|
||||||
|
"hygene": "Hygene",
|
||||||
|
"ice_skating": "Ice Skating",
|
||||||
|
"ignitions": "Ignitions",
|
||||||
|
"infantry": "Infantry",
|
||||||
|
"intro": "Intro",
|
||||||
|
"jet": "Jet",
|
||||||
|
"juggling": "Juggling",
|
||||||
|
"key_lock": "Key Lock",
|
||||||
|
"kids": "Kids",
|
||||||
|
"knocks": "Knocks",
|
||||||
|
"lab_equip": "Lab Equip",
|
||||||
|
"lacrosse": "Lacrosse",
|
||||||
|
"lamps_lanterns": "Lamps Lanterns",
|
||||||
|
"leather": "Leather",
|
||||||
|
"liquid_suction": "Liquid Suction",
|
||||||
|
"locker_doors": "Locker Doors",
|
||||||
|
"machine_gun": "Machine Gun",
|
||||||
|
"magic_spells": "Magic Spells",
|
||||||
|
"medium_large_explosions": "Medium Large Explosions",
|
||||||
|
"metal": "Metal",
|
||||||
|
"modern_rings": "Modern Rings",
|
||||||
|
"money_coins": "Money Coins",
|
||||||
|
"motorcycles": "Motorcycles",
|
||||||
|
"movement": "Movement",
|
||||||
|
"moves": "Moves",
|
||||||
|
"nature": "Nature",
|
||||||
|
"oar_boat": "Oar Boat",
|
||||||
|
"pagers": "Pagers",
|
||||||
|
"paintball": "Paintball",
|
||||||
|
"paper": "Paper",
|
||||||
|
"parachute": "Parachute",
|
||||||
|
"pay_phones": "Pay Phones",
|
||||||
|
"phone_beeps": "Phone Beeps",
|
||||||
|
"pigmy_bats": "Pigmy Bats",
|
||||||
|
"pills": "Pills",
|
||||||
|
"pour_water": "Pour Water",
|
||||||
|
"power_up_down": "Power Up Down",
|
||||||
|
"printers": "Printers",
|
||||||
|
"prison": "Prison",
|
||||||
|
"public_space": "Public Space",
|
||||||
|
"racquetball": "Racquetball",
|
||||||
|
"radios_static": "Radios Static",
|
||||||
|
"rain": "Rain",
|
||||||
|
"rc_airplane": "RC Airplane",
|
||||||
|
"rc_car": "RC Car",
|
||||||
|
"refrigerators_freezers": "Refrigerators Freezers",
|
||||||
|
"regular": "Regular",
|
||||||
|
"respirator": "Respirator",
|
||||||
|
"rifle": "Rifle",
|
||||||
|
"roller_coaster": "Roller Coaster",
|
||||||
|
"rollerskates_rollerblades": "RollerSkates RollerBlades",
|
||||||
|
"room_tones": "Room Tones",
|
||||||
|
"ropes_climbing": "Ropes Climbing",
|
||||||
|
"rotary_rings": "Rotary Rings",
|
||||||
|
"rowboat_canoe": "Rowboat Canoe",
|
||||||
|
"rubber": "Rubber",
|
||||||
|
"running": "Running",
|
||||||
|
"sails": "Sails",
|
||||||
|
"sand_gravel": "Sand Gravel",
|
||||||
|
"screen_doors": "Screen Doors",
|
||||||
|
"screens": "Screens",
|
||||||
|
"seats_stools": "Seats Stools",
|
||||||
|
"servos": "Servos",
|
||||||
|
"shoes_boots": "Shoes Boots",
|
||||||
|
"shotgun": "Shotgun",
|
||||||
|
"shower": "Shower",
|
||||||
|
"sink_faucet": "Sink Faucet",
|
||||||
|
"sink_filling_water": "Sink Filling Water",
|
||||||
|
"sink_run_and_off": "Sink Run And Off",
|
||||||
|
"sink_water_splatter": "Sink Water Splatter",
|
||||||
|
"sirens": "Sirens",
|
||||||
|
"skateboards": "Skateboards",
|
||||||
|
"ski": "Ski",
|
||||||
|
"skids_tires": "Skids Tires",
|
||||||
|
"sled": "Sled",
|
||||||
|
"slides": "Slides",
|
||||||
|
"small_explosions": "Small Explosions",
|
||||||
|
"snow": "Snow",
|
||||||
|
"snowmobile": "Snowmobile",
|
||||||
|
"soldiers": "Soldiers",
|
||||||
|
"splash_water": "Splash Water",
|
||||||
|
"splashes_sprays": "Splashes Sprays",
|
||||||
|
"sports_whistles": "Sports Whistles",
|
||||||
|
"squeaks": "Squeaks",
|
||||||
|
"squeaky": "Squeaky",
|
||||||
|
"stairs": "Stairs",
|
||||||
|
"steam": "Steam",
|
||||||
|
"submarine_diesel": "Submarine Diesel",
|
||||||
|
"swing_doors": "Swing Doors",
|
||||||
|
"switches_levers": "Switches Levers",
|
||||||
|
"swords": "Swords",
|
||||||
|
"tape": "Tape",
|
||||||
|
"tape_machine": "Tape Machine",
|
||||||
|
"televisions_shows": "Televisions Shows",
|
||||||
|
"tennis_pingpong": "Tennis PingPong",
|
||||||
|
"textile": "Textile",
|
||||||
|
"throw": "Throw",
|
||||||
|
"thunder": "Thunder",
|
||||||
|
"ticks": "Ticks",
|
||||||
|
"timer": "Timer",
|
||||||
|
"toilet_flush": "Toilet Flush",
|
||||||
|
"tone": "Tone",
|
||||||
|
"tones_noises": "Tones Noises",
|
||||||
|
"toys": "Toys",
|
||||||
|
"tractors": "Tractors",
|
||||||
|
"traffic": "Traffic",
|
||||||
|
"train": "Train",
|
||||||
|
"trucks_vans": "Trucks Vans",
|
||||||
|
"turnstiles": "Turnstiles",
|
||||||
|
"typing": "Typing",
|
||||||
|
"umbrella": "Umbrella",
|
||||||
|
"underwater": "Underwater",
|
||||||
|
"vampires": "Vampires",
|
||||||
|
"various": "Various",
|
||||||
|
"video_tunes": "Video Tunes",
|
||||||
|
"volcano_earthquake": "Volcano Earthquake",
|
||||||
|
"watches": "Watches",
|
||||||
|
"water": "Water",
|
||||||
|
"water_running": "Water Running",
|
||||||
|
"werewolves": "Werewolves",
|
||||||
|
"winches_gears": "Winches Gears",
|
||||||
|
"wind": "Wind",
|
||||||
|
"wood": "Wood",
|
||||||
|
"wood_boat": "Wood Boat",
|
||||||
|
"woosh": "Woosh",
|
||||||
|
"zap": "Zap",
|
||||||
|
"zippers": "Zippers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
"cannot_connect_with_error": {
|
"cannot_connect_with_error": {
|
||||||
"message": "Error connecting: {error}"
|
"message": "Error connecting: {error}"
|
||||||
},
|
},
|
||||||
"cannot_retrieve_data_with_error": {
|
"cannot_retrieve_data_with_error": {
|
||||||
"message": "Error retrieving data: {error}"
|
"message": "Error retrieving data: {error}"
|
||||||
|
},
|
||||||
|
"device_serial_number_missing": {
|
||||||
|
"message": "Device serial number missing: {device_id}"
|
||||||
|
},
|
||||||
|
"invalid_device_id": {
|
||||||
|
"message": "Invalid device ID specified: {device_id}"
|
||||||
|
},
|
||||||
|
"invalid_sound_value": {
|
||||||
|
"message": "Invalid sound {sound} with variant {variant} specified"
|
||||||
|
},
|
||||||
|
"entry_not_loaded": {
|
||||||
|
"message": "Entry not loaded: {entry}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preset1": {
|
"preset1": {
|
||||||
"name": "Favourite 1",
|
"name": "Favorite 1",
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"event_type": {
|
"event_type": {
|
||||||
"state": {
|
"state": {
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preset2": {
|
"preset2": {
|
||||||
"name": "Favourite 2",
|
"name": "Favorite 2",
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"event_type": {
|
"event_type": {
|
||||||
"state": {
|
"state": {
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preset3": {
|
"preset3": {
|
||||||
"name": "Favourite 3",
|
"name": "Favorite 3",
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"event_type": {
|
"event_type": {
|
||||||
"state": {
|
"state": {
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preset4": {
|
"preset4": {
|
||||||
"name": "Favourite 4",
|
"name": "Favorite 4",
|
||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"event_type": {
|
"event_type": {
|
||||||
"state": {
|
"state": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bluesound",
|
"documentation": "https://www.home-assistant.io/integrations/bluesound",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["pyblu==2.0.1"],
|
"requirements": ["pyblu==2.0.4"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"type": "_musc._tcp.local."
|
"type": "_musc._tcp.local."
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ async def _async_reproduce_states(
|
|||||||
await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state})
|
await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(ATTR_TEMPERATURE in state.attributes)
|
(ATTR_TEMPERATURE in state.attributes and state.attributes[ATTR_TEMPERATURE] is not None)
|
||||||
or (ATTR_TARGET_TEMP_HIGH in state.attributes)
|
or (ATTR_TARGET_TEMP_HIGH in state.attributes and state.attributes[ATTR_TARGET_TEMP_HIGH] is not None)
|
||||||
or (ATTR_TARGET_TEMP_LOW in state.attributes)
|
or (ATTR_TARGET_TEMP_LOW in state.attributes and state.attributes[ATTR_TARGET_TEMP_LOW] is not None)
|
||||||
):
|
):
|
||||||
await call_service(
|
await call_service(
|
||||||
SERVICE_SET_TEMPERATURE,
|
SERVICE_SET_TEMPERATURE,
|
||||||
|
|||||||
@@ -13,6 +13,6 @@
|
|||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||||
"requirements": ["hass-nabucasa==0.108.0"],
|
"requirements": ["hass-nabucasa==0.110.0"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/compensation",
|
"documentation": "https://www.home-assistant.io/integrations/compensation",
|
||||||
"iot_class": "calculated",
|
"iot_class": "calculated",
|
||||||
"quality_scale": "legacy",
|
"quality_scale": "legacy",
|
||||||
"requirements": ["numpy==2.3.0"]
|
"requirements": ["numpy==2.3.2"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.23"]
|
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.7.30"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> b
|
|||||||
prefix = options[CONF_PREFIX]
|
prefix = options[CONF_PREFIX]
|
||||||
sample_rate = options[CONF_RATE]
|
sample_rate = options[CONF_RATE]
|
||||||
|
|
||||||
statsd_client = DogStatsd(host=host, port=port, namespace=prefix)
|
statsd_client = DogStatsd(
|
||||||
|
host=host, port=port, namespace=prefix, disable_telemetry=True
|
||||||
|
)
|
||||||
entry.runtime_data = statsd_client
|
entry.runtime_data = statsd_client
|
||||||
|
|
||||||
initialize(statsd_host=host, statsd_port=port)
|
initialize(statsd_host=host, statsd_port=port)
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ class DatadogConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle user config flow."""
|
"""Handle user config flow."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
if user_input:
|
if user_input:
|
||||||
|
self._async_abort_entries_match(
|
||||||
|
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
|
||||||
|
)
|
||||||
# Validate connection to Datadog Agent
|
# Validate connection to Datadog Agent
|
||||||
success = await validate_datadog_connection(
|
success = await validate_datadog_connection(
|
||||||
self.hass,
|
self.hass,
|
||||||
user_input,
|
user_input,
|
||||||
)
|
)
|
||||||
self._async_abort_entries_match(
|
|
||||||
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
|
|
||||||
)
|
|
||||||
if not success:
|
if not success:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
else:
|
else:
|
||||||
@@ -58,7 +58,6 @@ class DatadogConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_RATE: user_input[CONF_RATE],
|
CONF_RATE: user_input[CONF_RATE],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema(
|
||||||
@@ -107,7 +106,26 @@ class DatadogOptionsFlowHandler(OptionsFlow):
|
|||||||
options = self.config_entry.options
|
options = self.config_entry.options
|
||||||
|
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
user_input = {}
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(
|
||||||
|
CONF_PREFIX,
|
||||||
|
default=options.get(
|
||||||
|
CONF_PREFIX, data.get(CONF_PREFIX, DEFAULT_PREFIX)
|
||||||
|
),
|
||||||
|
): str,
|
||||||
|
vol.Required(
|
||||||
|
CONF_RATE,
|
||||||
|
default=options.get(
|
||||||
|
CONF_RATE, data.get(CONF_RATE, DEFAULT_RATE)
|
||||||
|
),
|
||||||
|
): int,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors={},
|
||||||
|
)
|
||||||
|
|
||||||
success = await validate_datadog_connection(
|
success = await validate_datadog_connection(
|
||||||
self.hass,
|
self.hass,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ DOMAIN = "datadog"
|
|||||||
|
|
||||||
CONF_RATE = "rate"
|
CONF_RATE = "rate"
|
||||||
|
|
||||||
DEFAULT_HOST = "localhost"
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
DEFAULT_PORT = 8125
|
DEFAULT_PORT = 8125
|
||||||
DEFAULT_PREFIX = "hass"
|
DEFAULT_PREFIX = "hass"
|
||||||
DEFAULT_RATE = 1
|
DEFAULT_RATE = 1
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["datadog"],
|
"loggers": ["datadog"],
|
||||||
"quality_scale": "legacy",
|
"quality_scale": "legacy",
|
||||||
"requirements": ["datadog==0.15.0"]
|
"requirements": ["datadog==0.52.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,45 +7,39 @@ from typing import Any
|
|||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||||
SOURCE_REAUTH,
|
|
||||||
ConfigEntry,
|
|
||||||
ConfigFlow,
|
|
||||||
ConfigFlowResult,
|
|
||||||
)
|
|
||||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||||
from homeassistant.core import callback
|
|
||||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
from . import configure_mydevolo
|
from . import configure_mydevolo
|
||||||
from .const import DOMAIN, SUPPORTED_MODEL_TYPES
|
from .const import DOMAIN, SUPPORTED_MODEL_TYPES
|
||||||
from .exceptions import CredentialsInvalid, UuidChanged
|
from .exceptions import CredentialsInvalid, UuidChanged
|
||||||
|
|
||||||
|
DATA_SCHEMA = vol.Schema(
|
||||||
|
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
|
class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a devolo HomeControl config flow."""
|
"""Handle a devolo HomeControl config flow."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
_reauth_entry: ConfigEntry
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Initialize devolo Home Control flow."""
|
|
||||||
self.data_schema = {
|
|
||||||
vol.Required(CONF_USERNAME): str,
|
|
||||||
vol.Required(CONF_PASSWORD): str,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle a flow initiated by the user."""
|
"""Handle a flow initiated by the user."""
|
||||||
if user_input is None:
|
errors: dict[str, str] = {}
|
||||||
return self._show_form(step_id="user")
|
|
||||||
try:
|
if user_input is not None:
|
||||||
return await self._connect_mydevolo(user_input)
|
try:
|
||||||
except CredentialsInvalid:
|
return await self._connect_mydevolo(user_input)
|
||||||
return self._show_form(step_id="user", errors={"base": "invalid_auth"})
|
except CredentialsInvalid:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_zeroconf(
|
async def async_step_zeroconf(
|
||||||
self, discovery_info: ZeroconfServiceInfo
|
self, discovery_info: ZeroconfServiceInfo
|
||||||
@@ -61,42 +55,47 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle a flow initiated by zeroconf."""
|
"""Handle a flow initiated by zeroconf."""
|
||||||
if user_input is None:
|
errors: dict[str, str] = {}
|
||||||
return self._show_form(step_id="zeroconf_confirm")
|
|
||||||
try:
|
if user_input is not None:
|
||||||
return await self._connect_mydevolo(user_input)
|
try:
|
||||||
except CredentialsInvalid:
|
return await self._connect_mydevolo(user_input)
|
||||||
return self._show_form(
|
except CredentialsInvalid:
|
||||||
step_id="zeroconf_confirm", errors={"base": "invalid_auth"}
|
errors["base"] = "invalid_auth"
|
||||||
)
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="zeroconf_confirm", data_schema=DATA_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_reauth(
|
async def async_step_reauth(
|
||||||
self, entry_data: Mapping[str, Any]
|
self, entry_data: Mapping[str, Any]
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle reauthentication."""
|
"""Handle reauthentication."""
|
||||||
self._reauth_entry = self._get_reauth_entry()
|
|
||||||
self.data_schema = {
|
|
||||||
vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str,
|
|
||||||
vol.Required(CONF_PASSWORD): str,
|
|
||||||
}
|
|
||||||
return await self.async_step_reauth_confirm()
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
async def async_step_reauth_confirm(
|
async def async_step_reauth_confirm(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle a flow initiated by reauthentication."""
|
"""Handle a flow initiated by reauthentication."""
|
||||||
if user_input is None:
|
errors: dict[str, str] = {}
|
||||||
return self._show_form(step_id="reauth_confirm")
|
data_schema = vol.Schema(
|
||||||
try:
|
{
|
||||||
return await self._connect_mydevolo(user_input)
|
vol.Required(CONF_USERNAME, default=self.init_data[CONF_USERNAME]): str,
|
||||||
except CredentialsInvalid:
|
vol.Required(CONF_PASSWORD): str,
|
||||||
return self._show_form(
|
}
|
||||||
step_id="reauth_confirm", errors={"base": "invalid_auth"}
|
)
|
||||||
)
|
|
||||||
except UuidChanged:
|
if user_input is not None:
|
||||||
return self._show_form(
|
try:
|
||||||
step_id="reauth_confirm", errors={"base": "reauth_failed"}
|
return await self._connect_mydevolo(user_input)
|
||||||
)
|
except CredentialsInvalid:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except UuidChanged:
|
||||||
|
errors["base"] = "reauth_failed"
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm", data_schema=data_schema, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
async def _connect_mydevolo(self, user_input: dict[str, Any]) -> ConfigFlowResult:
|
||||||
"""Connect to mydevolo."""
|
"""Connect to mydevolo."""
|
||||||
@@ -119,21 +118,11 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._reauth_entry.unique_id != uuid:
|
if self.unique_id != uuid:
|
||||||
# The old user and the new user are not the same. This could mess-up everything as all unique IDs might change.
|
# The old user and the new user are not the same. This could mess-up everything as all unique IDs might change.
|
||||||
raise UuidChanged
|
raise UuidChanged
|
||||||
|
|
||||||
|
reauth_entry = self._get_reauth_entry()
|
||||||
return self.async_update_reload_and_abort(
|
return self.async_update_reload_and_abort(
|
||||||
self._reauth_entry, data=user_input, unique_id=uuid
|
reauth_entry, data=user_input, unique_id=uuid
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _show_form(
|
|
||||||
self, step_id: str, errors: dict[str, str] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Show the form to the user."""
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id=step_id,
|
|
||||||
data_schema=vol.Schema(self.data_schema),
|
|
||||||
errors=errors if errors else {},
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["devolo_plc_api"],
|
"loggers": ["devolo_plc_api"],
|
||||||
|
"quality_scale": "silver",
|
||||||
"requirements": ["devolo-plc-api==1.5.1"],
|
"requirements": ["devolo-plc-api==1.5.1"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
rules:
|
||||||
|
# Bronze
|
||||||
|
action-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
This integration does not provide additional actions.
|
||||||
|
appropriate-polling: done
|
||||||
|
brands: done
|
||||||
|
common-modules: done
|
||||||
|
config-flow-test-coverage: done
|
||||||
|
config-flow: done
|
||||||
|
dependency-transparency: done
|
||||||
|
docs-actions:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
This integration does not provide additional actions.
|
||||||
|
docs-high-level-description: done
|
||||||
|
docs-installation-instructions: done
|
||||||
|
docs-removal-instructions: done
|
||||||
|
entity-event-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
Entities of this integration does not explicitly subscribe to events.
|
||||||
|
entity-unique-id: done
|
||||||
|
has-entity-name: done
|
||||||
|
runtime-data: done
|
||||||
|
test-before-configure: done
|
||||||
|
test-before-setup: done
|
||||||
|
unique-config-entry: done
|
||||||
|
|
||||||
|
# Silver
|
||||||
|
action-exceptions:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
This integration does not provide additional actions.
|
||||||
|
config-entry-unloading: done
|
||||||
|
docs-configuration-parameters:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
This integration does not have an options flow.
|
||||||
|
docs-installation-parameters: done
|
||||||
|
entity-unavailable: done
|
||||||
|
integration-owner: done
|
||||||
|
log-when-unavailable: done
|
||||||
|
parallel-updates: done
|
||||||
|
reauthentication-flow: done
|
||||||
|
test-coverage: done
|
||||||
|
|
||||||
|
# Gold
|
||||||
|
devices: done
|
||||||
|
diagnostics: done
|
||||||
|
discovery-update-info: done
|
||||||
|
discovery: done
|
||||||
|
docs-data-update: done
|
||||||
|
docs-examples: done
|
||||||
|
docs-known-limitations: done
|
||||||
|
docs-supported-devices: done
|
||||||
|
docs-supported-functions: done
|
||||||
|
docs-troubleshooting: done
|
||||||
|
docs-use-cases: done
|
||||||
|
dynamic-devices: done
|
||||||
|
entity-category: done
|
||||||
|
entity-device-class: done
|
||||||
|
entity-disabled-by-default: done
|
||||||
|
entity-translations: done
|
||||||
|
exception-translations: done
|
||||||
|
icon-translations: done
|
||||||
|
reconfiguration-flow:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
A change of the IP address is covered by discovery-update-info and a change of the password is covered by reauthentication-flow. No other configuration options are available.
|
||||||
|
repair-issues:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
This integration doesn't have any cases where raising an issue is needed.
|
||||||
|
stale-devices:
|
||||||
|
status: todo
|
||||||
|
comment: |
|
||||||
|
The tracked devices could be own devices with a manual delete option as the API cannot distinguish between stale devices and devices that are not home.
|
||||||
|
|
||||||
|
# Platinum
|
||||||
|
async-dependency: done
|
||||||
|
inject-websession: done
|
||||||
|
strict-typing: done
|
||||||
@@ -4,10 +4,12 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from deebot_client.capabilities import CapabilityEvent
|
from deebot_client.capabilities import CapabilityEvent
|
||||||
from deebot_client.events.base import Event
|
from deebot_client.events import Event
|
||||||
from deebot_client.events.water_info import MopAttachedEvent
|
from deebot_client.events.water_info import MopAttachedEvent
|
||||||
|
from sucks import VacBot
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
|
BinarySensorDeviceClass,
|
||||||
BinarySensorEntity,
|
BinarySensorEntity,
|
||||||
BinarySensorEntityDescription,
|
BinarySensorEntityDescription,
|
||||||
)
|
)
|
||||||
@@ -16,7 +18,11 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import EcovacsConfigEntry
|
from . import EcovacsConfigEntry
|
||||||
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity
|
from .entity import (
|
||||||
|
EcovacsCapabilityEntityDescription,
|
||||||
|
EcovacsDescriptionEntity,
|
||||||
|
EcovacsLegacyEntity,
|
||||||
|
)
|
||||||
from .util import get_supported_entities
|
from .util import get_supported_entities
|
||||||
|
|
||||||
|
|
||||||
@@ -47,12 +53,23 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add entities for passed config_entry in HA."""
|
"""Add entities for passed config_entry in HA."""
|
||||||
|
controller = config_entry.runtime_data
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
get_supported_entities(
|
get_supported_entities(
|
||||||
config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS
|
config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
legacy_entities = []
|
||||||
|
for device in controller.legacy_devices:
|
||||||
|
if not controller.legacy_entity_is_added(device, "battery_charging"):
|
||||||
|
controller.add_legacy_entity(device, "battery_charging")
|
||||||
|
legacy_entities.append(EcovacsLegacyBatteryChargingSensor(device))
|
||||||
|
|
||||||
|
if legacy_entities:
|
||||||
|
async_add_entities(legacy_entities)
|
||||||
|
|
||||||
|
|
||||||
class EcovacsBinarySensor[EventT: Event](
|
class EcovacsBinarySensor[EventT: Event](
|
||||||
EcovacsDescriptionEntity[CapabilityEvent[EventT]],
|
EcovacsDescriptionEntity[CapabilityEvent[EventT]],
|
||||||
@@ -71,3 +88,33 @@ class EcovacsBinarySensor[EventT: Event](
|
|||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
self._subscribe(self._capability.event, on_event)
|
self._subscribe(self._capability.event, on_event)
|
||||||
|
|
||||||
|
|
||||||
|
class EcovacsLegacyBatteryChargingSensor(EcovacsLegacyEntity, BinarySensorEntity):
|
||||||
|
"""Legacy battery charging sensor."""
|
||||||
|
|
||||||
|
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device: VacBot,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the entity."""
|
||||||
|
super().__init__(device)
|
||||||
|
self._attr_unique_id = f"{device.vacuum['did']}_battery_charging"
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Set up the event listeners now that hass is ready."""
|
||||||
|
self._event_listeners.append(
|
||||||
|
self.device.statusEvents.subscribe(
|
||||||
|
lambda _: self.schedule_update_ha_state()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return true if the binary sensor is on."""
|
||||||
|
if self.device.charge_status is None:
|
||||||
|
return None
|
||||||
|
return bool(self.device.is_charging)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
from homeassistant.helpers.icon import icon_for_battery_level
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
|
|
||||||
from . import EcovacsConfigEntry
|
from . import EcovacsConfigEntry
|
||||||
@@ -225,7 +226,7 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
async def _add_legacy_entities() -> None:
|
async def _add_legacy_lifespan_entities() -> None:
|
||||||
entities = []
|
entities = []
|
||||||
for device in controller.legacy_devices:
|
for device in controller.legacy_devices:
|
||||||
for description in LEGACY_LIFESPAN_SENSORS:
|
for description in LEGACY_LIFESPAN_SENSORS:
|
||||||
@@ -242,14 +243,21 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
def _fire_ecovacs_legacy_lifespan_event(_: Any) -> None:
|
def _fire_ecovacs_legacy_lifespan_event(_: Any) -> None:
|
||||||
hass.create_task(_add_legacy_entities())
|
hass.create_task(_add_legacy_lifespan_entities())
|
||||||
|
|
||||||
|
legacy_entities = []
|
||||||
for device in controller.legacy_devices:
|
for device in controller.legacy_devices:
|
||||||
config_entry.async_on_unload(
|
config_entry.async_on_unload(
|
||||||
device.lifespanEvents.subscribe(
|
device.lifespanEvents.subscribe(
|
||||||
_fire_ecovacs_legacy_lifespan_event
|
_fire_ecovacs_legacy_lifespan_event
|
||||||
).unsubscribe
|
).unsubscribe
|
||||||
)
|
)
|
||||||
|
if not controller.legacy_entity_is_added(device, "battery_status"):
|
||||||
|
controller.add_legacy_entity(device, "battery_status")
|
||||||
|
legacy_entities.append(EcovacsLegacyBatterySensor(device))
|
||||||
|
|
||||||
|
if legacy_entities:
|
||||||
|
async_add_entities(legacy_entities)
|
||||||
|
|
||||||
|
|
||||||
class EcovacsSensor(
|
class EcovacsSensor(
|
||||||
@@ -344,6 +352,44 @@ class EcovacsErrorSensor(
|
|||||||
self._subscribe(self._capability.event, on_event)
|
self._subscribe(self._capability.event, on_event)
|
||||||
|
|
||||||
|
|
||||||
|
class EcovacsLegacyBatterySensor(EcovacsLegacyEntity, SensorEntity):
|
||||||
|
"""Legacy battery sensor."""
|
||||||
|
|
||||||
|
_attr_native_unit_of_measurement = PERCENTAGE
|
||||||
|
_attr_device_class = SensorDeviceClass.BATTERY
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device: VacBot,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the entity."""
|
||||||
|
super().__init__(device)
|
||||||
|
self._attr_unique_id = f"{device.vacuum['did']}_battery_status"
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Set up the event listeners now that hass is ready."""
|
||||||
|
self._event_listeners.append(
|
||||||
|
self.device.batteryEvents.subscribe(
|
||||||
|
lambda _: self.schedule_update_ha_state()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> StateType:
|
||||||
|
"""Return the value reported by the sensor."""
|
||||||
|
if (status := self.device.battery_status) is not None:
|
||||||
|
return status * 100 # type: ignore[no-any-return]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str | None:
|
||||||
|
"""Return the icon to use in the frontend, if any."""
|
||||||
|
return icon_for_battery_level(
|
||||||
|
battery_level=self.native_value, charging=self.device.is_charging
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EcovacsLegacyLifespanSensor(EcovacsLegacyEntity, SensorEntity):
|
class EcovacsLegacyLifespanSensor(EcovacsLegacyEntity, SensorEntity):
|
||||||
"""Legacy Lifespan sensor."""
|
"""Legacy Lifespan sensor."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from deebot_client.capabilities import Capabilities, DeviceType
|
from deebot_client.capabilities import Capabilities, DeviceType
|
||||||
from deebot_client.device import Device
|
from deebot_client.device import Device
|
||||||
from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent
|
from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent
|
||||||
from deebot_client.models import CleanAction, CleanMode, Room, State
|
from deebot_client.models import CleanAction, CleanMode, Room, State
|
||||||
import sucks
|
import sucks
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant, SupportsResponse
|
|||||||
from homeassistant.exceptions import ServiceValidationError
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers import entity_platform
|
from homeassistant.helpers import entity_platform
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.icon import icon_for_battery_level
|
|
||||||
from homeassistant.util import slugify
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
from . import EcovacsConfigEntry
|
from . import EcovacsConfigEntry
|
||||||
@@ -71,8 +70,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
|
|||||||
|
|
||||||
_attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH]
|
_attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH]
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
VacuumEntityFeature.BATTERY
|
VacuumEntityFeature.RETURN_HOME
|
||||||
| VacuumEntityFeature.RETURN_HOME
|
|
||||||
| VacuumEntityFeature.CLEAN_SPOT
|
| VacuumEntityFeature.CLEAN_SPOT
|
||||||
| VacuumEntityFeature.STOP
|
| VacuumEntityFeature.STOP
|
||||||
| VacuumEntityFeature.START
|
| VacuumEntityFeature.START
|
||||||
@@ -89,11 +87,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
|
|||||||
lambda _: self.schedule_update_ha_state()
|
lambda _: self.schedule_update_ha_state()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._event_listeners.append(
|
|
||||||
self.device.batteryEvents.subscribe(
|
|
||||||
lambda _: self.schedule_update_ha_state()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._event_listeners.append(
|
self._event_listeners.append(
|
||||||
self.device.lifespanEvents.subscribe(
|
self.device.lifespanEvents.subscribe(
|
||||||
lambda _: self.schedule_update_ha_state()
|
lambda _: self.schedule_update_ha_state()
|
||||||
@@ -137,21 +130,6 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
|
||||||
def battery_level(self) -> int | None:
|
|
||||||
"""Return the battery level of the vacuum cleaner."""
|
|
||||||
if self.device.battery_status is not None:
|
|
||||||
return self.device.battery_status * 100 # type: ignore[no-any-return]
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def battery_icon(self) -> str:
|
|
||||||
"""Return the battery icon for the vacuum cleaner."""
|
|
||||||
return icon_for_battery_level(
|
|
||||||
battery_level=self.battery_level, charging=self.device.is_charging
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def fan_speed(self) -> str | None:
|
def fan_speed(self) -> str | None:
|
||||||
"""Return the fan speed of the vacuum cleaner."""
|
"""Return the fan speed of the vacuum cleaner."""
|
||||||
@@ -238,7 +216,6 @@ class EcovacsVacuum(
|
|||||||
VacuumEntityFeature.PAUSE
|
VacuumEntityFeature.PAUSE
|
||||||
| VacuumEntityFeature.STOP
|
| VacuumEntityFeature.STOP
|
||||||
| VacuumEntityFeature.RETURN_HOME
|
| VacuumEntityFeature.RETURN_HOME
|
||||||
| VacuumEntityFeature.BATTERY
|
|
||||||
| VacuumEntityFeature.SEND_COMMAND
|
| VacuumEntityFeature.SEND_COMMAND
|
||||||
| VacuumEntityFeature.LOCATE
|
| VacuumEntityFeature.LOCATE
|
||||||
| VacuumEntityFeature.STATE
|
| VacuumEntityFeature.STATE
|
||||||
@@ -265,10 +242,6 @@ class EcovacsVacuum(
|
|||||||
"""Set up the event listeners now that hass is ready."""
|
"""Set up the event listeners now that hass is ready."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
async def on_battery(event: BatteryEvent) -> None:
|
|
||||||
self._attr_battery_level = event.value
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
async def on_rooms(event: RoomsEvent) -> None:
|
async def on_rooms(event: RoomsEvent) -> None:
|
||||||
self._rooms = event.rooms
|
self._rooms = event.rooms
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
@@ -277,7 +250,6 @@ class EcovacsVacuum(
|
|||||||
self._attr_activity = _STATE_TO_VACUUM_STATE[event.state]
|
self._attr_activity = _STATE_TO_VACUUM_STATE[event.state]
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
self._subscribe(self._capability.battery.event, on_battery)
|
|
||||||
self._subscribe(self._capability.state.event, on_status)
|
self._subscribe(self._capability.state.event, on_status)
|
||||||
|
|
||||||
if self._capability.fan_speed:
|
if self._capability.fan_speed:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["pyenphase"],
|
"loggers": ["pyenphase"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["pyenphase==2.2.2"],
|
"requirements": ["pyenphase==2.2.3"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"type": "_enphase-envoy._tcp.local."
|
"type": "_enphase-envoy._tcp.local."
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
|
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
|
||||||
|
from .encryption_key_storage import async_get_encryption_key_storage
|
||||||
from .entry_data import ESPHomeConfigEntry
|
from .entry_data import ESPHomeConfigEntry
|
||||||
from .manager import async_replace_device
|
from .manager import async_replace_device
|
||||||
|
|
||||||
@@ -159,7 +160,10 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle reauthorization flow."""
|
"""Handle reauthorization flow."""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if await self._retrieve_encryption_key_from_dashboard():
|
if (
|
||||||
|
await self._retrieve_encryption_key_from_storage()
|
||||||
|
or await self._retrieve_encryption_key_from_dashboard()
|
||||||
|
):
|
||||||
error = await self.fetch_device_info()
|
error = await self.fetch_device_info()
|
||||||
if error is None:
|
if error is None:
|
||||||
return await self._async_authenticate_or_add()
|
return await self._async_authenticate_or_add()
|
||||||
@@ -226,9 +230,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
response = await self.fetch_device_info()
|
response = await self.fetch_device_info()
|
||||||
self._noise_psk = None
|
self._noise_psk = None
|
||||||
|
|
||||||
|
# Try to retrieve an existing key from dashboard or storage.
|
||||||
if (
|
if (
|
||||||
self._device_name
|
self._device_name
|
||||||
and await self._retrieve_encryption_key_from_dashboard()
|
and await self._retrieve_encryption_key_from_dashboard()
|
||||||
|
) or (
|
||||||
|
self._device_mac and await self._retrieve_encryption_key_from_storage()
|
||||||
):
|
):
|
||||||
response = await self.fetch_device_info()
|
response = await self.fetch_device_info()
|
||||||
|
|
||||||
@@ -284,6 +291,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
self._name = discovery_info.properties.get("friendly_name", device_name)
|
self._name = discovery_info.properties.get("friendly_name", device_name)
|
||||||
self._host = discovery_info.host
|
self._host = discovery_info.host
|
||||||
self._port = discovery_info.port
|
self._port = discovery_info.port
|
||||||
|
self._device_mac = mac_address
|
||||||
self._noise_required = bool(discovery_info.properties.get("api_encryption"))
|
self._noise_required = bool(discovery_info.properties.get("api_encryption"))
|
||||||
|
|
||||||
# Check if already configured
|
# Check if already configured
|
||||||
@@ -772,6 +780,26 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
self._noise_psk = noise_psk
|
self._noise_psk = noise_psk
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _retrieve_encryption_key_from_storage(self) -> bool:
|
||||||
|
"""Try to retrieve the encryption key from storage.
|
||||||
|
|
||||||
|
Return boolean if a key was retrieved.
|
||||||
|
"""
|
||||||
|
# Try to get MAC address from current flow state or reauth entry
|
||||||
|
mac_address = self._device_mac
|
||||||
|
if mac_address is None and self._reauth_entry is not None:
|
||||||
|
# In reauth flow, get MAC from the existing entry's unique_id
|
||||||
|
mac_address = self._reauth_entry.unique_id
|
||||||
|
|
||||||
|
assert mac_address is not None
|
||||||
|
|
||||||
|
storage = await async_get_encryption_key_storage(self.hass)
|
||||||
|
if stored_key := await storage.async_get_key(mac_address):
|
||||||
|
self._noise_psk = stored_key
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(
|
||||||
|
|||||||
94
homeassistant/components/esphome/encryption_key_storage.py
Normal file
94
homeassistant/components/esphome/encryption_key_storage.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Encryption key storage for ESPHome devices."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.json import JSONEncoder
|
||||||
|
from homeassistant.helpers.singleton import singleton
|
||||||
|
from homeassistant.helpers.storage import Store
|
||||||
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ENCRYPTION_KEY_STORAGE_VERSION = 1
|
||||||
|
ENCRYPTION_KEY_STORAGE_KEY = "esphome.encryption_keys"
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptionKeyData(TypedDict):
|
||||||
|
"""Encryption key storage data."""
|
||||||
|
|
||||||
|
keys: dict[str, str] # MAC address -> base64 encoded key
|
||||||
|
|
||||||
|
|
||||||
|
KEY_ENCRYPTION_STORAGE: HassKey[ESPHomeEncryptionKeyStorage] = HassKey(
|
||||||
|
"esphome_encryption_key_storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ESPHomeEncryptionKeyStorage:
|
||||||
|
"""Storage for ESPHome encryption keys."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
|
"""Initialize the encryption key storage."""
|
||||||
|
self.hass = hass
|
||||||
|
self._store = Store[EncryptionKeyData](
|
||||||
|
hass,
|
||||||
|
ENCRYPTION_KEY_STORAGE_VERSION,
|
||||||
|
ENCRYPTION_KEY_STORAGE_KEY,
|
||||||
|
encoder=JSONEncoder,
|
||||||
|
)
|
||||||
|
self._data: EncryptionKeyData | None = None
|
||||||
|
|
||||||
|
async def async_load(self) -> None:
|
||||||
|
"""Load encryption keys from storage."""
|
||||||
|
if self._data is None:
|
||||||
|
data = await self._store.async_load()
|
||||||
|
self._data = data or {"keys": {}}
|
||||||
|
|
||||||
|
async def async_save(self) -> None:
|
||||||
|
"""Save encryption keys to storage."""
|
||||||
|
if self._data is not None:
|
||||||
|
await self._store.async_save(self._data)
|
||||||
|
|
||||||
|
async def async_get_key(self, mac_address: str) -> str | None:
|
||||||
|
"""Get encryption key for a MAC address."""
|
||||||
|
await self.async_load()
|
||||||
|
assert self._data is not None
|
||||||
|
return self._data["keys"].get(mac_address.lower())
|
||||||
|
|
||||||
|
async def async_store_key(self, mac_address: str, key: str) -> None:
|
||||||
|
"""Store encryption key for a MAC address."""
|
||||||
|
await self.async_load()
|
||||||
|
assert self._data is not None
|
||||||
|
self._data["keys"][mac_address.lower()] = key
|
||||||
|
await self.async_save()
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Stored encryption key for device with MAC %s",
|
||||||
|
mac_address,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_remove_key(self, mac_address: str) -> None:
|
||||||
|
"""Remove encryption key for a MAC address."""
|
||||||
|
await self.async_load()
|
||||||
|
assert self._data is not None
|
||||||
|
lower_mac_address = mac_address.lower()
|
||||||
|
if lower_mac_address in self._data["keys"]:
|
||||||
|
del self._data["keys"][lower_mac_address]
|
||||||
|
await self.async_save()
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Removed encryption key for device with MAC %s",
|
||||||
|
mac_address,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@singleton(KEY_ENCRYPTION_STORAGE, async_=True)
|
||||||
|
async def async_get_encryption_key_storage(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
) -> ESPHomeEncryptionKeyStorage:
|
||||||
|
"""Get the encryption key storage instance."""
|
||||||
|
storage = ESPHomeEncryptionKeyStorage(hass)
|
||||||
|
await storage.async_load()
|
||||||
|
return storage
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||||
|
|
||||||
from aioesphomeapi import (
|
from aioesphomeapi import (
|
||||||
@@ -68,6 +70,7 @@ from .const import (
|
|||||||
CONF_ALLOW_SERVICE_CALLS,
|
CONF_ALLOW_SERVICE_CALLS,
|
||||||
CONF_BLUETOOTH_MAC_ADDRESS,
|
CONF_BLUETOOTH_MAC_ADDRESS,
|
||||||
CONF_DEVICE_NAME,
|
CONF_DEVICE_NAME,
|
||||||
|
CONF_NOISE_PSK,
|
||||||
CONF_SUBSCRIBE_LOGS,
|
CONF_SUBSCRIBE_LOGS,
|
||||||
DEFAULT_ALLOW_SERVICE_CALLS,
|
DEFAULT_ALLOW_SERVICE_CALLS,
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
@@ -78,6 +81,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .dashboard import async_get_dashboard
|
from .dashboard import async_get_dashboard
|
||||||
from .domain_data import DomainData
|
from .domain_data import DomainData
|
||||||
|
from .encryption_key_storage import async_get_encryption_key_storage
|
||||||
|
|
||||||
# Import config flow so that it's added to the registry
|
# Import config flow so that it's added to the registry
|
||||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||||
@@ -85,9 +89,7 @@ from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
|||||||
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
|
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001
|
||||||
SubscribeLogsResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -515,6 +517,8 @@ class ESPHomeManager:
|
|||||||
assert api_version is not None, "API version must be set"
|
assert api_version is not None, "API version must be set"
|
||||||
entry_data.async_on_connect(device_info, api_version)
|
entry_data.async_on_connect(device_info, api_version)
|
||||||
|
|
||||||
|
await self._handle_dynamic_encryption_key(device_info)
|
||||||
|
|
||||||
if device_info.name:
|
if device_info.name:
|
||||||
reconnect_logic.name = device_info.name
|
reconnect_logic.name = device_info.name
|
||||||
|
|
||||||
@@ -618,6 +622,7 @@ class ESPHomeManager:
|
|||||||
),
|
),
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(err, InvalidEncryptionKeyAPIError):
|
if isinstance(err, InvalidEncryptionKeyAPIError):
|
||||||
if (
|
if (
|
||||||
(received_name := err.received_name)
|
(received_name := err.received_name)
|
||||||
@@ -648,6 +653,93 @@ class ESPHomeManager:
|
|||||||
return
|
return
|
||||||
self.entry.async_start_reauth(self.hass)
|
self.entry.async_start_reauth(self.hass)
|
||||||
|
|
||||||
|
async def _handle_dynamic_encryption_key(
|
||||||
|
self, device_info: EsphomeDeviceInfo
|
||||||
|
) -> None:
|
||||||
|
"""Handle dynamic encryption keys.
|
||||||
|
|
||||||
|
If a device reports it supports encryption, but we connected without a key,
|
||||||
|
we need to generate and store one.
|
||||||
|
"""
|
||||||
|
noise_psk: str | None = self.entry.data.get(CONF_NOISE_PSK)
|
||||||
|
if noise_psk:
|
||||||
|
# we're already connected with a noise PSK - nothing to do
|
||||||
|
return
|
||||||
|
|
||||||
|
if not device_info.api_encryption_supported:
|
||||||
|
# device does not support encryption - nothing to do
|
||||||
|
return
|
||||||
|
|
||||||
|
# Connected to device without key and the device supports encryption
|
||||||
|
storage = await async_get_encryption_key_storage(self.hass)
|
||||||
|
|
||||||
|
# First check if we have a key in storage for this device
|
||||||
|
from_storage: bool = False
|
||||||
|
if self.entry.unique_id and (
|
||||||
|
stored_key := await storage.async_get_key(self.entry.unique_id)
|
||||||
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Retrieved encryption key from storage for device %s",
|
||||||
|
self.entry.unique_id,
|
||||||
|
)
|
||||||
|
# Use the stored key
|
||||||
|
new_key = stored_key.encode()
|
||||||
|
new_key_str = stored_key
|
||||||
|
from_storage = True
|
||||||
|
else:
|
||||||
|
# No stored key found, generate a new one
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Generating new encryption key for device %s", self.entry.unique_id
|
||||||
|
)
|
||||||
|
new_key = base64.b64encode(secrets.token_bytes(32))
|
||||||
|
new_key_str = new_key.decode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Store the key on the device using the existing connection
|
||||||
|
result = await self.cli.noise_encryption_set_key(new_key)
|
||||||
|
except APIConnectionError as ex:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Connection error while storing encryption key for device %s (%s): %s",
|
||||||
|
self.entry.data.get(CONF_DEVICE_NAME, self.host),
|
||||||
|
self.entry.unique_id,
|
||||||
|
ex,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
if not result:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to set dynamic encryption key on device %s (%s)",
|
||||||
|
self.entry.data.get(CONF_DEVICE_NAME, self.host),
|
||||||
|
self.entry.unique_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Key stored successfully on device
|
||||||
|
assert self.entry.unique_id is not None
|
||||||
|
|
||||||
|
# Only store in storage if it was newly generated
|
||||||
|
if not from_storage:
|
||||||
|
await storage.async_store_key(self.entry.unique_id, new_key_str)
|
||||||
|
|
||||||
|
# Always update config entry
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self.entry,
|
||||||
|
data={**self.entry.data, CONF_NOISE_PSK: new_key_str},
|
||||||
|
)
|
||||||
|
|
||||||
|
if from_storage:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Set encryption key from storage on device %s (%s)",
|
||||||
|
self.entry.data.get(CONF_DEVICE_NAME, self.host),
|
||||||
|
self.entry.unique_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Generated and stored encryption key for device %s (%s)",
|
||||||
|
self.entry.data.get(CONF_DEVICE_NAME, self.host),
|
||||||
|
self.entry.unique_id,
|
||||||
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_handle_logging_changed(self, _event: Event) -> None:
|
def _async_handle_logging_changed(self, _event: Event) -> None:
|
||||||
"""Handle when the logging level changes."""
|
"""Handle when the logging level changes."""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"mqtt": ["esphome/discover/#"],
|
"mqtt": ["esphome/discover/#"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aioesphomeapi==37.0.2",
|
"aioesphomeapi==37.1.5",
|
||||||
"esphome-dashboard-api==1.3.0",
|
"esphome-dashboard-api==1.3.0",
|
||||||
"bleak-esphome==3.1.0"
|
"bleak-esphome==3.1.0"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20250702.3"]
|
"requirements": ["home-assistant-frontend==20250730.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/garages_amsterdam",
|
"documentation": "https://www.home-assistant.io/integrations/garages_amsterdam",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["odp-amsterdam==6.1.1"]
|
"requirements": ["odp-amsterdam==6.1.2"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"confirm_discovery": {
|
"confirm_discovery": {
|
||||||
"description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual."
|
"description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new IP address. Refer to your router's user manual."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ async def async_setup_entry(
|
|||||||
calendar_info = calendars[calendar_id]
|
calendar_info = calendars[calendar_id]
|
||||||
else:
|
else:
|
||||||
calendar_info = get_calendar_info(
|
calendar_info = get_calendar_info(
|
||||||
hass, calendar_item.dict(exclude_unset=True)
|
hass, calendar_item.model_dump(exclude_unset=True)
|
||||||
)
|
)
|
||||||
new_calendars.append(calendar_info)
|
new_calendars.append(calendar_info)
|
||||||
|
|
||||||
@@ -467,7 +467,7 @@ class GoogleCalendarEntity(
|
|||||||
else:
|
else:
|
||||||
start = DateOrDatetime(date=dtstart)
|
start = DateOrDatetime(date=dtstart)
|
||||||
end = DateOrDatetime(date=dtend)
|
end = DateOrDatetime(date=dtend)
|
||||||
event = Event.parse_obj(
|
event = Event.model_validate(
|
||||||
{
|
{
|
||||||
EVENT_SUMMARY: kwargs[EVENT_SUMMARY],
|
EVENT_SUMMARY: kwargs[EVENT_SUMMARY],
|
||||||
"start": start,
|
"start": start,
|
||||||
@@ -538,7 +538,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) ->
|
|||||||
|
|
||||||
if EVENT_IN in call.data:
|
if EVENT_IN in call.data:
|
||||||
if EVENT_IN_DAYS in call.data[EVENT_IN]:
|
if EVENT_IN_DAYS in call.data[EVENT_IN]:
|
||||||
now = datetime.now()
|
now = datetime.now().date()
|
||||||
|
|
||||||
start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS])
|
start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS])
|
||||||
end_in = start_in + timedelta(days=1)
|
end_in = start_in + timedelta(days=1)
|
||||||
@@ -547,7 +547,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) ->
|
|||||||
end = DateOrDatetime(date=end_in)
|
end = DateOrDatetime(date=end_in)
|
||||||
|
|
||||||
elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
|
elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
|
||||||
now = datetime.now()
|
now = datetime.now().date()
|
||||||
|
|
||||||
start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
|
start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
|
||||||
end_in = start_in + timedelta(days=1)
|
end_in = start_in + timedelta(days=1)
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["googleapiclient"],
|
"loggers": ["googleapiclient"],
|
||||||
"requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"]
|
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ async def basic_group_options_schema(
|
|||||||
entity_selector: selector.Selector[Any] | vol.Schema
|
entity_selector: selector.Selector[Any] | vol.Schema
|
||||||
if handler is None:
|
if handler is None:
|
||||||
entity_selector = selector.selector(
|
entity_selector = selector.selector(
|
||||||
{"entity": {"domain": domain, "multiple": True}}
|
{"entity": {"domain": domain, "multiple": True, "reorder": True}}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
entity_selector = entity_selector_without_own_entities(
|
entity_selector = entity_selector_without_own_entities(
|
||||||
cast(SchemaOptionsFlowHandler, handler.parent_handler),
|
cast(SchemaOptionsFlowHandler, handler.parent_handler),
|
||||||
selector.EntitySelectorConfig(domain=domain, multiple=True),
|
selector.EntitySelectorConfig(domain=domain, multiple=True, reorder=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
return vol.Schema(
|
return vol.Schema(
|
||||||
@@ -78,7 +78,9 @@ def basic_group_config_schema(domain: str | list[str]) -> vol.Schema:
|
|||||||
{
|
{
|
||||||
vol.Required("name"): selector.TextSelector(),
|
vol.Required("name"): selector.TextSelector(),
|
||||||
vol.Required(CONF_ENTITIES): selector.EntitySelector(
|
vol.Required(CONF_ENTITIES): selector.EntitySelector(
|
||||||
selector.EntitySelectorConfig(domain=domain, multiple=True),
|
selector.EntitySelectorConfig(
|
||||||
|
domain=domain, multiple=True, reorder=True
|
||||||
|
),
|
||||||
),
|
),
|
||||||
vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(),
|
vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(),
|
||||||
}
|
}
|
||||||
@@ -139,9 +141,7 @@ async def light_switch_options_schema(
|
|||||||
"""Generate options schema."""
|
"""Generate options schema."""
|
||||||
return (await basic_group_options_schema(domain, handler)).extend(
|
return (await basic_group_options_schema(domain, handler)).extend(
|
||||||
{
|
{
|
||||||
vol.Required(
|
vol.Required(CONF_ALL, default=False): selector.BooleanSelector(),
|
||||||
CONF_ALL, default=False, description={"advanced": True}
|
|
||||||
): selector.BooleanSelector(),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,14 @@
|
|||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"title": "[%key:component::group::config::step::user::title%]",
|
"title": "[%key:component::group::config::step::user::title%]",
|
||||||
"description": "If \"all entities\" is enabled, the group's state is on only if all members are on. If \"all entities\" is disabled, the group's state is on if any member is on.",
|
|
||||||
"data": {
|
"data": {
|
||||||
"all": "All entities",
|
"all": "All entities",
|
||||||
"entities": "Members",
|
"entities": "Members",
|
||||||
"hide_members": "Hide members",
|
"hide_members": "Hide members",
|
||||||
"name": "[%key:common::config_flow::data::name%]"
|
"name": "[%key:common::config_flow::data::name%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"all": "If enabled, the group's state is on only if all members are on. If disabled, the group's state is on if any member is on."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
@@ -105,6 +107,9 @@
|
|||||||
"device_class": "Device class",
|
"device_class": "Device class",
|
||||||
"state_class": "State class",
|
"state_class": "State class",
|
||||||
"unit_of_measurement": "Unit of measurement"
|
"unit_of_measurement": "Unit of measurement"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"ignore_non_numeric": "If enabled, the group's state is calculated if at least one member has a numerical value. If disabled, the group's state is calculated only if all group members have numerical values."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
@@ -120,11 +125,13 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"description": "[%key:component::group::config::step::binary_sensor::description%]",
|
|
||||||
"data": {
|
"data": {
|
||||||
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
|
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
|
||||||
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
||||||
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
@@ -146,11 +153,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"light": {
|
"light": {
|
||||||
"description": "[%key:component::group::config::step::binary_sensor::description%]",
|
|
||||||
"data": {
|
"data": {
|
||||||
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
|
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
|
||||||
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
||||||
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lock": {
|
"lock": {
|
||||||
@@ -172,7 +181,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sensor": {
|
"sensor": {
|
||||||
"description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.",
|
|
||||||
"data": {
|
"data": {
|
||||||
"ignore_non_numeric": "[%key:component::group::config::step::sensor::data::ignore_non_numeric%]",
|
"ignore_non_numeric": "[%key:component::group::config::step::sensor::data::ignore_non_numeric%]",
|
||||||
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
||||||
@@ -182,14 +190,19 @@
|
|||||||
"device_class": "[%key:component::group::config::step::sensor::data::device_class%]",
|
"device_class": "[%key:component::group::config::step::sensor::data::device_class%]",
|
||||||
"state_class": "[%key:component::group::config::step::sensor::data::state_class%]",
|
"state_class": "[%key:component::group::config::step::sensor::data::state_class%]",
|
||||||
"unit_of_measurement": "[%key:component::group::config::step::sensor::data::unit_of_measurement%]"
|
"unit_of_measurement": "[%key:component::group::config::step::sensor::data::unit_of_measurement%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"ignore_non_numeric": "[%key:component::group::config::step::sensor::data_description::ignore_non_numeric%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"switch": {
|
"switch": {
|
||||||
"description": "[%key:component::group::config::step::binary_sensor::description%]",
|
|
||||||
"data": {
|
"data": {
|
||||||
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
|
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
|
||||||
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
|
||||||
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,7 @@ from dataclasses import dataclass
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import ClientError
|
from habiticalib import Habitica, HabiticaClass, Skill, TaskType
|
||||||
from habiticalib import (
|
|
||||||
HabiticaClass,
|
|
||||||
HabiticaException,
|
|
||||||
NotAuthorizedError,
|
|
||||||
Skill,
|
|
||||||
TaskType,
|
|
||||||
TooManyRequestsError,
|
|
||||||
)
|
|
||||||
|
|
||||||
from homeassistant.components.button import (
|
from homeassistant.components.button import (
|
||||||
DOMAIN as BUTTON_DOMAIN,
|
DOMAIN as BUTTON_DOMAIN,
|
||||||
@@ -23,16 +15,11 @@ from homeassistant.components.button import (
|
|||||||
ButtonEntityDescription,
|
ButtonEntityDescription,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .const import ASSETS_URL, DOMAIN
|
from .const import ASSETS_URL, DOMAIN
|
||||||
from .coordinator import (
|
from .coordinator import HabiticaConfigEntry, HabiticaData
|
||||||
HabiticaConfigEntry,
|
|
||||||
HabiticaData,
|
|
||||||
HabiticaDataUpdateCoordinator,
|
|
||||||
)
|
|
||||||
from .entity import HabiticaBase
|
from .entity import HabiticaBase
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
@@ -42,7 +29,7 @@ PARALLEL_UPDATES = 1
|
|||||||
class HabiticaButtonEntityDescription(ButtonEntityDescription):
|
class HabiticaButtonEntityDescription(ButtonEntityDescription):
|
||||||
"""Describes Habitica button entity."""
|
"""Describes Habitica button entity."""
|
||||||
|
|
||||||
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
|
press_fn: Callable[[Habitica], Any]
|
||||||
available_fn: Callable[[HabiticaData], bool]
|
available_fn: Callable[[HabiticaData], bool]
|
||||||
class_needed: HabiticaClass | None = None
|
class_needed: HabiticaClass | None = None
|
||||||
entity_picture: str | None = None
|
entity_picture: str | None = None
|
||||||
@@ -73,13 +60,13 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.RUN_CRON,
|
key=HabiticaButtonEntity.RUN_CRON,
|
||||||
translation_key=HabiticaButtonEntity.RUN_CRON,
|
translation_key=HabiticaButtonEntity.RUN_CRON,
|
||||||
press_fn=lambda coordinator: coordinator.habitica.run_cron(),
|
press_fn=lambda habitica: habitica.run_cron(),
|
||||||
available_fn=lambda data: data.user.needsCron is True,
|
available_fn=lambda data: data.user.needsCron is True,
|
||||||
),
|
),
|
||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.BUY_HEALTH_POTION,
|
key=HabiticaButtonEntity.BUY_HEALTH_POTION,
|
||||||
translation_key=HabiticaButtonEntity.BUY_HEALTH_POTION,
|
translation_key=HabiticaButtonEntity.BUY_HEALTH_POTION,
|
||||||
press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(),
|
press_fn=lambda habitica: habitica.buy_health_potion(),
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.gp or 0) >= 25
|
lambda data: (data.user.stats.gp or 0) >= 25
|
||||||
and (data.user.stats.hp or 0) < 50
|
and (data.user.stats.hp or 0) < 50
|
||||||
@@ -89,7 +76,7 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
||||||
translation_key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
translation_key=HabiticaButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
||||||
press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(),
|
press_fn=lambda habitica: habitica.allocate_stat_points(),
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: data.user.preferences.automaticAllocation is True
|
lambda data: data.user.preferences.automaticAllocation is True
|
||||||
and (data.user.stats.points or 0) > 0
|
and (data.user.stats.points or 0) > 0
|
||||||
@@ -98,7 +85,7 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.REVIVE,
|
key=HabiticaButtonEntity.REVIVE,
|
||||||
translation_key=HabiticaButtonEntity.REVIVE,
|
translation_key=HabiticaButtonEntity.REVIVE,
|
||||||
press_fn=lambda coordinator: coordinator.habitica.revive(),
|
press_fn=lambda habitica: habitica.revive(),
|
||||||
available_fn=lambda data: data.user.stats.hp == 0,
|
available_fn=lambda data: data.user.stats.hp == 0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -108,9 +95,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.MPHEAL,
|
key=HabiticaButtonEntity.MPHEAL,
|
||||||
translation_key=HabiticaButtonEntity.MPHEAL,
|
translation_key=HabiticaButtonEntity.MPHEAL,
|
||||||
press_fn=(
|
press_fn=lambda habitica: habitica.cast_skill(Skill.ETHEREAL_SURGE),
|
||||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE)
|
|
||||||
),
|
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 12
|
lambda data: (data.user.stats.lvl or 0) >= 12
|
||||||
and (data.user.stats.mp or 0) >= 30
|
and (data.user.stats.mp or 0) >= 30
|
||||||
@@ -121,7 +106,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.EARTH,
|
key=HabiticaButtonEntity.EARTH,
|
||||||
translation_key=HabiticaButtonEntity.EARTH,
|
translation_key=HabiticaButtonEntity.EARTH,
|
||||||
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE),
|
press_fn=lambda habitica: habitica.cast_skill(Skill.EARTHQUAKE),
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||||
and (data.user.stats.mp or 0) >= 35
|
and (data.user.stats.mp or 0) >= 35
|
||||||
@@ -132,9 +117,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.FROST,
|
key=HabiticaButtonEntity.FROST,
|
||||||
translation_key=HabiticaButtonEntity.FROST,
|
translation_key=HabiticaButtonEntity.FROST,
|
||||||
press_fn=(
|
press_fn=lambda habitica: habitica.cast_skill(Skill.CHILLING_FROST),
|
||||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST)
|
|
||||||
),
|
|
||||||
# chilling frost can only be cast once per day (streaks buff is false)
|
# chilling frost can only be cast once per day (streaks buff is false)
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||||
@@ -147,9 +130,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.DEFENSIVE_STANCE,
|
key=HabiticaButtonEntity.DEFENSIVE_STANCE,
|
||||||
translation_key=HabiticaButtonEntity.DEFENSIVE_STANCE,
|
translation_key=HabiticaButtonEntity.DEFENSIVE_STANCE,
|
||||||
press_fn=(
|
press_fn=lambda habitica: habitica.cast_skill(Skill.DEFENSIVE_STANCE),
|
||||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE)
|
|
||||||
),
|
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 12
|
lambda data: (data.user.stats.lvl or 0) >= 12
|
||||||
and (data.user.stats.mp or 0) >= 25
|
and (data.user.stats.mp or 0) >= 25
|
||||||
@@ -160,9 +141,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.VALOROUS_PRESENCE,
|
key=HabiticaButtonEntity.VALOROUS_PRESENCE,
|
||||||
translation_key=HabiticaButtonEntity.VALOROUS_PRESENCE,
|
translation_key=HabiticaButtonEntity.VALOROUS_PRESENCE,
|
||||||
press_fn=(
|
press_fn=lambda habitica: habitica.cast_skill(Skill.VALOROUS_PRESENCE),
|
||||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE)
|
|
||||||
),
|
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||||
and (data.user.stats.mp or 0) >= 20
|
and (data.user.stats.mp or 0) >= 20
|
||||||
@@ -173,9 +152,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.INTIMIDATE,
|
key=HabiticaButtonEntity.INTIMIDATE,
|
||||||
translation_key=HabiticaButtonEntity.INTIMIDATE,
|
translation_key=HabiticaButtonEntity.INTIMIDATE,
|
||||||
press_fn=(
|
press_fn=lambda habitica: habitica.cast_skill(Skill.INTIMIDATING_GAZE),
|
||||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE)
|
|
||||||
),
|
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||||
and (data.user.stats.mp or 0) >= 15
|
and (data.user.stats.mp or 0) >= 15
|
||||||
@@ -186,11 +163,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.TOOLS_OF_TRADE,
|
key=HabiticaButtonEntity.TOOLS_OF_TRADE,
|
||||||
translation_key=HabiticaButtonEntity.TOOLS_OF_TRADE,
|
translation_key=HabiticaButtonEntity.TOOLS_OF_TRADE,
|
||||||
press_fn=(
|
press_fn=lambda habitica: habitica.cast_skill(Skill.TOOLS_OF_THE_TRADE),
|
||||||
lambda coordinator: coordinator.habitica.cast_skill(
|
|
||||||
Skill.TOOLS_OF_THE_TRADE
|
|
||||||
)
|
|
||||||
),
|
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||||
and (data.user.stats.mp or 0) >= 25
|
and (data.user.stats.mp or 0) >= 25
|
||||||
@@ -201,7 +174,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.STEALTH,
|
key=HabiticaButtonEntity.STEALTH,
|
||||||
translation_key=HabiticaButtonEntity.STEALTH,
|
translation_key=HabiticaButtonEntity.STEALTH,
|
||||||
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH),
|
press_fn=lambda habitica: habitica.cast_skill(Skill.STEALTH),
|
||||||
# Stealth buffs stack and it can only be cast if the amount of
|
# Stealth buffs stack and it can only be cast if the amount of
|
||||||
# buffs is smaller than the amount of unfinished dailies
|
# buffs is smaller than the amount of unfinished dailies
|
||||||
available_fn=(
|
available_fn=(
|
||||||
@@ -224,9 +197,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.HEAL,
|
key=HabiticaButtonEntity.HEAL,
|
||||||
translation_key=HabiticaButtonEntity.HEAL,
|
translation_key=HabiticaButtonEntity.HEAL,
|
||||||
press_fn=(
|
press_fn=lambda habitica: habitica.cast_skill(Skill.HEALING_LIGHT),
|
||||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT)
|
|
||||||
),
|
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 11
|
lambda data: (data.user.stats.lvl or 0) >= 11
|
||||||
and (data.user.stats.mp or 0) >= 15
|
and (data.user.stats.mp or 0) >= 15
|
||||||
@@ -238,11 +209,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.BRIGHTNESS,
|
key=HabiticaButtonEntity.BRIGHTNESS,
|
||||||
translation_key=HabiticaButtonEntity.BRIGHTNESS,
|
translation_key=HabiticaButtonEntity.BRIGHTNESS,
|
||||||
press_fn=(
|
press_fn=lambda habitica: habitica.cast_skill(Skill.SEARING_BRIGHTNESS),
|
||||||
lambda coordinator: coordinator.habitica.cast_skill(
|
|
||||||
Skill.SEARING_BRIGHTNESS
|
|
||||||
)
|
|
||||||
),
|
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 12
|
lambda data: (data.user.stats.lvl or 0) >= 12
|
||||||
and (data.user.stats.mp or 0) >= 15
|
and (data.user.stats.mp or 0) >= 15
|
||||||
@@ -253,9 +220,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.PROTECT_AURA,
|
key=HabiticaButtonEntity.PROTECT_AURA,
|
||||||
translation_key=HabiticaButtonEntity.PROTECT_AURA,
|
translation_key=HabiticaButtonEntity.PROTECT_AURA,
|
||||||
press_fn=(
|
press_fn=lambda habitica: habitica.cast_skill(Skill.PROTECTIVE_AURA),
|
||||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA)
|
|
||||||
),
|
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||||
and (data.user.stats.mp or 0) >= 30
|
and (data.user.stats.mp or 0) >= 30
|
||||||
@@ -266,7 +231,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
|||||||
HabiticaButtonEntityDescription(
|
HabiticaButtonEntityDescription(
|
||||||
key=HabiticaButtonEntity.HEAL_ALL,
|
key=HabiticaButtonEntity.HEAL_ALL,
|
||||||
translation_key=HabiticaButtonEntity.HEAL_ALL,
|
translation_key=HabiticaButtonEntity.HEAL_ALL,
|
||||||
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING),
|
press_fn=lambda habitica: habitica.cast_skill(Skill.BLESSING),
|
||||||
available_fn=(
|
available_fn=(
|
||||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||||
and (data.user.stats.mp or 0) >= 25
|
and (data.user.stats.mp or 0) >= 25
|
||||||
@@ -332,33 +297,9 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
|
|||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Handle the button press."""
|
"""Handle the button press."""
|
||||||
try:
|
|
||||||
await self.entity_description.press_fn(self.coordinator)
|
await self.coordinator.execute(self.entity_description.press_fn)
|
||||||
except TooManyRequestsError as e:
|
await self.coordinator.async_request_refresh()
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="setup_rate_limit_exception",
|
|
||||||
translation_placeholders={"retry_after": str(e.retry_after)},
|
|
||||||
) from e
|
|
||||||
except NotAuthorizedError as e:
|
|
||||||
raise ServiceValidationError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="service_call_unallowed",
|
|
||||||
) from e
|
|
||||||
except HabiticaException as e:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="service_call_exception",
|
|
||||||
translation_placeholders={"reason": e.error.message},
|
|
||||||
) from e
|
|
||||||
except ClientError as e:
|
|
||||||
raise HomeAssistantError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="service_call_exception",
|
|
||||||
translation_placeholders={"reason": str(e)},
|
|
||||||
) from e
|
|
||||||
else:
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
data={
|
data={
|
||||||
CONF_API_USER: str(login.id),
|
CONF_API_USER: str(login.id),
|
||||||
CONF_API_KEY: login.apiToken,
|
CONF_API_KEY: login.apiToken,
|
||||||
CONF_NAME: user.profile.name, # needed for api_call action
|
|
||||||
CONF_URL: DEFAULT_URL,
|
CONF_URL: DEFAULT_URL,
|
||||||
CONF_VERIFY_SSL: True,
|
CONF_VERIFY_SSL: True,
|
||||||
},
|
},
|
||||||
@@ -200,7 +199,6 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
data={
|
data={
|
||||||
**user_input,
|
**user_input,
|
||||||
CONF_URL: user_input.get(CONF_URL, DEFAULT_URL),
|
CONF_URL: user_input.get(CONF_URL, DEFAULT_URL),
|
||||||
CONF_NAME: user.profile.name, # needed for api_call action
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ from habiticalib import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_NAME
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import (
|
from homeassistant.exceptions import (
|
||||||
ConfigEntryAuthFailed,
|
ConfigEntryAuthFailed,
|
||||||
ConfigEntryNotReady,
|
ConfigEntryNotReady,
|
||||||
HomeAssistantError,
|
HomeAssistantError,
|
||||||
|
ServiceValidationError,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.debounce import Debouncer
|
from homeassistant.helpers.debounce import Debouncer
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
@@ -106,12 +106,6 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
|||||||
translation_placeholders={"reason": str(e)},
|
translation_placeholders={"reason": str(e)},
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
if not self.config_entry.data.get(CONF_NAME):
|
|
||||||
self.hass.config_entries.async_update_entry(
|
|
||||||
self.config_entry,
|
|
||||||
data={**self.config_entry.data, CONF_NAME: user.data.profile.name},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> HabiticaData:
|
async def _async_update_data(self) -> HabiticaData:
|
||||||
try:
|
try:
|
||||||
user = (await self.habitica.get_user()).data
|
user = (await self.habitica.get_user()).data
|
||||||
@@ -137,19 +131,22 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
|||||||
else:
|
else:
|
||||||
return HabiticaData(user=user, tasks=tasks + completed_todos)
|
return HabiticaData(user=user, tasks=tasks + completed_todos)
|
||||||
|
|
||||||
async def execute(
|
async def execute(self, func: Callable[[Habitica], Any]) -> None:
|
||||||
self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
|
|
||||||
) -> None:
|
|
||||||
"""Execute an API call."""
|
"""Execute an API call."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await func(self)
|
await func(self.habitica)
|
||||||
except TooManyRequestsError as e:
|
except TooManyRequestsError as e:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="setup_rate_limit_exception",
|
translation_key="setup_rate_limit_exception",
|
||||||
translation_placeholders={"retry_after": str(e.retry_after)},
|
translation_placeholders={"retry_after": str(e.retry_after)},
|
||||||
) from e
|
) from e
|
||||||
|
except NotAuthorizedError as e:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="service_call_unallowed",
|
||||||
|
) from e
|
||||||
except HabiticaException as e:
|
except HabiticaException as e:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
|
|
||||||
from homeassistant.const import CONF_NAME, CONF_URL
|
from homeassistant.const import CONF_URL
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.entity import EntityDescription
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
@@ -37,7 +37,7 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]):
|
|||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
manufacturer=MANUFACTURER,
|
manufacturer=MANUFACTURER,
|
||||||
model=NAME,
|
model=NAME,
|
||||||
name=coordinator.config_entry.data[CONF_NAME],
|
name=coordinator.data.user.profile.name,
|
||||||
configuration_url=(
|
configuration_url=(
|
||||||
URL(coordinator.config_entry.data[CONF_URL])
|
URL(coordinator.config_entry.data[CONF_URL])
|
||||||
/ "profile"
|
/ "profile"
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["habiticalib"],
|
"loggers": ["habiticalib"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["habiticalib==0.4.0"]
|
"requirements": ["habiticalib==0.4.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from dataclasses import dataclass
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from habiticalib import Habitica
|
||||||
|
|
||||||
from homeassistant.components.switch import (
|
from homeassistant.components.switch import (
|
||||||
SwitchDeviceClass,
|
SwitchDeviceClass,
|
||||||
SwitchEntity,
|
SwitchEntity,
|
||||||
@@ -15,11 +17,7 @@ from homeassistant.components.switch import (
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .coordinator import (
|
from .coordinator import HabiticaConfigEntry, HabiticaData
|
||||||
HabiticaConfigEntry,
|
|
||||||
HabiticaData,
|
|
||||||
HabiticaDataUpdateCoordinator,
|
|
||||||
)
|
|
||||||
from .entity import HabiticaBase
|
from .entity import HabiticaBase
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
@@ -29,8 +27,8 @@ PARALLEL_UPDATES = 1
|
|||||||
class HabiticaSwitchEntityDescription(SwitchEntityDescription):
|
class HabiticaSwitchEntityDescription(SwitchEntityDescription):
|
||||||
"""Describes Habitica switch entity."""
|
"""Describes Habitica switch entity."""
|
||||||
|
|
||||||
turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
|
turn_on_fn: Callable[[Habitica], Any]
|
||||||
turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
|
turn_off_fn: Callable[[Habitica], Any]
|
||||||
is_on_fn: Callable[[HabiticaData], bool | None]
|
is_on_fn: Callable[[HabiticaData], bool | None]
|
||||||
|
|
||||||
|
|
||||||
@@ -45,8 +43,8 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = (
|
|||||||
key=HabiticaSwitchEntity.SLEEP,
|
key=HabiticaSwitchEntity.SLEEP,
|
||||||
translation_key=HabiticaSwitchEntity.SLEEP,
|
translation_key=HabiticaSwitchEntity.SLEEP,
|
||||||
device_class=SwitchDeviceClass.SWITCH,
|
device_class=SwitchDeviceClass.SWITCH,
|
||||||
turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
|
turn_on_fn=lambda habitica: habitica.toggle_sleep(),
|
||||||
turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
|
turn_off_fn=lambda habitica: habitica.toggle_sleep(),
|
||||||
is_on_fn=lambda data: data.user.preferences.sleep,
|
is_on_fn=lambda data: data.user.preferences.sleep,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform
|
from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.start import async_at_started
|
from homeassistant.helpers.start import async_at_started
|
||||||
|
|
||||||
from .const import TRAVEL_MODE_PUBLIC
|
from .const import CONF_TRAFFIC_MODE, TRAVEL_MODE_PUBLIC
|
||||||
from .coordinator import (
|
from .coordinator import (
|
||||||
HereConfigEntry,
|
HereConfigEntry,
|
||||||
HERERoutingDataUpdateCoordinator,
|
HERERoutingDataUpdateCoordinator,
|
||||||
@@ -15,6 +17,8 @@ from .coordinator import (
|
|||||||
|
|
||||||
PLATFORMS = [Platform.SENSOR]
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) -> bool:
|
||||||
"""Set up HERE Travel Time from a config entry."""
|
"""Set up HERE Travel Time from a config entry."""
|
||||||
@@ -43,3 +47,28 @@ async def async_unload_entry(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(
|
||||||
|
hass: HomeAssistant, config_entry: HereConfigEntry
|
||||||
|
) -> bool:
|
||||||
|
"""Migrate an old config entry."""
|
||||||
|
|
||||||
|
if config_entry.version == 1 and config_entry.minor_version == 1:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating from version %s.%s",
|
||||||
|
config_entry.version,
|
||||||
|
config_entry.minor_version,
|
||||||
|
)
|
||||||
|
options = dict(config_entry.options)
|
||||||
|
options[CONF_TRAFFIC_MODE] = True
|
||||||
|
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, options=options, version=1, minor_version=2
|
||||||
|
)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migration to version %s.%s successful",
|
||||||
|
config_entry.version,
|
||||||
|
config_entry.minor_version,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from homeassistant.const import (
|
|||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
|
BooleanSelector,
|
||||||
EntitySelector,
|
EntitySelector,
|
||||||
LocationSelector,
|
LocationSelector,
|
||||||
TimeSelector,
|
TimeSelector,
|
||||||
@@ -50,6 +51,7 @@ from .const import (
|
|||||||
CONF_ORIGIN_LATITUDE,
|
CONF_ORIGIN_LATITUDE,
|
||||||
CONF_ORIGIN_LONGITUDE,
|
CONF_ORIGIN_LONGITUDE,
|
||||||
CONF_ROUTE_MODE,
|
CONF_ROUTE_MODE,
|
||||||
|
CONF_TRAFFIC_MODE,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ROUTE_MODE_FASTEST,
|
ROUTE_MODE_FASTEST,
|
||||||
@@ -65,6 +67,7 @@ DEFAULT_OPTIONS = {
|
|||||||
CONF_ROUTE_MODE: ROUTE_MODE_FASTEST,
|
CONF_ROUTE_MODE: ROUTE_MODE_FASTEST,
|
||||||
CONF_ARRIVAL_TIME: None,
|
CONF_ARRIVAL_TIME: None,
|
||||||
CONF_DEPARTURE_TIME: None,
|
CONF_DEPARTURE_TIME: None,
|
||||||
|
CONF_TRAFFIC_MODE: True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +105,7 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle a config flow for HERE Travel Time."""
|
"""Handle a config flow for HERE Travel Time."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
MINOR_VERSION = 2
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Init Config Flow."""
|
"""Init Config Flow."""
|
||||||
@@ -307,7 +311,9 @@ class HERETravelTimeOptionsFlow(OptionsFlow):
|
|||||||
"""Manage the HERE Travel Time options."""
|
"""Manage the HERE Travel Time options."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._config = user_input
|
self._config = user_input
|
||||||
return await self.async_step_time_menu()
|
if self._config[CONF_TRAFFIC_MODE]:
|
||||||
|
return await self.async_step_time_menu()
|
||||||
|
return self.async_create_entry(title="", data=self._config)
|
||||||
|
|
||||||
schema = self.add_suggested_values_to_schema(
|
schema = self.add_suggested_values_to_schema(
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
@@ -318,12 +324,21 @@ class HERETravelTimeOptionsFlow(OptionsFlow):
|
|||||||
CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
|
CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
|
||||||
),
|
),
|
||||||
): vol.In(ROUTE_MODES),
|
): vol.In(ROUTE_MODES),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_TRAFFIC_MODE,
|
||||||
|
default=self.config_entry.options.get(
|
||||||
|
CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE]
|
||||||
|
),
|
||||||
|
): BooleanSelector(),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
CONF_ROUTE_MODE: self.config_entry.options.get(
|
CONF_ROUTE_MODE: self.config_entry.options.get(
|
||||||
CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
|
CONF_ROUTE_MODE, DEFAULT_OPTIONS[CONF_ROUTE_MODE]
|
||||||
),
|
),
|
||||||
|
CONF_TRAFFIC_MODE: self.config_entry.options.get(
|
||||||
|
CONF_TRAFFIC_MODE, DEFAULT_OPTIONS[CONF_TRAFFIC_MODE]
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ CONF_ARRIVAL = "arrival"
|
|||||||
CONF_DEPARTURE = "departure"
|
CONF_DEPARTURE = "departure"
|
||||||
CONF_ARRIVAL_TIME = "arrival_time"
|
CONF_ARRIVAL_TIME = "arrival_time"
|
||||||
CONF_DEPARTURE_TIME = "departure_time"
|
CONF_DEPARTURE_TIME = "departure_time"
|
||||||
|
CONF_TRAFFIC_MODE = "traffic_mode"
|
||||||
|
|
||||||
DEFAULT_NAME = "HERE Travel Time"
|
DEFAULT_NAME = "HERE Travel Time"
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from here_routing import (
|
|||||||
Return,
|
Return,
|
||||||
RoutingMode,
|
RoutingMode,
|
||||||
Spans,
|
Spans,
|
||||||
|
TrafficMode,
|
||||||
TransportMode,
|
TransportMode,
|
||||||
)
|
)
|
||||||
import here_transit
|
import here_transit
|
||||||
@@ -44,6 +45,7 @@ from .const import (
|
|||||||
CONF_ORIGIN_LATITUDE,
|
CONF_ORIGIN_LATITUDE,
|
||||||
CONF_ORIGIN_LONGITUDE,
|
CONF_ORIGIN_LONGITUDE,
|
||||||
CONF_ROUTE_MODE,
|
CONF_ROUTE_MODE,
|
||||||
|
CONF_TRAFFIC_MODE,
|
||||||
DEFAULT_SCAN_INTERVAL,
|
DEFAULT_SCAN_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ROUTE_MODE_FASTEST,
|
ROUTE_MODE_FASTEST,
|
||||||
@@ -87,7 +89,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
|
|||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
(
|
(
|
||||||
"Requesting route for origin: %s, destination: %s, route_mode: %s,"
|
"Requesting route for origin: %s, destination: %s, route_mode: %s,"
|
||||||
" mode: %s, arrival: %s, departure: %s"
|
" mode: %s, arrival: %s, departure: %s, traffic_mode: %s"
|
||||||
),
|
),
|
||||||
params.origin,
|
params.origin,
|
||||||
params.destination,
|
params.destination,
|
||||||
@@ -95,6 +97,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
|
|||||||
TransportMode(params.travel_mode),
|
TransportMode(params.travel_mode),
|
||||||
params.arrival,
|
params.arrival,
|
||||||
params.departure,
|
params.departure,
|
||||||
|
params.traffic_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -109,6 +112,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]
|
|||||||
routing_mode=params.route_mode,
|
routing_mode=params.route_mode,
|
||||||
arrival_time=params.arrival,
|
arrival_time=params.arrival,
|
||||||
departure_time=params.departure,
|
departure_time=params.departure,
|
||||||
|
traffic_mode=params.traffic_mode,
|
||||||
return_values=[Return.POLYINE, Return.SUMMARY],
|
return_values=[Return.POLYINE, Return.SUMMARY],
|
||||||
spans=[Spans.NAMES],
|
spans=[Spans.NAMES],
|
||||||
)
|
)
|
||||||
@@ -350,6 +354,11 @@ def prepare_parameters(
|
|||||||
if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST
|
if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST
|
||||||
else RoutingMode.SHORT
|
else RoutingMode.SHORT
|
||||||
)
|
)
|
||||||
|
traffic_mode = (
|
||||||
|
TrafficMode.DISABLED
|
||||||
|
if config_entry.options[CONF_TRAFFIC_MODE] is False
|
||||||
|
else TrafficMode.DEFAULT
|
||||||
|
)
|
||||||
|
|
||||||
return HERETravelTimeAPIParams(
|
return HERETravelTimeAPIParams(
|
||||||
destination=destination,
|
destination=destination,
|
||||||
@@ -358,6 +367,7 @@ def prepare_parameters(
|
|||||||
route_mode=route_mode,
|
route_mode=route_mode,
|
||||||
arrival=arrival,
|
arrival=arrival,
|
||||||
departure=departure,
|
departure=departure,
|
||||||
|
traffic_mode=traffic_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from here_routing import RoutingMode
|
from here_routing import RoutingMode, TrafficMode
|
||||||
|
|
||||||
|
|
||||||
class HERETravelTimeData(TypedDict):
|
class HERETravelTimeData(TypedDict):
|
||||||
@@ -32,3 +32,4 @@ class HERETravelTimeAPIParams:
|
|||||||
route_mode: RoutingMode
|
route_mode: RoutingMode
|
||||||
arrival: datetime | None
|
arrival: datetime | None
|
||||||
departure: datetime | None
|
departure: datetime | None
|
||||||
|
traffic_mode: TrafficMode
|
||||||
|
|||||||
@@ -60,8 +60,11 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"data": {
|
||||||
"traffic_mode": "Traffic mode",
|
"traffic_mode": "Use traffic and time-aware routing",
|
||||||
"route_mode": "Route mode"
|
"route_mode": "Route mode"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"traffic_mode": "Needed for defining arrival/departure times"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"time_menu": {
|
"time_menu": {
|
||||||
|
|||||||
@@ -193,11 +193,11 @@
|
|||||||
"consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte",
|
"consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte",
|
||||||
"consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth",
|
"consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth",
|
||||||
"consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk",
|
"consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk",
|
||||||
"consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner",
|
"consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner Brauner",
|
||||||
"consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner",
|
"consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser Brauner",
|
||||||
"consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter",
|
"consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter",
|
||||||
"consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun",
|
"consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun",
|
||||||
"consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange",
|
"consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener Melange",
|
||||||
"consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white",
|
"consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white",
|
||||||
"consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
|
"consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
|
||||||
"consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado",
|
"consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado",
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
"cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
|
"cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
|
||||||
"cooking_oven_program_heating_mode_keep_warm": "Keep warm",
|
"cooking_oven_program_heating_mode_keep_warm": "Keep warm",
|
||||||
"cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware",
|
"cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware",
|
||||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products",
|
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products",
|
||||||
"cooking_oven_program_heating_mode_desiccation": "Desiccation",
|
"cooking_oven_program_heating_mode_desiccation": "Desiccation",
|
||||||
"cooking_oven_program_heating_mode_defrost": "Defrost",
|
"cooking_oven_program_heating_mode_defrost": "Defrost",
|
||||||
"cooking_oven_program_heating_mode_proof": "Proof",
|
"cooking_oven_program_heating_mode_proof": "Proof",
|
||||||
@@ -316,8 +316,8 @@
|
|||||||
"laundry_care_washer_program_monsoon": "Monsoon",
|
"laundry_care_washer_program_monsoon": "Monsoon",
|
||||||
"laundry_care_washer_program_outdoor": "Outdoor",
|
"laundry_care_washer_program_outdoor": "Outdoor",
|
||||||
"laundry_care_washer_program_plush_toy": "Plush toy",
|
"laundry_care_washer_program_plush_toy": "Plush toy",
|
||||||
"laundry_care_washer_program_shirts_blouses": "Shirts blouses",
|
"laundry_care_washer_program_shirts_blouses": "Shirts/blouses",
|
||||||
"laundry_care_washer_program_sport_fitness": "Sport fitness",
|
"laundry_care_washer_program_sport_fitness": "Sport/fitness",
|
||||||
"laundry_care_washer_program_towels": "Towels",
|
"laundry_care_washer_program_towels": "Towels",
|
||||||
"laundry_care_washer_program_water_proof": "Water proof",
|
"laundry_care_washer_program_water_proof": "Water proof",
|
||||||
"laundry_care_washer_program_power_speed_59": "Power speed <59 min",
|
"laundry_care_washer_program_power_speed_59": "Power speed <59 min",
|
||||||
@@ -582,7 +582,7 @@
|
|||||||
},
|
},
|
||||||
"consumer_products_cleaning_robot_option_cleaning_mode": {
|
"consumer_products_cleaning_robot_option_cleaning_mode": {
|
||||||
"name": "Cleaning mode",
|
"name": "Cleaning mode",
|
||||||
"description": "Defines the favoured cleaning mode."
|
"description": "Defines the favored cleaning mode."
|
||||||
},
|
},
|
||||||
"consumer_products_coffee_maker_option_bean_amount": {
|
"consumer_products_coffee_maker_option_bean_amount": {
|
||||||
"name": "Bean amount",
|
"name": "Bean amount",
|
||||||
@@ -670,7 +670,7 @@
|
|||||||
},
|
},
|
||||||
"cooking_oven_option_setpoint_temperature": {
|
"cooking_oven_option_setpoint_temperature": {
|
||||||
"name": "Setpoint temperature",
|
"name": "Setpoint temperature",
|
||||||
"description": "Defines the target cavity temperature, which will be hold by the oven."
|
"description": "Defines the target cavity temperature, which will be held by the oven."
|
||||||
},
|
},
|
||||||
"b_s_h_common_option_duration": {
|
"b_s_h_common_option_duration": {
|
||||||
"name": "Duration",
|
"name": "Duration",
|
||||||
@@ -1291,9 +1291,9 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"cooking_hood_enum_type_color_temperature_custom": "Custom",
|
"cooking_hood_enum_type_color_temperature_custom": "Custom",
|
||||||
"cooking_hood_enum_type_color_temperature_warm": "Warm",
|
"cooking_hood_enum_type_color_temperature_warm": "Warm",
|
||||||
"cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral",
|
"cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to neutral",
|
||||||
"cooking_hood_enum_type_color_temperature_neutral": "Neutral",
|
"cooking_hood_enum_type_color_temperature_neutral": "Neutral",
|
||||||
"cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold",
|
"cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to cold",
|
||||||
"cooking_hood_enum_type_color_temperature_cold": "Cold"
|
"cooking_hood_enum_type_color_temperature_cold": "Cold"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["homee"],
|
"loggers": ["homee"],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "silver",
|
||||||
"requirements": ["pyHomee==1.2.10"]
|
"requirements": ["pyHomee==1.2.10"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,19 @@ rules:
|
|||||||
unique-config-entry: done
|
unique-config-entry: done
|
||||||
|
|
||||||
# Silver
|
# Silver
|
||||||
action-exceptions: todo
|
action-exceptions: done
|
||||||
config-entry-unloading: done
|
config-entry-unloading: done
|
||||||
docs-configuration-parameters: todo
|
docs-configuration-parameters:
|
||||||
docs-installation-parameters: todo
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
The integration does not have options.
|
||||||
|
docs-installation-parameters: done
|
||||||
entity-unavailable: done
|
entity-unavailable: done
|
||||||
integration-owner: done
|
integration-owner: done
|
||||||
log-when-unavailable: done
|
log-when-unavailable: done
|
||||||
parallel-updates: done
|
parallel-updates: done
|
||||||
reauthentication-flow: todo
|
reauthentication-flow: done
|
||||||
test-coverage: todo
|
test-coverage: done
|
||||||
|
|
||||||
# Gold
|
# Gold
|
||||||
devices: done
|
devices: done
|
||||||
@@ -49,16 +52,16 @@ rules:
|
|||||||
docs-known-limitations: todo
|
docs-known-limitations: todo
|
||||||
docs-supported-devices: todo
|
docs-supported-devices: todo
|
||||||
docs-supported-functions: todo
|
docs-supported-functions: todo
|
||||||
docs-troubleshooting: todo
|
docs-troubleshooting: done
|
||||||
docs-use-cases: todo
|
docs-use-cases: todo
|
||||||
dynamic-devices: todo
|
dynamic-devices: todo
|
||||||
entity-category: done
|
entity-category: done
|
||||||
entity-device-class: done
|
entity-device-class: done
|
||||||
entity-disabled-by-default: done
|
entity-disabled-by-default: done
|
||||||
entity-translations: done
|
entity-translations: done
|
||||||
exception-translations: todo
|
exception-translations: done
|
||||||
icon-translations: done
|
icon-translations: done
|
||||||
reconfiguration-flow: todo
|
reconfiguration-flow: done
|
||||||
repair-issues: todo
|
repair-issues: todo
|
||||||
stale-devices: todo
|
stale-devices: todo
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aioautomower"],
|
"loggers": ["aioautomower"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["aioautomower==2.0.1"]
|
"requirements": ["aioautomower==2.1.1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from operator import attrgetter
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from aioautomower.model import (
|
from aioautomower.model import (
|
||||||
|
ExternalReasons,
|
||||||
InactiveReasons,
|
InactiveReasons,
|
||||||
MowerAttributes,
|
MowerAttributes,
|
||||||
MowerModes,
|
MowerModes,
|
||||||
@@ -190,11 +191,37 @@ RESTRICTED_REASONS: list = [
|
|||||||
RestrictedReasons.PARK_OVERRIDE,
|
RestrictedReasons.PARK_OVERRIDE,
|
||||||
RestrictedReasons.SENSOR,
|
RestrictedReasons.SENSOR,
|
||||||
RestrictedReasons.WEEK_SCHEDULE,
|
RestrictedReasons.WEEK_SCHEDULE,
|
||||||
|
ExternalReasons.AMAZON_ALEXA,
|
||||||
|
ExternalReasons.DEVELOPER_PORTAL,
|
||||||
|
ExternalReasons.GARDENA_SMART_SYSTEM,
|
||||||
|
ExternalReasons.GOOGLE_ASSISTANT,
|
||||||
|
ExternalReasons.HOME_ASSISTANT,
|
||||||
|
ExternalReasons.IFTTT,
|
||||||
|
ExternalReasons.IFTTT_APPLETS,
|
||||||
|
ExternalReasons.IFTTT_CALENDAR_CONNECTION,
|
||||||
|
ExternalReasons.SMART_ROUTINE,
|
||||||
|
ExternalReasons.SMART_ROUTINE_FROST_GUARD,
|
||||||
|
ExternalReasons.SMART_ROUTINE_RAIN_GUARD,
|
||||||
|
ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION,
|
||||||
]
|
]
|
||||||
|
|
||||||
STATE_NO_WORK_AREA_ACTIVE = "no_work_area_active"
|
STATE_NO_WORK_AREA_ACTIVE = "no_work_area_active"
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _get_restricted_reason(data: MowerAttributes) -> str:
|
||||||
|
"""Return the restricted reason.
|
||||||
|
|
||||||
|
If there is an external reason, return that instead, if it's available.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
data.planner.restricted_reason == RestrictedReasons.EXTERNAL
|
||||||
|
and data.planner.external_reason is not None
|
||||||
|
):
|
||||||
|
return data.planner.external_reason
|
||||||
|
return data.planner.restricted_reason
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _get_work_area_names(data: MowerAttributes) -> list[str]:
|
def _get_work_area_names(data: MowerAttributes) -> list[str]:
|
||||||
"""Return a list with all work area names."""
|
"""Return a list with all work area names."""
|
||||||
@@ -400,7 +427,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
|||||||
translation_key="restricted_reason",
|
translation_key="restricted_reason",
|
||||||
device_class=SensorDeviceClass.ENUM,
|
device_class=SensorDeviceClass.ENUM,
|
||||||
option_fn=lambda data: RESTRICTED_REASONS,
|
option_fn=lambda data: RESTRICTED_REASONS,
|
||||||
value_fn=attrgetter("planner.restricted_reason"),
|
value_fn=_get_restricted_reason,
|
||||||
),
|
),
|
||||||
AutomowerSensorEntityDescription(
|
AutomowerSensorEntityDescription(
|
||||||
key="inactive_reason",
|
key="inactive_reason",
|
||||||
|
|||||||
@@ -242,16 +242,28 @@
|
|||||||
"restricted_reason": {
|
"restricted_reason": {
|
||||||
"name": "Restricted reason",
|
"name": "Restricted reason",
|
||||||
"state": {
|
"state": {
|
||||||
"none": "No restrictions",
|
"all_work_areas_completed": "All work areas completed",
|
||||||
"week_schedule": "Week schedule",
|
"amazon_alexa": "Amazon Alexa",
|
||||||
"park_override": "Park override",
|
|
||||||
"sensor": "Weather timer",
|
|
||||||
"daily_limit": "Daily limit",
|
"daily_limit": "Daily limit",
|
||||||
|
"developer_portal": "Developer Portal",
|
||||||
|
"external": "External",
|
||||||
"fota": "Firmware Over-the-Air update running",
|
"fota": "Firmware Over-the-Air update running",
|
||||||
"frost": "Frost",
|
"frost": "Frost",
|
||||||
"all_work_areas_completed": "All work areas completed",
|
"gardena_smart_system": "Gardena Smart System",
|
||||||
"external": "External",
|
"google_assistant": "Google Assistant",
|
||||||
"not_applicable": "Not applicable"
|
"home_assistant": "Home Assistant",
|
||||||
|
"ifttt_applets": "IFTTT applets",
|
||||||
|
"ifttt_calendar_connection": "IFTTT calendar connection",
|
||||||
|
"ifttt": "IFTTT",
|
||||||
|
"none": "No restrictions",
|
||||||
|
"not_applicable": "Not applicable",
|
||||||
|
"park_override": "Park override",
|
||||||
|
"sensor": "Weather timer",
|
||||||
|
"smart_routine_frost_guard": "Frost guard",
|
||||||
|
"smart_routine_rain_guard": "Rain guard",
|
||||||
|
"smart_routine_wildlife_protection": "Wildlife protection",
|
||||||
|
"smart_routine": "Generic smart routine",
|
||||||
|
"week_schedule": "Week schedule"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"total_charging_time": {
|
"total_charging_time": {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator]
|
|||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.LAWN_MOWER,
|
Platform.LAWN_MOWER,
|
||||||
|
Platform.SENSOR,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if TYPE_CHECKING:
|
|||||||
SCAN_INTERVAL = timedelta(seconds=60)
|
SCAN_INTERVAL = timedelta(seconds=60)
|
||||||
|
|
||||||
|
|
||||||
class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
|
class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, str | int]]):
|
||||||
"""Class to manage fetching data."""
|
"""Class to manage fetching data."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -67,11 +67,11 @@ class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
|
|||||||
except BleakError as err:
|
except BleakError as err:
|
||||||
raise UpdateFailed("Failed to connect") from err
|
raise UpdateFailed("Failed to connect") from err
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, bytes]:
|
async def _async_update_data(self) -> dict[str, str | int]:
|
||||||
"""Poll the device."""
|
"""Poll the device."""
|
||||||
LOGGER.debug("Polling device")
|
LOGGER.debug("Polling device")
|
||||||
|
|
||||||
data: dict[str, bytes] = {}
|
data: dict[str, str | int] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.mower.is_connected():
|
if not self.mower.is_connected():
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .const import DOMAIN, MANUFACTURER
|
from .const import DOMAIN, MANUFACTURER
|
||||||
@@ -28,3 +29,18 @@ class HusqvarnaAutomowerBleEntity(CoordinatorEntity[HusqvarnaCoordinator]):
|
|||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if entity is available."""
|
"""Return if entity is available."""
|
||||||
return super().available and self.coordinator.mower.is_connected()
|
return super().available and self.coordinator.mower.is_connected()
|
||||||
|
|
||||||
|
|
||||||
|
class HusqvarnaAutomowerBleDescriptorEntity(HusqvarnaAutomowerBleEntity):
|
||||||
|
"""Coordinator entity for entities with entity description."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, coordinator: HusqvarnaCoordinator, description: EntityDescription
|
||||||
|
) -> None:
|
||||||
|
"""Initialize description entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
|
||||||
|
self._attr_unique_id = (
|
||||||
|
f"{coordinator.address}_{coordinator.channel_id}_{description.key}"
|
||||||
|
)
|
||||||
|
self.entity_description = description
|
||||||
|
|||||||
51
homeassistant/components/husqvarna_automower_ble/sensor.py
Normal file
51
homeassistant/components/husqvarna_automower_ble/sensor.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Support for sensor entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from . import HusqvarnaConfigEntry
|
||||||
|
from .entity import HusqvarnaAutomowerBleDescriptorEntity
|
||||||
|
|
||||||
|
DESCRIPTIONS = (
|
||||||
|
SensorEntityDescription(
|
||||||
|
key="battery_level",
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: HusqvarnaConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up Husqvarna Automower Ble sensor based on a config entry."""
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
|
HusqvarnaAutomowerBleSensor(coordinator, description)
|
||||||
|
for description in DESCRIPTIONS
|
||||||
|
if description.key in coordinator.data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HusqvarnaAutomowerBleSensor(HusqvarnaAutomowerBleDescriptorEntity, SensorEntity):
|
||||||
|
"""Representation of a sensor."""
|
||||||
|
|
||||||
|
entity_description: SensorEntityDescription
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | int:
|
||||||
|
"""Return the previously fetched value."""
|
||||||
|
return self.coordinator.data[self.entity_description.key]
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydrawise import Zone
|
from pydrawise import Controller, Zone
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
@@ -81,31 +81,46 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Hydrawise binary_sensor platform."""
|
"""Set up the Hydrawise binary_sensor platform."""
|
||||||
coordinators = config_entry.runtime_data
|
coordinators = config_entry.runtime_data
|
||||||
entities: list[HydrawiseBinarySensor] = []
|
|
||||||
for controller in coordinators.main.data.controllers.values():
|
def _add_new_controllers(controllers: Iterable[Controller]) -> None:
|
||||||
entities.extend(
|
entities: list[HydrawiseBinarySensor] = []
|
||||||
HydrawiseBinarySensor(coordinators.main, description, controller)
|
for controller in controllers:
|
||||||
for description in CONTROLLER_BINARY_SENSORS
|
entities.extend(
|
||||||
)
|
HydrawiseBinarySensor(coordinators.main, description, controller)
|
||||||
entities.extend(
|
for description in CONTROLLER_BINARY_SENSORS
|
||||||
HydrawiseBinarySensor(
|
|
||||||
coordinators.main,
|
|
||||||
description,
|
|
||||||
controller,
|
|
||||||
sensor_id=sensor.id,
|
|
||||||
)
|
)
|
||||||
for sensor in controller.sensors
|
entities.extend(
|
||||||
for description in RAIN_SENSOR_BINARY_SENSOR
|
HydrawiseBinarySensor(
|
||||||
if "rain sensor" in sensor.model.name.lower()
|
coordinators.main,
|
||||||
)
|
description,
|
||||||
entities.extend(
|
controller,
|
||||||
|
sensor_id=sensor.id,
|
||||||
|
)
|
||||||
|
for sensor in controller.sensors
|
||||||
|
for description in RAIN_SENSOR_BINARY_SENSOR
|
||||||
|
if "rain sensor" in sensor.model.name.lower()
|
||||||
|
)
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
|
||||||
|
async_add_entities(
|
||||||
HydrawiseZoneBinarySensor(
|
HydrawiseZoneBinarySensor(
|
||||||
coordinators.main, description, controller, zone_id=zone.id
|
coordinators.main, description, controller, zone_id=zone.id
|
||||||
)
|
)
|
||||||
for zone in controller.zones
|
for zone, controller in zones
|
||||||
for description in ZONE_BINARY_SENSORS
|
for description in ZONE_BINARY_SENSORS
|
||||||
)
|
)
|
||||||
async_add_entities(entities)
|
|
||||||
|
_add_new_controllers(coordinators.main.data.controllers.values())
|
||||||
|
_add_new_zones(
|
||||||
|
[
|
||||||
|
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
|
||||||
|
for zone in coordinators.main.data.zones.values()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
coordinators.main.new_controllers_callbacks.append(_add_new_controllers)
|
||||||
|
coordinators.main.new_zones_callbacks.append(_add_new_zones)
|
||||||
|
|
||||||
platform = entity_platform.async_get_current_platform()
|
platform = entity_platform.async_get_current_platform()
|
||||||
platform.async_register_entity_service(SERVICE_RESUME, None, "resume")
|
platform.async_register_entity_service(SERVICE_RESUME, None, "resume")
|
||||||
platform.async_register_entity_service(
|
platform.async_register_entity_service(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ DOMAIN = "hydrawise"
|
|||||||
DEFAULT_WATERING_TIME = timedelta(minutes=15)
|
DEFAULT_WATERING_TIME = timedelta(minutes=15)
|
||||||
|
|
||||||
MANUFACTURER = "Hydrawise"
|
MANUFACTURER = "Hydrawise"
|
||||||
|
MODEL_ZONE = "Zone"
|
||||||
|
|
||||||
MAIN_SCAN_INTERVAL = timedelta(minutes=5)
|
MAIN_SCAN_INTERVAL = timedelta(minutes=5)
|
||||||
WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)
|
WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)
|
||||||
|
|||||||
@@ -2,17 +2,26 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from pydrawise import HydrawiseBase
|
from pydrawise import HydrawiseBase
|
||||||
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
|
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
from homeassistant.helpers.device_registry import DeviceEntry
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
from homeassistant.util.dt import now
|
from homeassistant.util.dt import now
|
||||||
|
|
||||||
from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
LOGGER,
|
||||||
|
MAIN_SCAN_INTERVAL,
|
||||||
|
MODEL_ZONE,
|
||||||
|
WATER_USE_SCAN_INTERVAL,
|
||||||
|
)
|
||||||
|
|
||||||
type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators]
|
type HydrawiseConfigEntry = ConfigEntry[HydrawiseUpdateCoordinators]
|
||||||
|
|
||||||
@@ -24,6 +33,7 @@ class HydrawiseData:
|
|||||||
user: User
|
user: User
|
||||||
controllers: dict[int, Controller] = field(default_factory=dict)
|
controllers: dict[int, Controller] = field(default_factory=dict)
|
||||||
zones: dict[int, Zone] = field(default_factory=dict)
|
zones: dict[int, Zone] = field(default_factory=dict)
|
||||||
|
zone_id_to_controller: dict[int, Controller] = field(default_factory=dict)
|
||||||
sensors: dict[int, Sensor] = field(default_factory=dict)
|
sensors: dict[int, Sensor] = field(default_factory=dict)
|
||||||
daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
|
daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
|
||||||
default_factory=dict
|
default_factory=dict
|
||||||
@@ -68,6 +78,13 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
|||||||
update_interval=MAIN_SCAN_INTERVAL,
|
update_interval=MAIN_SCAN_INTERVAL,
|
||||||
)
|
)
|
||||||
self.api = api
|
self.api = api
|
||||||
|
self.new_controllers_callbacks: list[
|
||||||
|
Callable[[Iterable[Controller]], None]
|
||||||
|
] = []
|
||||||
|
self.new_zones_callbacks: list[
|
||||||
|
Callable[[Iterable[tuple[Zone, Controller]]], None]
|
||||||
|
] = []
|
||||||
|
self.async_add_listener(self._add_remove_zones)
|
||||||
|
|
||||||
async def _async_update_data(self) -> HydrawiseData:
|
async def _async_update_data(self) -> HydrawiseData:
|
||||||
"""Fetch the latest data from Hydrawise."""
|
"""Fetch the latest data from Hydrawise."""
|
||||||
@@ -80,10 +97,81 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
|||||||
controller.zones = await self.api.get_zones(controller)
|
controller.zones = await self.api.get_zones(controller)
|
||||||
for zone in controller.zones:
|
for zone in controller.zones:
|
||||||
data.zones[zone.id] = zone
|
data.zones[zone.id] = zone
|
||||||
|
data.zone_id_to_controller[zone.id] = controller
|
||||||
for sensor in controller.sensors:
|
for sensor in controller.sensors:
|
||||||
data.sensors[sensor.id] = sensor
|
data.sensors[sensor.id] = sensor
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _add_remove_zones(self) -> None:
|
||||||
|
"""Add newly discovered zones and remove nonexistent ones."""
|
||||||
|
if self.data is None:
|
||||||
|
# Likely a setup error; ignore.
|
||||||
|
# Despite what mypy thinks, this is still reachable. Without this check,
|
||||||
|
# the test_connect_retry test in test_init.py fails.
|
||||||
|
return # type: ignore[unreachable]
|
||||||
|
|
||||||
|
device_registry = dr.async_get(self.hass)
|
||||||
|
devices = dr.async_entries_for_config_entry(
|
||||||
|
device_registry, self.config_entry.entry_id
|
||||||
|
)
|
||||||
|
previous_zones: set[str] = set()
|
||||||
|
previous_zones_by_id: dict[str, DeviceEntry] = {}
|
||||||
|
previous_controllers: set[str] = set()
|
||||||
|
previous_controllers_by_id: dict[str, DeviceEntry] = {}
|
||||||
|
for device in devices:
|
||||||
|
for domain, identifier in device.identifiers:
|
||||||
|
if domain == DOMAIN:
|
||||||
|
if device.model == MODEL_ZONE:
|
||||||
|
previous_zones.add(identifier)
|
||||||
|
previous_zones_by_id[identifier] = device
|
||||||
|
else:
|
||||||
|
previous_controllers.add(identifier)
|
||||||
|
previous_controllers_by_id[identifier] = device
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_zones = {str(zone_id) for zone_id in self.data.zones}
|
||||||
|
current_controllers = {
|
||||||
|
str(controller_id) for controller_id in self.data.controllers
|
||||||
|
}
|
||||||
|
|
||||||
|
if removed_zones := previous_zones - current_zones:
|
||||||
|
LOGGER.debug("Removed zones: %s", ", ".join(removed_zones))
|
||||||
|
for zone_id in removed_zones:
|
||||||
|
device_registry.async_update_device(
|
||||||
|
device_id=previous_zones_by_id[zone_id].id,
|
||||||
|
remove_config_entry_id=self.config_entry.entry_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if removed_controllers := previous_controllers - current_controllers:
|
||||||
|
LOGGER.debug("Removed controllers: %s", ", ".join(removed_controllers))
|
||||||
|
for controller_id in removed_controllers:
|
||||||
|
device_registry.async_update_device(
|
||||||
|
device_id=previous_controllers_by_id[controller_id].id,
|
||||||
|
remove_config_entry_id=self.config_entry.entry_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_controller_ids := current_controllers - previous_controllers:
|
||||||
|
LOGGER.debug("New controllers found: %s", ", ".join(new_controller_ids))
|
||||||
|
new_controllers = [
|
||||||
|
self.data.controllers[controller_id]
|
||||||
|
for controller_id in map(int, new_controller_ids)
|
||||||
|
]
|
||||||
|
for new_controller_callback in self.new_controllers_callbacks:
|
||||||
|
new_controller_callback(new_controllers)
|
||||||
|
|
||||||
|
if new_zone_ids := current_zones - previous_zones:
|
||||||
|
LOGGER.debug("New zones found: %s", ", ".join(new_zone_ids))
|
||||||
|
new_zones = [
|
||||||
|
(
|
||||||
|
self.data.zones[zone_id],
|
||||||
|
self.data.zone_id_to_controller[zone_id],
|
||||||
|
)
|
||||||
|
for zone_id in map(int, new_zone_ids)
|
||||||
|
]
|
||||||
|
for new_zone_callback in self.new_zones_callbacks:
|
||||||
|
new_zone_callback(new_zones)
|
||||||
|
|
||||||
|
|
||||||
class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
||||||
"""Data Update Coordinator for Hydrawise Water Use.
|
"""Data Update Coordinator for Hydrawise Water Use.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity import EntityDescription
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .const import DOMAIN, MANUFACTURER
|
from .const import DOMAIN, MANUFACTURER, MODEL_ZONE
|
||||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +40,9 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]):
|
|||||||
identifiers={(DOMAIN, self._device_id)},
|
identifiers={(DOMAIN, self._device_id)},
|
||||||
name=self.zone.name if zone_id is not None else controller.name,
|
name=self.zone.name if zone_id is not None else controller.name,
|
||||||
model=(
|
model=(
|
||||||
"Zone" if zone_id is not None else controller.hardware.model.description
|
MODEL_ZONE
|
||||||
|
if zone_id is not None
|
||||||
|
else controller.hardware.model.description
|
||||||
),
|
),
|
||||||
manufacturer=MANUFACTURER,
|
manufacturer=MANUFACTURER,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydrawise.schema import ControllerWaterUseSummary
|
from pydrawise.schema import Controller, ControllerWaterUseSummary, Zone
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@@ -31,7 +31,9 @@ class HydrawiseSensorEntityDescription(SensorEntityDescription):
|
|||||||
|
|
||||||
|
|
||||||
def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary:
|
def _get_water_use(sensor: HydrawiseSensor) -> ControllerWaterUseSummary:
|
||||||
return sensor.coordinator.data.daily_water_summary[sensor.controller.id]
|
return sensor.coordinator.data.daily_water_summary.get(
|
||||||
|
sensor.controller.id, ControllerWaterUseSummary()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||||
@@ -133,44 +135,65 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Hydrawise sensor platform."""
|
"""Set up the Hydrawise sensor platform."""
|
||||||
coordinators = config_entry.runtime_data
|
coordinators = config_entry.runtime_data
|
||||||
entities: list[HydrawiseSensor] = []
|
|
||||||
for controller in coordinators.main.data.controllers.values():
|
def _has_flow_sensor(controller: Controller) -> bool:
|
||||||
entities.extend(
|
daily_water_use_summary = coordinators.water_use.data.daily_water_summary.get(
|
||||||
HydrawiseSensor(coordinators.water_use, description, controller)
|
controller.id, ControllerWaterUseSummary()
|
||||||
for description in WATER_USE_CONTROLLER_SENSORS
|
|
||||||
)
|
)
|
||||||
entities.extend(
|
return daily_water_use_summary.total_use is not None
|
||||||
HydrawiseSensor(
|
|
||||||
coordinators.water_use, description, controller, zone_id=zone.id
|
def _add_new_controllers(controllers: Iterable[Controller]) -> None:
|
||||||
)
|
entities: list[HydrawiseSensor] = []
|
||||||
for zone in controller.zones
|
for controller in controllers:
|
||||||
for description in WATER_USE_ZONE_SENSORS
|
|
||||||
)
|
|
||||||
entities.extend(
|
|
||||||
HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id)
|
|
||||||
for zone in controller.zones
|
|
||||||
for description in ZONE_SENSORS
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
coordinators.water_use.data.daily_water_summary[controller.id].total_use
|
|
||||||
is not None
|
|
||||||
):
|
|
||||||
# we have a flow sensor for this controller
|
|
||||||
entities.extend(
|
entities.extend(
|
||||||
HydrawiseSensor(coordinators.water_use, description, controller)
|
HydrawiseSensor(coordinators.water_use, description, controller)
|
||||||
for description in FLOW_CONTROLLER_SENSORS
|
for description in WATER_USE_CONTROLLER_SENSORS
|
||||||
)
|
)
|
||||||
entities.extend(
|
if _has_flow_sensor(controller):
|
||||||
|
entities.extend(
|
||||||
|
HydrawiseSensor(coordinators.water_use, description, controller)
|
||||||
|
for description in FLOW_CONTROLLER_SENSORS
|
||||||
|
)
|
||||||
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
|
||||||
|
async_add_entities(
|
||||||
|
[
|
||||||
|
HydrawiseSensor(
|
||||||
|
coordinators.water_use, description, controller, zone_id=zone.id
|
||||||
|
)
|
||||||
|
for zone, controller in zones
|
||||||
|
for description in WATER_USE_ZONE_SENSORS
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
HydrawiseSensor(
|
||||||
|
coordinators.main, description, controller, zone_id=zone.id
|
||||||
|
)
|
||||||
|
for zone, controller in zones
|
||||||
|
for description in ZONE_SENSORS
|
||||||
|
]
|
||||||
|
+ [
|
||||||
HydrawiseSensor(
|
HydrawiseSensor(
|
||||||
coordinators.water_use,
|
coordinators.water_use,
|
||||||
description,
|
description,
|
||||||
controller,
|
controller,
|
||||||
zone_id=zone.id,
|
zone_id=zone.id,
|
||||||
)
|
)
|
||||||
for zone in controller.zones
|
for zone, controller in zones
|
||||||
for description in FLOW_ZONE_SENSORS
|
for description in FLOW_ZONE_SENSORS
|
||||||
)
|
if _has_flow_sensor(controller)
|
||||||
async_add_entities(entities)
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
_add_new_controllers(coordinators.main.data.controllers.values())
|
||||||
|
_add_new_zones(
|
||||||
|
[
|
||||||
|
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
|
||||||
|
for zone in coordinators.main.data.zones.values()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
coordinators.main.new_controllers_callbacks.append(_add_new_controllers)
|
||||||
|
coordinators.main.new_zones_callbacks.append(_add_new_zones)
|
||||||
|
|
||||||
|
|
||||||
class HydrawiseSensor(HydrawiseEntity, SensorEntity):
|
class HydrawiseSensor(HydrawiseEntity, SensorEntity):
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine, Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydrawise import HydrawiseBase, Zone
|
from pydrawise import Controller, HydrawiseBase, Zone
|
||||||
|
|
||||||
from homeassistant.components.switch import (
|
from homeassistant.components.switch import (
|
||||||
SwitchDeviceClass,
|
SwitchDeviceClass,
|
||||||
@@ -66,12 +66,21 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Hydrawise switch platform."""
|
"""Set up the Hydrawise switch platform."""
|
||||||
coordinators = config_entry.runtime_data
|
coordinators = config_entry.runtime_data
|
||||||
async_add_entities(
|
|
||||||
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
|
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
|
||||||
for controller in coordinators.main.data.controllers.values()
|
async_add_entities(
|
||||||
for zone in controller.zones
|
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
|
||||||
for description in SWITCH_TYPES
|
for zone, controller in zones
|
||||||
|
for description in SWITCH_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
|
_add_new_zones(
|
||||||
|
[
|
||||||
|
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
|
||||||
|
for zone in coordinators.main.data.zones.values()
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
coordinators.main.new_zones_callbacks.append(_add_new_zones)
|
||||||
|
|
||||||
|
|
||||||
class HydrawiseSwitch(HydrawiseEntity, SwitchEntity):
|
class HydrawiseSwitch(HydrawiseEntity, SwitchEntity):
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydrawise.schema import Zone
|
from pydrawise.schema import Controller, Zone
|
||||||
|
|
||||||
from homeassistant.components.valve import (
|
from homeassistant.components.valve import (
|
||||||
ValveDeviceClass,
|
ValveDeviceClass,
|
||||||
@@ -33,12 +34,21 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Hydrawise valve platform."""
|
"""Set up the Hydrawise valve platform."""
|
||||||
coordinators = config_entry.runtime_data
|
coordinators = config_entry.runtime_data
|
||||||
async_add_entities(
|
|
||||||
HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id)
|
def _add_new_zones(zones: Iterable[tuple[Zone, Controller]]) -> None:
|
||||||
for controller in coordinators.main.data.controllers.values()
|
async_add_entities(
|
||||||
for zone in controller.zones
|
HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id)
|
||||||
for description in VALVE_TYPES
|
for zone, controller in zones
|
||||||
|
for description in VALVE_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
|
_add_new_zones(
|
||||||
|
[
|
||||||
|
(zone, coordinators.main.data.zone_id_to_controller[zone.id])
|
||||||
|
for zone in coordinators.main.data.zones.values()
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
coordinators.main.new_zones_callbacks.append(_add_new_zones)
|
||||||
|
|
||||||
|
|
||||||
class HydrawiseValve(HydrawiseEntity, ValveEntity):
|
class HydrawiseValve(HydrawiseEntity, ValveEntity):
|
||||||
|
|||||||
@@ -16,13 +16,25 @@ from homeassistant.const import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator
|
from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator
|
||||||
|
from .services import async_setup_services
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE]
|
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
"""Set up immich integration."""
|
||||||
|
await async_setup_services(hass)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
|
||||||
"""Set up Immich from a config entry."""
|
"""Set up Immich from a config entry."""
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,10 @@
|
|||||||
"default": "mdi:file-video"
|
"default": "mdi:file-video"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"upload_file": {
|
||||||
|
"service": "mdi:upload"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse
|
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse
|
||||||
|
from aioimmich.assets.models import ImmichAsset
|
||||||
from aioimmich.exceptions import ImmichError
|
from aioimmich.exceptions import ImmichError
|
||||||
|
|
||||||
from homeassistant.components.http import HomeAssistantView
|
from homeassistant.components.http import HomeAssistantView
|
||||||
@@ -83,6 +84,10 @@ class ImmichMediaSource(MediaSource):
|
|||||||
self, item: MediaSourceItem, entries: list[ConfigEntry]
|
self, item: MediaSourceItem, entries: list[ConfigEntry]
|
||||||
) -> list[BrowseMediaSource]:
|
) -> list[BrowseMediaSource]:
|
||||||
"""Handle browsing different immich instances."""
|
"""Handle browsing different immich instances."""
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# root level, render immich instances
|
||||||
|
# --------------------------------------------------------
|
||||||
if not item.identifier:
|
if not item.identifier:
|
||||||
LOGGER.debug("Render all Immich instances")
|
LOGGER.debug("Render all Immich instances")
|
||||||
return [
|
return [
|
||||||
@@ -97,6 +102,10 @@ class ImmichMediaSource(MediaSource):
|
|||||||
)
|
)
|
||||||
for entry in entries
|
for entry in entries
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 1st level, render collections overview
|
||||||
|
# --------------------------------------------------------
|
||||||
identifier = ImmichMediaSourceIdentifier(item.identifier)
|
identifier = ImmichMediaSourceIdentifier(item.identifier)
|
||||||
entry: ImmichConfigEntry | None = (
|
entry: ImmichConfigEntry | None = (
|
||||||
self.hass.config_entries.async_entry_for_domain_unique_id(
|
self.hass.config_entries.async_entry_for_domain_unique_id(
|
||||||
@@ -111,50 +120,127 @@ class ImmichMediaSource(MediaSource):
|
|||||||
return [
|
return [
|
||||||
BrowseMediaSource(
|
BrowseMediaSource(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
identifier=f"{identifier.unique_id}|albums",
|
identifier=f"{identifier.unique_id}|{collection}",
|
||||||
media_class=MediaClass.DIRECTORY,
|
media_class=MediaClass.DIRECTORY,
|
||||||
media_content_type=MediaClass.IMAGE,
|
media_content_type=MediaClass.IMAGE,
|
||||||
title="albums",
|
title=collection,
|
||||||
can_play=False,
|
can_play=False,
|
||||||
can_expand=True,
|
can_expand=True,
|
||||||
)
|
)
|
||||||
|
for collection in ("albums", "people", "tags")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 2nd level, render collection
|
||||||
|
# --------------------------------------------------------
|
||||||
if identifier.collection_id is None:
|
if identifier.collection_id is None:
|
||||||
LOGGER.debug("Render all albums for %s", entry.title)
|
if identifier.collection == "albums":
|
||||||
|
LOGGER.debug("Render all albums for %s", entry.title)
|
||||||
|
try:
|
||||||
|
albums = await immich_api.albums.async_get_all_albums()
|
||||||
|
except ImmichError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"{identifier.unique_id}|albums|{album.album_id}",
|
||||||
|
media_class=MediaClass.DIRECTORY,
|
||||||
|
media_content_type=MediaClass.IMAGE,
|
||||||
|
title=album.album_name,
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg",
|
||||||
|
)
|
||||||
|
for album in albums
|
||||||
|
]
|
||||||
|
|
||||||
|
if identifier.collection == "tags":
|
||||||
|
LOGGER.debug("Render all tags for %s", entry.title)
|
||||||
|
try:
|
||||||
|
tags = await immich_api.tags.async_get_all_tags()
|
||||||
|
except ImmichError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"{identifier.unique_id}|tags|{tag.tag_id}",
|
||||||
|
media_class=MediaClass.DIRECTORY,
|
||||||
|
media_content_type=MediaClass.IMAGE,
|
||||||
|
title=tag.name,
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
)
|
||||||
|
for tag in tags
|
||||||
|
]
|
||||||
|
|
||||||
|
if identifier.collection == "people":
|
||||||
|
LOGGER.debug("Render all people for %s", entry.title)
|
||||||
|
try:
|
||||||
|
people = await immich_api.people.async_get_all_people()
|
||||||
|
except ImmichError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
BrowseMediaSource(
|
||||||
|
domain=DOMAIN,
|
||||||
|
identifier=f"{identifier.unique_id}|people|{person.person_id}",
|
||||||
|
media_class=MediaClass.DIRECTORY,
|
||||||
|
media_content_type=MediaClass.IMAGE,
|
||||||
|
title=person.name,
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
thumbnail=f"/immich/{identifier.unique_id}/{person.person_id}/person/image/jpg",
|
||||||
|
)
|
||||||
|
for person in people
|
||||||
|
]
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# final level, render assets
|
||||||
|
# --------------------------------------------------------
|
||||||
|
assert identifier.collection_id is not None
|
||||||
|
assets: list[ImmichAsset] = []
|
||||||
|
if identifier.collection == "albums":
|
||||||
|
LOGGER.debug(
|
||||||
|
"Render all assets of album %s for %s",
|
||||||
|
identifier.collection_id,
|
||||||
|
entry.title,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
albums = await immich_api.albums.async_get_all_albums()
|
album_info = await immich_api.albums.async_get_album_info(
|
||||||
|
identifier.collection_id
|
||||||
|
)
|
||||||
|
assets = album_info.assets
|
||||||
except ImmichError:
|
except ImmichError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return [
|
elif identifier.collection == "tags":
|
||||||
BrowseMediaSource(
|
LOGGER.debug(
|
||||||
domain=DOMAIN,
|
"Render all assets with tag %s",
|
||||||
identifier=f"{identifier.unique_id}|albums|{album.album_id}",
|
identifier.collection_id,
|
||||||
media_class=MediaClass.DIRECTORY,
|
|
||||||
media_content_type=MediaClass.IMAGE,
|
|
||||||
title=album.album_name,
|
|
||||||
can_play=False,
|
|
||||||
can_expand=True,
|
|
||||||
thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg",
|
|
||||||
)
|
|
||||||
for album in albums
|
|
||||||
]
|
|
||||||
|
|
||||||
LOGGER.debug(
|
|
||||||
"Render all assets of album %s for %s",
|
|
||||||
identifier.collection_id,
|
|
||||||
entry.title,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
album_info = await immich_api.albums.async_get_album_info(
|
|
||||||
identifier.collection_id
|
|
||||||
)
|
)
|
||||||
except ImmichError:
|
try:
|
||||||
return []
|
assets = await immich_api.search.async_get_all_by_tag_ids(
|
||||||
|
[identifier.collection_id]
|
||||||
|
)
|
||||||
|
except ImmichError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
elif identifier.collection == "people":
|
||||||
|
LOGGER.debug(
|
||||||
|
"Render all assets for person %s",
|
||||||
|
identifier.collection_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assets = await immich_api.search.async_get_all_by_person_ids(
|
||||||
|
[identifier.collection_id]
|
||||||
|
)
|
||||||
|
except ImmichError:
|
||||||
|
return []
|
||||||
|
|
||||||
ret: list[BrowseMediaSource] = []
|
ret: list[BrowseMediaSource] = []
|
||||||
for asset in album_info.assets:
|
for asset in assets:
|
||||||
if not (mime_type := asset.original_mime_type) or not mime_type.startswith(
|
if not (mime_type := asset.original_mime_type) or not mime_type.startswith(
|
||||||
("image/", "video/")
|
("image/", "video/")
|
||||||
):
|
):
|
||||||
@@ -173,7 +259,8 @@ class ImmichMediaSource(MediaSource):
|
|||||||
BrowseMediaSource(
|
BrowseMediaSource(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
identifier=(
|
identifier=(
|
||||||
f"{identifier.unique_id}|albums|"
|
f"{identifier.unique_id}|"
|
||||||
|
f"{identifier.collection}|"
|
||||||
f"{identifier.collection_id}|"
|
f"{identifier.collection_id}|"
|
||||||
f"{asset.asset_id}|"
|
f"{asset.asset_id}|"
|
||||||
f"{asset.original_file_name}|"
|
f"{asset.original_file_name}|"
|
||||||
@@ -257,7 +344,10 @@ class ImmichMediaView(HomeAssistantView):
|
|||||||
|
|
||||||
# web response for images
|
# web response for images
|
||||||
try:
|
try:
|
||||||
image = await immich_api.assets.async_view_asset(asset_id, size)
|
if size == "person":
|
||||||
|
image = await immich_api.people.async_get_person_thumbnail(asset_id)
|
||||||
|
else:
|
||||||
|
image = await immich_api.assets.async_view_asset(asset_id, size)
|
||||||
except ImmichError as exc:
|
except ImmichError as exc:
|
||||||
raise HTTPNotFound from exc
|
raise HTTPNotFound from exc
|
||||||
return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}")
|
return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}")
|
||||||
|
|||||||
98
homeassistant/components/immich/services.py
Normal file
98
homeassistant/components/immich/services.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Services for the Immich integration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aioimmich.exceptions import ImmichError
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.media_source import async_resolve_media
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.helpers.selector import MediaSelector
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import ImmichConfigEntry
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONF_ALBUM_ID = "album_id"
|
||||||
|
CONF_CONFIG_ENTRY_ID = "config_entry_id"
|
||||||
|
CONF_FILE = "file"
|
||||||
|
|
||||||
|
SERVICE_UPLOAD_FILE = "upload_file"
|
||||||
|
SERVICE_SCHEMA_UPLOAD_FILE = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_CONFIG_ENTRY_ID): str,
|
||||||
|
vol.Required(CONF_FILE): MediaSelector({"accept": ["image/*", "video/*"]}),
|
||||||
|
vol.Optional(CONF_ALBUM_ID): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_upload_file(service_call: ServiceCall) -> None:
|
||||||
|
"""Call immich upload file service."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Executing service %s with arguments %s",
|
||||||
|
service_call.service,
|
||||||
|
service_call.data,
|
||||||
|
)
|
||||||
|
hass = service_call.hass
|
||||||
|
target_entry: ImmichConfigEntry | None = hass.config_entries.async_get_entry(
|
||||||
|
service_call.data[CONF_CONFIG_ENTRY_ID]
|
||||||
|
)
|
||||||
|
source_media_id = service_call.data[CONF_FILE]["media_content_id"]
|
||||||
|
|
||||||
|
if not target_entry:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="config_entry_not_found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if target_entry.state is not ConfigEntryState.LOADED:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="config_entry_not_loaded",
|
||||||
|
)
|
||||||
|
|
||||||
|
media = await async_resolve_media(hass, source_media_id, None)
|
||||||
|
if media.path is None:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN, translation_key="only_local_media_supported"
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator = target_entry.runtime_data
|
||||||
|
|
||||||
|
if target_album := service_call.data.get(CONF_ALBUM_ID):
|
||||||
|
try:
|
||||||
|
await coordinator.api.albums.async_get_album_info(target_album, True)
|
||||||
|
except ImmichError as ex:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="album_not_found",
|
||||||
|
translation_placeholders={"album_id": target_album, "error": str(ex)},
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
try:
|
||||||
|
upload_result = await coordinator.api.assets.async_upload_asset(str(media.path))
|
||||||
|
if target_album:
|
||||||
|
await coordinator.api.albums.async_add_assets_to_album(
|
||||||
|
target_album, [upload_result.asset_id]
|
||||||
|
)
|
||||||
|
except ImmichError as ex:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="upload_failed",
|
||||||
|
translation_placeholders={"file": str(media.path), "error": str(ex)},
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Set up services for immich integration."""
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_UPLOAD_FILE,
|
||||||
|
_async_upload_file,
|
||||||
|
SERVICE_SCHEMA_UPLOAD_FILE,
|
||||||
|
)
|
||||||
18
homeassistant/components/immich/services.yaml
Normal file
18
homeassistant/components/immich/services.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
upload_file:
|
||||||
|
fields:
|
||||||
|
config_entry_id:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
config_entry:
|
||||||
|
integration: immich
|
||||||
|
file:
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
media:
|
||||||
|
accept:
|
||||||
|
- image/*
|
||||||
|
- video/*
|
||||||
|
album_id:
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
@@ -74,5 +74,42 @@
|
|||||||
"name": "Version"
|
"name": "Version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"upload_file": {
|
||||||
|
"name": "Upload file",
|
||||||
|
"description": "Uploads a file to your Immich instance.",
|
||||||
|
"fields": {
|
||||||
|
"config_entry_id": {
|
||||||
|
"name": "Immich instance",
|
||||||
|
"description": "The Immich instance where to upload the file."
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"name": "File",
|
||||||
|
"description": "The path to the file to be uploaded."
|
||||||
|
},
|
||||||
|
"album_id": {
|
||||||
|
"name": "Album ID",
|
||||||
|
"description": "The album in which the file should be placed after uploading."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"config_entry_not_found": {
|
||||||
|
"message": "Config entry not found."
|
||||||
|
},
|
||||||
|
"config_entry_not_loaded": {
|
||||||
|
"message": "Config entry not loaded."
|
||||||
|
},
|
||||||
|
"only_local_media_supported": {
|
||||||
|
"message": "Only local media files are currently supported."
|
||||||
|
},
|
||||||
|
"album_not_found": {
|
||||||
|
"message": "Album with ID `{album_id}` not found ({error})."
|
||||||
|
},
|
||||||
|
"upload_failed": {
|
||||||
|
"message": "Upload of file `{file}` failed ({error})."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyiqvia"],
|
"loggers": ["pyiqvia"],
|
||||||
"requirements": ["numpy==2.3.0", "pyiqvia==2022.04.0"]
|
"requirements": ["numpy==2.3.2", "pyiqvia==2022.04.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ from pynecil import IronOSUpdate, Pynecil
|
|||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.typing import ConfigType
|
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
@@ -33,8 +31,6 @@ PLATFORMS: list[Platform] = [
|
|||||||
Platform.UPDATE,
|
Platform.UPDATE,
|
||||||
]
|
]
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
|
||||||
|
|
||||||
|
|
||||||
IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN)
|
IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN)
|
||||||
|
|
||||||
@@ -42,19 +38,15 @@ IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN)
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
||||||
"""Set up IronOS firmware update coordinator."""
|
|
||||||
|
|
||||||
session = async_get_clientsession(hass)
|
|
||||||
github = IronOSUpdate(session)
|
|
||||||
|
|
||||||
hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github)
|
|
||||||
await hass.data[IRON_OS_KEY].async_request_refresh()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool:
|
||||||
"""Set up IronOS from a config entry."""
|
"""Set up IronOS from a config entry."""
|
||||||
|
if IRON_OS_KEY not in hass.data:
|
||||||
|
session = async_get_clientsession(hass)
|
||||||
|
github = IronOSUpdate(session)
|
||||||
|
|
||||||
|
hass.data[IRON_OS_KEY] = IronOSFirmwareUpdateCoordinator(hass, github)
|
||||||
|
await hass.data[IRON_OS_KEY].async_request_refresh()
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
assert entry.unique_id
|
assert entry.unique_id
|
||||||
|
|
||||||
@@ -77,4 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo
|
|||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||||
|
await hass.data[IRON_OS_KEY].async_shutdown()
|
||||||
|
hass.data.pop(IRON_OS_KEY)
|
||||||
|
return unload_ok
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/israel_rail",
|
"documentation": "https://www.home-assistant.io/integrations/israel_rail",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["israelrailapi"],
|
"loggers": ["israelrailapi"],
|
||||||
"requirements": ["israel-rail-api==0.1.2"]
|
"requirements": ["israel-rail-api==0.1.3"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator
|
from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
|
Platform.BINARY_SENSOR,
|
||||||
Platform.DEVICE_TRACKER,
|
Platform.DEVICE_TRACKER,
|
||||||
Platform.SENSOR,
|
Platform.SENSOR,
|
||||||
]
|
]
|
||||||
|
|||||||
75
homeassistant/components/ituran/binary_sensor.py
Normal file
75
homeassistant/components/ituran/binary_sensor.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Binary sensors for Ituran vehicles."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from propcache.api import cached_property
|
||||||
|
from pyituran import Vehicle
|
||||||
|
|
||||||
|
from homeassistant.components.binary_sensor import (
|
||||||
|
BinarySensorDeviceClass,
|
||||||
|
BinarySensorEntity,
|
||||||
|
BinarySensorEntityDescription,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from . import IturanConfigEntry
|
||||||
|
from .coordinator import IturanDataUpdateCoordinator
|
||||||
|
from .entity import IturanBaseEntity
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class IturanBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||||
|
"""Describes Ituran binary sensor entity."""
|
||||||
|
|
||||||
|
value_fn: Callable[[Vehicle], bool]
|
||||||
|
supported_fn: Callable[[Vehicle], bool] = lambda _: True
|
||||||
|
|
||||||
|
|
||||||
|
BINARY_SENSOR_TYPES: list[IturanBinarySensorEntityDescription] = [
|
||||||
|
IturanBinarySensorEntityDescription(
|
||||||
|
key="is_charging",
|
||||||
|
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||||
|
value_fn=lambda vehicle: vehicle.is_charging,
|
||||||
|
supported_fn=lambda vehicle: vehicle.is_electric_vehicle,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: IturanConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Ituran binary sensors from config entry."""
|
||||||
|
coordinator = config_entry.runtime_data
|
||||||
|
async_add_entities(
|
||||||
|
IturanBinarySensor(coordinator, vehicle.license_plate, description)
|
||||||
|
for vehicle in coordinator.data.values()
|
||||||
|
for description in BINARY_SENSOR_TYPES
|
||||||
|
if description.supported_fn(vehicle)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
|
||||||
|
"""Ituran binary sensor."""
|
||||||
|
|
||||||
|
entity_description: IturanBinarySensorEntityDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: IturanDataUpdateCoordinator,
|
||||||
|
license_plate: str,
|
||||||
|
description: IturanBinarySensorEntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the binary sensor."""
|
||||||
|
super().__init__(coordinator, license_plate, description.key)
|
||||||
|
self.entity_description = description
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return true if the binary sensor is on."""
|
||||||
|
return self.entity_description.value_fn(self.vehicle)
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from propcache.api import cached_property
|
||||||
|
|
||||||
from homeassistant.components.device_tracker import TrackerEntity
|
from homeassistant.components.device_tracker import TrackerEntity
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
@@ -38,12 +40,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
|
|||||||
"""Initialize the device tracker."""
|
"""Initialize the device tracker."""
|
||||||
super().__init__(coordinator, license_plate, "device_tracker")
|
super().__init__(coordinator, license_plate, "device_tracker")
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def latitude(self) -> float | None:
|
def latitude(self) -> float | None:
|
||||||
"""Return latitude value of the device."""
|
"""Return latitude value of the device."""
|
||||||
return self.vehicle.gps_coordinates[0]
|
return self.vehicle.gps_coordinates[0]
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def longitude(self) -> float | None:
|
def longitude(self) -> float | None:
|
||||||
"""Return longitude value of the device."""
|
"""Return longitude value of the device."""
|
||||||
return self.vehicle.gps_coordinates[1]
|
return self.vehicle.gps_coordinates[1]
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"address": {
|
"address": {
|
||||||
"default": "mdi:map-marker"
|
"default": "mdi:map-marker"
|
||||||
},
|
},
|
||||||
|
"battery_range": {
|
||||||
|
"default": "mdi:ev-station"
|
||||||
|
},
|
||||||
"battery_voltage": {
|
"battery_voltage": {
|
||||||
"default": "mdi:car-battery"
|
"default": "mdi:car-battery"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["pyituran==0.1.4"]
|
"requirements": ["pyituran==0.1.5"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from propcache.api import cached_property
|
||||||
from pyituran import Vehicle
|
from pyituran import Vehicle
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
@@ -15,6 +16,7 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
DEGREE,
|
DEGREE,
|
||||||
|
PERCENTAGE,
|
||||||
UnitOfElectricPotential,
|
UnitOfElectricPotential,
|
||||||
UnitOfLength,
|
UnitOfLength,
|
||||||
UnitOfSpeed,
|
UnitOfSpeed,
|
||||||
@@ -33,6 +35,7 @@ class IturanSensorEntityDescription(SensorEntityDescription):
|
|||||||
"""Describes Ituran sensor entity."""
|
"""Describes Ituran sensor entity."""
|
||||||
|
|
||||||
value_fn: Callable[[Vehicle], StateType | datetime]
|
value_fn: Callable[[Vehicle], StateType | datetime]
|
||||||
|
supported_fn: Callable[[Vehicle], bool] = lambda _: True
|
||||||
|
|
||||||
|
|
||||||
SENSOR_TYPES: list[IturanSensorEntityDescription] = [
|
SENSOR_TYPES: list[IturanSensorEntityDescription] = [
|
||||||
@@ -42,6 +45,22 @@ SENSOR_TYPES: list[IturanSensorEntityDescription] = [
|
|||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
value_fn=lambda vehicle: vehicle.address,
|
value_fn=lambda vehicle: vehicle.address,
|
||||||
),
|
),
|
||||||
|
IturanSensorEntityDescription(
|
||||||
|
key="battery_level",
|
||||||
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
value_fn=lambda vehicle: vehicle.battery_level,
|
||||||
|
supported_fn=lambda vehicle: vehicle.is_electric_vehicle,
|
||||||
|
),
|
||||||
|
IturanSensorEntityDescription(
|
||||||
|
key="battery_range",
|
||||||
|
translation_key="battery_range",
|
||||||
|
device_class=SensorDeviceClass.DISTANCE,
|
||||||
|
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||||
|
suggested_display_precision=0,
|
||||||
|
value_fn=lambda vehicle: vehicle.battery_range,
|
||||||
|
supported_fn=lambda vehicle: vehicle.is_electric_vehicle,
|
||||||
|
),
|
||||||
IturanSensorEntityDescription(
|
IturanSensorEntityDescription(
|
||||||
key="battery_voltage",
|
key="battery_voltage",
|
||||||
translation_key="battery_voltage",
|
translation_key="battery_voltage",
|
||||||
@@ -92,14 +111,15 @@ async def async_setup_entry(
|
|||||||
"""Set up the Ituran sensors from config entry."""
|
"""Set up the Ituran sensors from config entry."""
|
||||||
coordinator = config_entry.runtime_data
|
coordinator = config_entry.runtime_data
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
IturanSensor(coordinator, license_plate, description)
|
IturanSensor(coordinator, vehicle.license_plate, description)
|
||||||
|
for vehicle in coordinator.data.values()
|
||||||
for description in SENSOR_TYPES
|
for description in SENSOR_TYPES
|
||||||
for license_plate in coordinator.data
|
if description.supported_fn(vehicle)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class IturanSensor(IturanBaseEntity, SensorEntity):
|
class IturanSensor(IturanBaseEntity, SensorEntity):
|
||||||
"""Ituran device tracker."""
|
"""Ituran sensor."""
|
||||||
|
|
||||||
entity_description: IturanSensorEntityDescription
|
entity_description: IturanSensorEntityDescription
|
||||||
|
|
||||||
@@ -113,7 +133,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
|
|||||||
super().__init__(coordinator, license_plate, description.key)
|
super().__init__(coordinator, license_plate, description.key)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def native_value(self) -> StateType | datetime:
|
def native_value(self) -> StateType | datetime:
|
||||||
"""Return the state of the device."""
|
"""Return the state of the device."""
|
||||||
return self.entity_description.value_fn(self.vehicle)
|
return self.entity_description.value_fn(self.vehicle)
|
||||||
|
|||||||
@@ -40,6 +40,9 @@
|
|||||||
"address": {
|
"address": {
|
||||||
"name": "Address"
|
"name": "Address"
|
||||||
},
|
},
|
||||||
|
"battery_range": {
|
||||||
|
"name": "Remaining range"
|
||||||
|
},
|
||||||
"battery_voltage": {
|
"battery_voltage": {
|
||||||
"name": "Battery voltage"
|
"name": "Battery voltage"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||||
"already_configured": "PCHK connection using the same ip address/port is already configured."
|
"already_configured": "PCHK connection using the same IP address/port is already configured."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
},
|
},
|
||||||
"relays": {
|
"relays": {
|
||||||
"name": "Relays",
|
"name": "Relays",
|
||||||
"description": "Sets the relays status.",
|
"description": "Sets the relay states.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"device_id": {
|
"device_id": {
|
||||||
"name": "[%key:common::config_flow::data::device%]",
|
"name": "[%key:common::config_flow::data::device%]",
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"name": "State",
|
"name": "State",
|
||||||
"description": "Relays states as string (1=on, 2=off, t=toggle, -=no change)."
|
"description": "Relay states as string (1=on, 2=off, t=toggle, -=no change)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
},
|
},
|
||||||
"lock_keys": {
|
"lock_keys": {
|
||||||
"name": "Lock keys",
|
"name": "Lock keys",
|
||||||
"description": "Locks keys.",
|
"description": "Sets the key lock states.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"device_id": {
|
"device_id": {
|
||||||
"name": "[%key:common::config_flow::data::device%]",
|
"name": "[%key:common::config_flow::data::device%]",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user