mirror of
https://github.com/home-assistant/core.git
synced 2025-12-06 16:08:09 +00:00
Compare commits
218 Commits
knx-data-s
...
knx-trigge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e4211fde9 | ||
|
|
e4aadd675e | ||
|
|
a47255c233 | ||
|
|
c1e7492743 | ||
|
|
63e8cf582f | ||
|
|
73f23168a2 | ||
|
|
20d8176515 | ||
|
|
c9351a022e | ||
|
|
4e8a31a4e2 | ||
|
|
2beb551db3 | ||
|
|
90cea0325f | ||
|
|
f5dd9d83ac | ||
|
|
e0484ba1ff | ||
|
|
62f758f695 | ||
|
|
20d2115122 | ||
|
|
2bed7afe0e | ||
|
|
2eeac5f9c9 | ||
|
|
a35af9097b | ||
|
|
710b7c2b41 | ||
|
|
c058810461 | ||
|
|
0ccfd77fef | ||
|
|
4805b33a27 | ||
|
|
c333036959 | ||
|
|
002eed24f1 | ||
|
|
9a9f8271b3 | ||
|
|
855d7c6e16 | ||
|
|
837de55ce6 | ||
|
|
81ed259c59 | ||
|
|
5f00452c96 | ||
|
|
06a44de3fb | ||
|
|
11b4d75cfb | ||
|
|
845c9ee05f | ||
|
|
dedf6b1223 | ||
|
|
c1b631d049 | ||
|
|
6cc645bc6c | ||
|
|
f10866395d | ||
|
|
df68448b27 | ||
|
|
bf7b96622c | ||
|
|
53c644ac5b | ||
|
|
5e9107e52b | ||
|
|
ca9ea267c7 | ||
|
|
f1bfe2f11e | ||
|
|
34cc6036b9 | ||
|
|
2facfbadaa | ||
|
|
1b1dface35 | ||
|
|
3c0cfd5e0c | ||
|
|
69f66ffef4 | ||
|
|
d2c3543b6c | ||
|
|
ca4a2d441e | ||
|
|
f42fe9cee3 | ||
|
|
b67873f40c | ||
|
|
ecc08fce0f | ||
|
|
375f536b15 | ||
|
|
5cff813eac | ||
|
|
c2ce322af1 | ||
|
|
079f306a65 | ||
|
|
9129665c64 | ||
|
|
7bf60f9d15 | ||
|
|
7dddd89ac2 | ||
|
|
a2322ef3c7 | ||
|
|
5f6ef2109a | ||
|
|
44f0a8899a | ||
|
|
78fa29b41f | ||
|
|
06d4f085c0 | ||
|
|
f4e11da1a6 | ||
|
|
e0238b5ab2 | ||
|
|
352f3813e2 | ||
|
|
b1399a5541 | ||
|
|
316cddec86 | ||
|
|
2f71aec26f | ||
|
|
aa72b76ee7 | ||
|
|
e009898107 | ||
|
|
ceb13e70b9 | ||
|
|
498a80ac7f | ||
|
|
a9deb2a08a | ||
|
|
0d26d22986 | ||
|
|
062366966b | ||
|
|
1b8a50e80a | ||
|
|
59761385f0 | ||
|
|
6536d348e5 | ||
|
|
c157c83d54 | ||
|
|
77425cc40f | ||
|
|
c4b67329c3 | ||
|
|
c1f8c89bd0 | ||
|
|
b1bf6f5678 | ||
|
|
d347136188 | ||
|
|
a4319f3bf8 | ||
|
|
db27aee62a | ||
|
|
a7446b3da9 | ||
|
|
7fc5464621 | ||
|
|
a00b50c195 | ||
|
|
738fb59efa | ||
|
|
04e512a48e | ||
|
|
c63aca2d9b | ||
|
|
c95203e095 | ||
|
|
259235ceeb | ||
|
|
c7f1729300 | ||
|
|
065329e668 | ||
|
|
a93ed69fe4 | ||
|
|
189497622d | ||
|
|
a466fc4a01 | ||
|
|
8a968b5d0e | ||
|
|
3baee5c4ac | ||
|
|
f624a43770 | ||
|
|
242935774b | ||
|
|
051ad5878f | ||
|
|
b2156c1d4c | ||
|
|
7d4394f7ed | ||
|
|
4df172374c | ||
|
|
c97755472e | ||
|
|
ebc9060b01 | ||
|
|
bbcc2a94b3 | ||
|
|
692188fa85 | ||
|
|
2c993ea5a2 | ||
|
|
c765776726 | ||
|
|
723365d8e6 | ||
|
|
3d8e136049 | ||
|
|
2fe9fc7ee3 | ||
|
|
e11e31a1a0 | ||
|
|
989407047d | ||
|
|
6d3087c5a4 | ||
|
|
9bd3c35231 | ||
|
|
b7e97971cf | ||
|
|
4d232c63f8 | ||
|
|
6fc000ee2a | ||
|
|
623d3ecde5 | ||
|
|
0fbb3215b4 | ||
|
|
c82ce1ff89 | ||
|
|
8c891a20e5 | ||
|
|
97c50b2d86 | ||
|
|
ef4062a565 | ||
|
|
e31cce5d9b | ||
|
|
21f6b9a53a | ||
|
|
047e549112 | ||
|
|
4c4aecd9a7 | ||
|
|
733496ff3f | ||
|
|
f682e93243 | ||
|
|
c8fa5b0290 | ||
|
|
8ff2a22664 | ||
|
|
c174ab2d96 | ||
|
|
10f0ff7bd7 | ||
|
|
4a4eb33bf7 | ||
|
|
8199c4e5de | ||
|
|
0bfa8318a7 | ||
|
|
ed66a4920c | ||
|
|
f51007c448 | ||
|
|
bd44402b04 | ||
|
|
99fa92d966 | ||
|
|
1cb8f19020 | ||
|
|
81cdbdd4df | ||
|
|
c82706eaf5 | ||
|
|
07f9bec8b6 | ||
|
|
33d576234b | ||
|
|
9e2b4615f1 | ||
|
|
a46dc7e05f | ||
|
|
7dd9953345 | ||
|
|
1145026190 | ||
|
|
d8f9574bc3 | ||
|
|
e91f8d3a81 | ||
|
|
8c0fd0565e | ||
|
|
cc620fc0f8 | ||
|
|
5a89332680 | ||
|
|
1831c5e249 | ||
|
|
dddd2503ea | ||
|
|
91ba510a1e | ||
|
|
6e5e739496 | ||
|
|
6b39eb069c | ||
|
|
847c332c70 | ||
|
|
1a19f3b527 | ||
|
|
8110935d2d | ||
|
|
af69da94f5 | ||
|
|
c1cf17d4db | ||
|
|
6079637909 | ||
|
|
9268e12b20 | ||
|
|
d07993f4a4 | ||
|
|
441cb4197c | ||
|
|
d2a095588d | ||
|
|
f2578da7db | ||
|
|
22200d6804 | ||
|
|
8a4e5c3a28 | ||
|
|
30f31c7d8c | ||
|
|
232c4255a1 | ||
|
|
236f7cd22c | ||
|
|
5948ff2e31 | ||
|
|
380127bc70 | ||
|
|
b6a1e8251a | ||
|
|
c20236717c | ||
|
|
1fd9feaace | ||
|
|
7ce072b4dc | ||
|
|
45aa0399c7 | ||
|
|
d82b3871c1 | ||
|
|
8f6d1162e5 | ||
|
|
dafce97341 | ||
|
|
ffd5d33bbc | ||
|
|
bac32bc379 | ||
|
|
6344837009 | ||
|
|
9079ff5ea8 | ||
|
|
cd646aea11 | ||
|
|
b3a93d9fab | ||
|
|
db98fb138b | ||
|
|
348c8bca7c | ||
|
|
e30707ad5e | ||
|
|
3fa4dcb980 | ||
|
|
57835efc9d | ||
|
|
f8d5a8bc58 | ||
|
|
3f1f8da6f5 | ||
|
|
55613f56b6 | ||
|
|
3ee2a78663 | ||
|
|
814a0c4cc9 | ||
|
|
71b674d8f1 | ||
|
|
c952fc5e31 | ||
|
|
8c3d40a348 | ||
|
|
2451dfb63d | ||
|
|
8e5921eab6 | ||
|
|
bc730da9b1 | ||
|
|
28b7ebea6e | ||
|
|
cfa447c7a9 | ||
|
|
f64c870e42 |
@@ -27,7 +27,6 @@
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.pylint",
|
||||
"ms-python.vscode-pylance",
|
||||
"visualstudioexptteam.vscodeintellicode",
|
||||
"redhat.vscode-yaml",
|
||||
"esbenp.prettier-vscode",
|
||||
"GitHub.vscode-pull-request-github",
|
||||
|
||||
16
.github/workflows/builder.yml
vendored
16
.github/workflows/builder.yml
vendored
@@ -416,9 +416,19 @@ jobs:
|
||||
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Copying ${arch} image to DockerHub..."
|
||||
docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
sleep 10
|
||||
if [ "${attempt}" -eq 3 ]; then
|
||||
echo "Failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
|
||||
done
|
||||
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -571,6 +571,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/generic_hygrostat/ @Shulyaka
|
||||
/homeassistant/components/geniushub/ @manzanotti
|
||||
/tests/components/geniushub/ @manzanotti
|
||||
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
|
||||
/homeassistant/components/geo_json_events/ @exxamalte
|
||||
/tests/components/geo_json_events/ @exxamalte
|
||||
/homeassistant/components/geo_location/ @home-assistant/core
|
||||
@@ -1803,6 +1805,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/weatherflow_cloud/ @jeeftor
|
||||
/homeassistant/components/weatherkit/ @tjhorner
|
||||
/tests/components/weatherkit/ @tjhorner
|
||||
/homeassistant/components/web_rtc/ @home-assistant/core
|
||||
/tests/components/web_rtc/ @home-assistant/core
|
||||
/homeassistant/components/webdav/ @jpbede
|
||||
/tests/components/webdav/ @jpbede
|
||||
/homeassistant/components/webhook/ @home-assistant/core
|
||||
|
||||
@@ -30,6 +30,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_PASSWORD): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -68,34 +69,19 @@ class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.hass,
|
||||
cookie_jar=CookieJar(quote_cookie=False),
|
||||
),
|
||||
account_number=user_input.get(CONF_ACCOUNT_NUMBER),
|
||||
account_number=user_input[CONF_ACCOUNT_NUMBER],
|
||||
)
|
||||
)
|
||||
if isinstance(validation_response, BaseAuth):
|
||||
account_number = (
|
||||
user_input.get(CONF_ACCOUNT_NUMBER)
|
||||
or validation_response.account_number
|
||||
)
|
||||
await self.async_set_unique_id(account_number)
|
||||
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=account_number,
|
||||
title=user_input[CONF_ACCOUNT_NUMBER],
|
||||
data={
|
||||
**user_input,
|
||||
CONF_ACCESS_TOKEN: validation_response.refresh_token,
|
||||
CONF_ACCOUNT_NUMBER: account_number,
|
||||
},
|
||||
)
|
||||
if validation_response == "smart_meter_unavailable":
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
|
||||
}
|
||||
),
|
||||
errors={"base": validation_response},
|
||||
)
|
||||
errors["base"] = validation_response
|
||||
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyanglianwater"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyanglianwater==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -21,29 +21,29 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import DOMAIN
|
||||
from .websocket import BangOlufsenWebsocket
|
||||
from .websocket import BeoWebsocket
|
||||
|
||||
|
||||
@dataclass
|
||||
class BangOlufsenData:
|
||||
class BeoData:
|
||||
"""Dataclass for API client and WebSocket client."""
|
||||
|
||||
websocket: BangOlufsenWebsocket
|
||||
websocket: BeoWebsocket
|
||||
client: MozartClient
|
||||
|
||||
|
||||
type BangOlufsenConfigEntry = ConfigEntry[BangOlufsenData]
|
||||
type BeoConfigEntry = ConfigEntry[BeoData]
|
||||
|
||||
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
|
||||
# Remove casts to str
|
||||
assert entry.unique_id
|
||||
|
||||
# Create device now as BangOlufsenWebsocket needs a device for debug logging, firing events etc.
|
||||
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
@@ -68,10 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
|
||||
await client.close_api_client()
|
||||
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
|
||||
|
||||
websocket = BangOlufsenWebsocket(hass, entry, client)
|
||||
websocket = BeoWebsocket(hass, entry, client)
|
||||
|
||||
# Add the websocket and API client
|
||||
entry.runtime_data = BangOlufsenData(websocket, client)
|
||||
entry.runtime_data = BeoData(websocket, client)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -82,9 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BangOlufsenConfigEntry)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: BangOlufsenConfigEntry
|
||||
) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
# Close the API client and WebSocket notification listener
|
||||
entry.runtime_data.client.disconnect_notifications()
|
||||
|
||||
@@ -47,7 +47,7 @@ _exception_map = {
|
||||
}
|
||||
|
||||
|
||||
class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
_beolink_jid = ""
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
|
||||
|
||||
class BangOlufsenSource:
|
||||
class BeoSource:
|
||||
"""Class used for associating device source ids with friendly names. May not include all sources."""
|
||||
|
||||
DEEZER: Final[Source] = Source(name="Deezer", id="deezer")
|
||||
@@ -26,7 +26,7 @@ class BangOlufsenSource:
|
||||
URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer")
|
||||
|
||||
|
||||
BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
|
||||
BEO_STATES: dict[str, MediaPlayerState] = {
|
||||
# Dict used for translating device states to Home Assistant states.
|
||||
"started": MediaPlayerState.PLAYING,
|
||||
"buffering": MediaPlayerState.PLAYING,
|
||||
@@ -40,19 +40,19 @@ BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
|
||||
}
|
||||
|
||||
# Dict used for translating Home Assistant settings to device repeat settings.
|
||||
BANG_OLUFSEN_REPEAT_FROM_HA: dict[RepeatMode, str] = {
|
||||
BEO_REPEAT_FROM_HA: dict[RepeatMode, str] = {
|
||||
RepeatMode.ALL: "all",
|
||||
RepeatMode.ONE: "track",
|
||||
RepeatMode.OFF: "none",
|
||||
}
|
||||
# Dict used for translating device repeat settings to Home Assistant settings.
|
||||
BANG_OLUFSEN_REPEAT_TO_HA: dict[str, RepeatMode] = {
|
||||
value: key for key, value in BANG_OLUFSEN_REPEAT_FROM_HA.items()
|
||||
BEO_REPEAT_TO_HA: dict[str, RepeatMode] = {
|
||||
value: key for key, value in BEO_REPEAT_FROM_HA.items()
|
||||
}
|
||||
|
||||
|
||||
# Media types for play_media
|
||||
class BangOlufsenMediaType(StrEnum):
|
||||
class BeoMediaType(StrEnum):
|
||||
"""Bang & Olufsen specific media types."""
|
||||
|
||||
FAVOURITE = "favourite"
|
||||
@@ -63,7 +63,7 @@ class BangOlufsenMediaType(StrEnum):
|
||||
OVERLAY_TTS = "overlay_tts"
|
||||
|
||||
|
||||
class BangOlufsenModel(StrEnum):
|
||||
class BeoModel(StrEnum):
|
||||
"""Enum for compatible model names."""
|
||||
|
||||
# Mozart devices
|
||||
@@ -82,7 +82,7 @@ class BangOlufsenModel(StrEnum):
|
||||
BEOREMOTE_ONE = "Beoremote One"
|
||||
|
||||
|
||||
class BangOlufsenAttribute(StrEnum):
|
||||
class BeoAttribute(StrEnum):
|
||||
"""Enum for extra_state_attribute keys."""
|
||||
|
||||
BEOLINK = "beolink"
|
||||
@@ -93,7 +93,7 @@ class BangOlufsenAttribute(StrEnum):
|
||||
|
||||
|
||||
# Physical "buttons" on devices
|
||||
class BangOlufsenButtons(StrEnum):
|
||||
class BeoButtons(StrEnum):
|
||||
"""Enum for device buttons."""
|
||||
|
||||
BLUETOOTH = "Bluetooth"
|
||||
@@ -140,7 +140,7 @@ class WebsocketNotification(StrEnum):
|
||||
DOMAIN: Final[str] = "bang_olufsen"
|
||||
|
||||
# Default values for configuration.
|
||||
DEFAULT_MODEL: Final[str] = BangOlufsenModel.BEOSOUND_BALANCE
|
||||
DEFAULT_MODEL: Final[str] = BeoModel.BEOSOUND_BALANCE
|
||||
|
||||
# Configuration.
|
||||
CONF_SERIAL_NUMBER: Final = "serial_number"
|
||||
@@ -148,7 +148,7 @@ CONF_BEOLINK_JID: Final = "jid"
|
||||
|
||||
# Models to choose from in manual configuration.
|
||||
SELECTABLE_MODELS: list[str] = [
|
||||
model.value for model in BangOlufsenModel if model != BangOlufsenModel.BEOREMOTE_ONE
|
||||
model.value for model in BeoModel if model != BeoModel.BEOREMOTE_ONE
|
||||
]
|
||||
|
||||
MANUFACTURER: Final[str] = "Bang & Olufsen"
|
||||
@@ -160,15 +160,15 @@ ATTR_ITEM_NUMBER: Final[str] = "in"
|
||||
ATTR_FRIENDLY_NAME: Final[str] = "fn"
|
||||
|
||||
# Power states.
|
||||
BANG_OLUFSEN_ON: Final[str] = "on"
|
||||
BEO_ON: Final[str] = "on"
|
||||
|
||||
VALID_MEDIA_TYPES: Final[tuple] = (
|
||||
BangOlufsenMediaType.FAVOURITE,
|
||||
BangOlufsenMediaType.DEEZER,
|
||||
BangOlufsenMediaType.RADIO,
|
||||
BangOlufsenMediaType.TTS,
|
||||
BangOlufsenMediaType.TIDAL,
|
||||
BangOlufsenMediaType.OVERLAY_TTS,
|
||||
BeoMediaType.FAVOURITE,
|
||||
BeoMediaType.DEEZER,
|
||||
BeoMediaType.RADIO,
|
||||
BeoMediaType.TTS,
|
||||
BeoMediaType.TIDAL,
|
||||
BeoMediaType.OVERLAY_TTS,
|
||||
MediaType.MUSIC,
|
||||
MediaType.URL,
|
||||
MediaType.CHANNEL,
|
||||
@@ -246,7 +246,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
)
|
||||
|
||||
# Device events
|
||||
BANG_OLUFSEN_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
|
||||
BEO_WEBSOCKET_EVENT: Final[str] = f"{DOMAIN}_websocket_event"
|
||||
|
||||
# Dict used to translate native Bang & Olufsen event names to string.json compatible ones
|
||||
EVENT_TRANSLATION_MAP: dict[str, str] = {
|
||||
@@ -263,7 +263,7 @@ EVENT_TRANSLATION_MAP: dict[str, str] = {
|
||||
|
||||
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
|
||||
|
||||
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BangOlufsenButtons]
|
||||
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BeoButtons]
|
||||
|
||||
|
||||
DEVICE_BUTTON_EVENTS: Final[list[str]] = [
|
||||
|
||||
@@ -10,13 +10,13 @@ from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from . import BeoConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .util import get_device_buttons
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: BangOlufsenConfigEntry
|
||||
hass: HomeAssistant, config_entry: BeoConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ from homeassistant.helpers.entity import Entity
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class BangOlufsenBase:
|
||||
"""Base class for BangOlufsen Home Assistant objects."""
|
||||
class BeoBase:
|
||||
"""Base class for Bang & Olufsen Home Assistant objects."""
|
||||
|
||||
def __init__(self, entry: ConfigEntry, client: MozartClient) -> None:
|
||||
"""Initialize the object."""
|
||||
@@ -51,8 +51,8 @@ class BangOlufsenBase:
|
||||
)
|
||||
|
||||
|
||||
class BangOlufsenEntity(Entity, BangOlufsenBase):
|
||||
"""Base Entity for BangOlufsen entities."""
|
||||
class BeoEntity(Entity, BeoBase):
|
||||
"""Base Entity for Bang & Olufsen entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from . import BeoConfigEntry
|
||||
from .const import (
|
||||
BEO_REMOTE_CONTROL_KEYS,
|
||||
BEO_REMOTE_KEY_EVENTS,
|
||||
@@ -25,10 +25,10 @@ from .const import (
|
||||
DEVICE_BUTTON_EVENTS,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
BangOlufsenModel,
|
||||
BeoModel,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenEntity
|
||||
from .entity import BeoEntity
|
||||
from .util import get_device_buttons, get_remotes
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -36,14 +36,14 @@ PARALLEL_UPDATES = 0
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
config_entry: BeoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Event entities from config entry."""
|
||||
entities: list[BangOlufsenEvent] = []
|
||||
entities: list[BeoEvent] = []
|
||||
|
||||
async_add_entities(
|
||||
BangOlufsenButtonEvent(config_entry, button_type)
|
||||
BeoButtonEvent(config_entry, button_type)
|
||||
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ async def async_setup_entry(
|
||||
# Add Light keys
|
||||
entities.extend(
|
||||
[
|
||||
BangOlufsenRemoteKeyEvent(
|
||||
BeoRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
|
||||
@@ -66,7 +66,7 @@ async def async_setup_entry(
|
||||
# Add Control keys
|
||||
entities.extend(
|
||||
[
|
||||
BangOlufsenRemoteKeyEvent(
|
||||
BeoRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
|
||||
@@ -84,10 +84,9 @@ async def async_setup_entry(
|
||||
config_entry.entry_id
|
||||
)
|
||||
for device in devices:
|
||||
if (
|
||||
device.model == BangOlufsenModel.BEOREMOTE_ONE
|
||||
and device.serial_number not in {remote.serial_number for remote in remotes}
|
||||
):
|
||||
if device.model == BeoModel.BEOREMOTE_ONE and device.serial_number not in {
|
||||
remote.serial_number for remote in remotes
|
||||
}:
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
@@ -95,13 +94,13 @@ async def async_setup_entry(
|
||||
async_add_entities(new_entities=entities)
|
||||
|
||||
|
||||
class BangOlufsenEvent(BangOlufsenEntity, EventEntity):
|
||||
class BeoEvent(BeoEntity, EventEntity):
|
||||
"""Base Event class."""
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(self, config_entry: BangOlufsenConfigEntry) -> None:
|
||||
def __init__(self, config_entry: BeoConfigEntry) -> None:
|
||||
"""Initialize Event."""
|
||||
super().__init__(config_entry, config_entry.runtime_data.client)
|
||||
|
||||
@@ -112,12 +111,12 @@ class BangOlufsenEvent(BangOlufsenEntity, EventEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class BangOlufsenButtonEvent(BangOlufsenEvent):
|
||||
class BeoButtonEvent(BeoEvent):
|
||||
"""Event class for Button events."""
|
||||
|
||||
_attr_event_types = DEVICE_BUTTON_EVENTS
|
||||
|
||||
def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None:
|
||||
def __init__(self, config_entry: BeoConfigEntry, button_type: str) -> None:
|
||||
"""Initialize Button."""
|
||||
super().__init__(config_entry)
|
||||
|
||||
@@ -146,14 +145,14 @@ class BangOlufsenButtonEvent(BangOlufsenEvent):
|
||||
)
|
||||
|
||||
|
||||
class BangOlufsenRemoteKeyEvent(BangOlufsenEvent):
|
||||
class BeoRemoteKeyEvent(BeoEvent):
|
||||
"""Event class for Beoremote One key events."""
|
||||
|
||||
_attr_event_types = BEO_REMOTE_KEY_EVENTS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
config_entry: BeoConfigEntry,
|
||||
remote: PairedRemote,
|
||||
key_type: str,
|
||||
) -> None:
|
||||
@@ -166,8 +165,8 @@ class BangOlufsenRemoteKeyEvent(BangOlufsenEvent):
|
||||
self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
|
||||
name=f"{BangOlufsenModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
|
||||
model=BangOlufsenModel.BEOREMOTE_ONE,
|
||||
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
|
||||
model=BeoModel.BEOREMOTE_ONE,
|
||||
serial_number=remote.serial_number,
|
||||
sw_version=remote.app_version,
|
||||
manufacturer=MANUFACTURER,
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["mozart-api==5.1.0.247.1"],
|
||||
"requirements": ["mozart-api==5.3.1.108.0"],
|
||||
"zeroconf": ["_bangolufsen._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -69,11 +69,11 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from . import BeoConfigEntry
|
||||
from .const import (
|
||||
BANG_OLUFSEN_REPEAT_FROM_HA,
|
||||
BANG_OLUFSEN_REPEAT_TO_HA,
|
||||
BANG_OLUFSEN_STATES,
|
||||
BEO_REPEAT_FROM_HA,
|
||||
BEO_REPEAT_TO_HA,
|
||||
BEO_STATES,
|
||||
BEOLINK_JOIN_SOURCES,
|
||||
BEOLINK_JOIN_SOURCES_TO_UPPER,
|
||||
CONF_BEOLINK_JID,
|
||||
@@ -82,12 +82,12 @@ from .const import (
|
||||
FALLBACK_SOURCES,
|
||||
MANUFACTURER,
|
||||
VALID_MEDIA_TYPES,
|
||||
BangOlufsenAttribute,
|
||||
BangOlufsenMediaType,
|
||||
BangOlufsenSource,
|
||||
BeoAttribute,
|
||||
BeoMediaType,
|
||||
BeoSource,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenEntity
|
||||
from .entity import BeoEntity
|
||||
from .util import get_serial_number_from_jid
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -96,7 +96,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BANG_OLUFSEN_FEATURES = (
|
||||
BEO_FEATURES = (
|
||||
MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
|
||||
| MediaPlayerEntityFeature.GROUPING
|
||||
@@ -119,15 +119,13 @@ BANG_OLUFSEN_FEATURES = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
config_entry: BeoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a Media Player entity from config entry."""
|
||||
# Add MediaPlayer entity
|
||||
async_add_entities(
|
||||
new_entities=[
|
||||
BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
|
||||
],
|
||||
new_entities=[BeoMediaPlayer(config_entry, config_entry.runtime_data.client)],
|
||||
update_before_add=True,
|
||||
)
|
||||
|
||||
@@ -187,7 +185,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
|
||||
"""Representation of a media player."""
|
||||
|
||||
_attr_name = None
|
||||
@@ -288,7 +286,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
queue_settings = await self._client.get_settings_queue(_request_timeout=5)
|
||||
|
||||
if queue_settings.repeat is not None:
|
||||
self._attr_repeat = BANG_OLUFSEN_REPEAT_TO_HA[queue_settings.repeat]
|
||||
self._attr_repeat = BEO_REPEAT_TO_HA[queue_settings.repeat]
|
||||
|
||||
if queue_settings.shuffle is not None:
|
||||
self._attr_shuffle = queue_settings.shuffle
|
||||
@@ -408,8 +406,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
# Check if source is line-in or optical and progress should be updated
|
||||
if self._source_change.id in (
|
||||
BangOlufsenSource.LINE_IN.id,
|
||||
BangOlufsenSource.SPDIF.id,
|
||||
BeoSource.LINE_IN.id,
|
||||
BeoSource.SPDIF.id,
|
||||
):
|
||||
self._playback_progress = PlaybackProgress(progress=0)
|
||||
|
||||
@@ -450,10 +448,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
# Add Beolink self
|
||||
self._beolink_attributes = {
|
||||
BangOlufsenAttribute.BEOLINK: {
|
||||
BangOlufsenAttribute.BEOLINK_SELF: {
|
||||
self.device_entry.name: self._beolink_jid
|
||||
}
|
||||
BeoAttribute.BEOLINK: {
|
||||
BeoAttribute.BEOLINK_SELF: {self.device_entry.name: self._beolink_jid}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,12 +457,12 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
peers = await self._client.get_beolink_peers()
|
||||
|
||||
if len(peers) > 0:
|
||||
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
|
||||
BangOlufsenAttribute.BEOLINK_PEERS
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_PEERS
|
||||
] = {}
|
||||
for peer in peers:
|
||||
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
|
||||
BangOlufsenAttribute.BEOLINK_PEERS
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_PEERS
|
||||
][peer.friendly_name] = peer.jid
|
||||
|
||||
# Add Beolink listeners / leader
|
||||
@@ -488,8 +484,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
# Add self
|
||||
group_members.append(self.entity_id)
|
||||
|
||||
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
|
||||
BangOlufsenAttribute.BEOLINK_LEADER
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_LEADER
|
||||
] = {
|
||||
self._remote_leader.friendly_name: self._remote_leader.jid,
|
||||
}
|
||||
@@ -527,8 +523,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
beolink_listener.jid
|
||||
)
|
||||
break
|
||||
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
|
||||
BangOlufsenAttribute.BEOLINK_LISTENERS
|
||||
self._beolink_attributes[BeoAttribute.BEOLINK][
|
||||
BeoAttribute.BEOLINK_LISTENERS
|
||||
] = beolink_listeners_attribute
|
||||
|
||||
self._attr_group_members = group_members
|
||||
@@ -587,7 +583,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
for sound_mode in sound_modes:
|
||||
label = f"{sound_mode.name} ({sound_mode.id})"
|
||||
|
||||
self._sound_modes[label] = sound_mode.id
|
||||
self._sound_modes[label] = cast(int, sound_mode.id)
|
||||
|
||||
if sound_mode.id == active_sound_mode.id:
|
||||
self._attr_sound_mode = label
|
||||
@@ -600,7 +596,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Flag media player features that are supported."""
|
||||
features = BANG_OLUFSEN_FEATURES
|
||||
features = BEO_FEATURES
|
||||
|
||||
# Add seeking if supported by the current source
|
||||
if self._source_change.is_seekable is True:
|
||||
@@ -611,7 +607,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""Return the current state of the media player."""
|
||||
return BANG_OLUFSEN_STATES[self._state]
|
||||
return BEO_STATES[self._state]
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
@@ -631,10 +627,10 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
def media_content_type(self) -> MediaType | str | None:
|
||||
"""Return the current media type."""
|
||||
content_type = {
|
||||
BangOlufsenSource.URI_STREAMER.id: MediaType.URL,
|
||||
BangOlufsenSource.DEEZER.id: BangOlufsenMediaType.DEEZER,
|
||||
BangOlufsenSource.TIDAL.id: BangOlufsenMediaType.TIDAL,
|
||||
BangOlufsenSource.NET_RADIO.id: BangOlufsenMediaType.RADIO,
|
||||
BeoSource.URI_STREAMER.id: MediaType.URL,
|
||||
BeoSource.DEEZER.id: BeoMediaType.DEEZER,
|
||||
BeoSource.TIDAL.id: BeoMediaType.TIDAL,
|
||||
BeoSource.NET_RADIO.id: BeoMediaType.RADIO,
|
||||
}
|
||||
# Hard to determine content type.
|
||||
if self._source_change.id in content_type:
|
||||
@@ -765,9 +761,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set playback queues to repeat."""
|
||||
await self._client.set_settings_queue(
|
||||
play_queue_settings=PlayQueueSettings(
|
||||
repeat=BANG_OLUFSEN_REPEAT_FROM_HA[repeat]
|
||||
)
|
||||
play_queue_settings=PlayQueueSettings(repeat=BEO_REPEAT_FROM_HA[repeat])
|
||||
)
|
||||
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
@@ -871,7 +865,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
self._volume.level.level + offset_volume, 100
|
||||
)
|
||||
|
||||
if media_type == BangOlufsenMediaType.OVERLAY_TTS:
|
||||
if media_type == BeoMediaType.OVERLAY_TTS:
|
||||
# Bang & Olufsen cloud TTS
|
||||
overlay_play_request.text_to_speech = (
|
||||
OverlayPlayRequestTextToSpeechTextToSpeech(
|
||||
@@ -888,14 +882,14 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
# The "provider" media_type may not be suitable for overlay all the time.
|
||||
# Use it for now.
|
||||
elif media_type == BangOlufsenMediaType.TTS:
|
||||
elif media_type == BeoMediaType.TTS:
|
||||
await self._client.post_overlay_play(
|
||||
overlay_play_request=OverlayPlayRequest(
|
||||
uri=Uri(location=media_id),
|
||||
)
|
||||
)
|
||||
|
||||
elif media_type == BangOlufsenMediaType.RADIO:
|
||||
elif media_type == BeoMediaType.RADIO:
|
||||
await self._client.run_provided_scene(
|
||||
scene_properties=SceneProperties(
|
||||
action_list=[
|
||||
@@ -907,13 +901,13 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
)
|
||||
)
|
||||
|
||||
elif media_type == BangOlufsenMediaType.FAVOURITE:
|
||||
elif media_type == BeoMediaType.FAVOURITE:
|
||||
await self._client.activate_preset(id=int(media_id))
|
||||
|
||||
elif media_type in (BangOlufsenMediaType.DEEZER, BangOlufsenMediaType.TIDAL):
|
||||
elif media_type in (BeoMediaType.DEEZER, BeoMediaType.TIDAL):
|
||||
try:
|
||||
# Play Deezer flow.
|
||||
if media_id == "flow" and media_type == BangOlufsenMediaType.DEEZER:
|
||||
if media_id == "flow" and media_type == BeoMediaType.DEEZER:
|
||||
deezer_id = None
|
||||
|
||||
if "id" in kwargs[ATTR_MEDIA_EXTRA]:
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .const import DEVICE_BUTTONS, DOMAIN, BangOlufsenButtons, BangOlufsenModel
|
||||
from .const import DEVICE_BUTTONS, DOMAIN, BeoButtons, BeoModel
|
||||
|
||||
|
||||
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
|
||||
@@ -40,16 +40,16 @@ async def get_remotes(client: MozartClient) -> list[PairedRemote]:
|
||||
]
|
||||
|
||||
|
||||
def get_device_buttons(model: BangOlufsenModel) -> list[str]:
|
||||
def get_device_buttons(model: BeoModel) -> list[str]:
|
||||
"""Get supported buttons for a given model."""
|
||||
buttons = DEVICE_BUTTONS.copy()
|
||||
|
||||
# Beosound Premiere does not have a bluetooth button
|
||||
if model == BangOlufsenModel.BEOSOUND_PREMIERE:
|
||||
buttons.remove(BangOlufsenButtons.BLUETOOTH)
|
||||
if model == BeoModel.BEOSOUND_PREMIERE:
|
||||
buttons.remove(BeoButtons.BLUETOOTH)
|
||||
|
||||
# Beoconnect Core does not have any buttons
|
||||
elif model == BangOlufsenModel.BEOCONNECT_CORE:
|
||||
elif model == BeoModel.BEOCONNECT_CORE:
|
||||
buttons = []
|
||||
|
||||
return buttons
|
||||
|
||||
@@ -27,20 +27,20 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from .const import (
|
||||
BANG_OLUFSEN_WEBSOCKET_EVENT,
|
||||
BEO_WEBSOCKET_EVENT,
|
||||
CONNECTION_STATUS,
|
||||
DOMAIN,
|
||||
EVENT_TRANSLATION_MAP,
|
||||
BangOlufsenModel,
|
||||
BeoModel,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenBase
|
||||
from .entity import BeoBase
|
||||
from .util import get_device, get_remotes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
class BeoWebsocket(BeoBase):
|
||||
"""The WebSocket listeners."""
|
||||
|
||||
def __init__(
|
||||
@@ -48,7 +48,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
) -> None:
|
||||
"""Initialize the WebSocket listeners."""
|
||||
|
||||
BangOlufsenBase.__init__(self, entry, client)
|
||||
BeoBase.__init__(self, entry, client)
|
||||
|
||||
self.hass = hass
|
||||
self._device = get_device(hass, self._unique_id)
|
||||
@@ -178,7 +178,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
self.entry.entry_id
|
||||
)
|
||||
if device.serial_number is not None
|
||||
and device.model == BangOlufsenModel.BEOREMOTE_ONE
|
||||
and device.model == BeoModel.BEOREMOTE_ONE
|
||||
]
|
||||
# Get paired remotes from device
|
||||
remote_serial_numbers = [
|
||||
@@ -274,4 +274,4 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
}
|
||||
|
||||
_LOGGER.debug("%s", debug_notification)
|
||||
self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, debug_notification)
|
||||
self.hass.bus.async_fire(BEO_WEBSOCKET_EVENT, debug_notification)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==1.0.1",
|
||||
"bleak==2.0.0",
|
||||
"bleak-retry-connector==4.4.3",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
|
||||
@@ -20,7 +20,7 @@ from aiohttp import hdrs, web
|
||||
import attr
|
||||
from propcache.api import cached_property, under_cached_property
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidateInit, RTCIceServer
|
||||
from webrtc_models import RTCIceCandidateInit
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
@@ -37,6 +37,7 @@ from homeassistant.components.stream import (
|
||||
Stream,
|
||||
create_stream,
|
||||
)
|
||||
from homeassistant.components.web_rtc import async_get_ice_servers
|
||||
from homeassistant.components.websocket_api import ActiveConnection
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -84,7 +85,6 @@ from .prefs import (
|
||||
get_dynamic_camera_stream_settings,
|
||||
)
|
||||
from .webrtc import (
|
||||
DATA_ICE_SERVERS,
|
||||
CameraWebRTCProvider,
|
||||
WebRTCAnswer, # noqa: F401
|
||||
WebRTCCandidate, # noqa: F401
|
||||
@@ -93,7 +93,6 @@ from .webrtc import (
|
||||
WebRTCMessage, # noqa: F401
|
||||
WebRTCSendMessage,
|
||||
async_get_supported_provider,
|
||||
async_register_ice_servers,
|
||||
async_register_webrtc_provider, # noqa: F401
|
||||
async_register_ws,
|
||||
)
|
||||
@@ -400,20 +399,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service
|
||||
)
|
||||
|
||||
@callback
|
||||
def get_ice_servers() -> list[RTCIceServer]:
|
||||
if hass.config.webrtc.ice_servers:
|
||||
return hass.config.webrtc.ice_servers
|
||||
return [
|
||||
RTCIceServer(
|
||||
urls=[
|
||||
"stun:stun.home-assistant.io:3478",
|
||||
"stun:stun.home-assistant.io:80",
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
async_register_ice_servers(hass, get_ice_servers)
|
||||
return True
|
||||
|
||||
|
||||
@@ -731,11 +716,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
|
||||
config = self._async_get_webrtc_client_configuration()
|
||||
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
ice_servers = async_get_ice_servers(self.hass)
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
|
||||
return config
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Camera",
|
||||
"after_dependencies": ["media_player"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["http"],
|
||||
"dependencies": ["http", "web_rtc"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/camera",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from functools import cache, partial, wraps
|
||||
import logging
|
||||
@@ -12,12 +12,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mashumaro import MissingField
|
||||
import voluptuous as vol
|
||||
from webrtc_models import (
|
||||
RTCConfiguration,
|
||||
RTCIceCandidate,
|
||||
RTCIceCandidateInit,
|
||||
RTCIceServer,
|
||||
)
|
||||
from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceCandidateInit
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -38,9 +33,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
|
||||
"camera_webrtc_providers"
|
||||
)
|
||||
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
|
||||
"camera_webrtc_ice_servers"
|
||||
)
|
||||
|
||||
|
||||
_WEBRTC = "WebRTC"
|
||||
@@ -367,21 +359,3 @@ async def async_get_supported_provider(
|
||||
return provider
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_ice_servers(
|
||||
hass: HomeAssistant,
|
||||
get_ice_server_fn: Callable[[], Iterable[RTCIceServer]],
|
||||
) -> Callable[[], None]:
|
||||
"""Register a ICE server.
|
||||
|
||||
The registering integration is responsible to implement caching if needed.
|
||||
"""
|
||||
servers = hass.data.setdefault(DATA_ICE_SERVERS, [])
|
||||
|
||||
def remove() -> None:
|
||||
servers.remove(get_ice_server_fn)
|
||||
|
||||
servers.append(get_ice_server_fn)
|
||||
return remove
|
||||
|
||||
@@ -19,8 +19,8 @@ from homeassistant.components.alexa import (
|
||||
errors as alexa_errors,
|
||||
smart_home as alexa_smart_home,
|
||||
)
|
||||
from homeassistant.components.camera import async_register_ice_servers
|
||||
from homeassistant.components.google_assistant import smart_home as ga
|
||||
from homeassistant.components.web_rtc import async_register_ice_servers
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import Context, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"google_assistant"
|
||||
],
|
||||
"codeowners": ["@home-assistant/cloud"],
|
||||
"dependencies": ["auth", "http", "repairs", "webhook"],
|
||||
"dependencies": ["auth", "http", "repairs", "webhook", "web_rtc"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/cloud",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN, CONF_NAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
@@ -31,6 +31,8 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
|
||||
|
||||
|
||||
class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Duck DNS."""
|
||||
@@ -79,3 +81,37 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
deprecate_yaml_issue(self.hass, import_success=True)
|
||||
return result
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
if not await _update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
user_input[CONF_ACCESS_TOKEN],
|
||||
):
|
||||
errors["base"] = "update_failed"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={CONF_NAME: entry.title},
|
||||
)
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"update_failed": "Updating Duck DNS failed"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"access_token": "[%key:component::duckdns::config::step::user::data::access_token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"access_token": "[%key:component::duckdns::config::step::user::data_description::access_token%]"
|
||||
},
|
||||
"title": "Re-configure {name}"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"access_token": "Token",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==16.4.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ from .coordinator import (
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .entity import FressnapfTrackerEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FressnapfTrackerBinarySensorDescription(BinarySensorEntityDescription):
|
||||
@@ -34,13 +37,6 @@ BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.charging,
|
||||
),
|
||||
FressnapfTrackerBinarySensorDescription(
|
||||
translation_key="deep_sleep",
|
||||
key="deep_sleep_value",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: bool(data.deep_sleep_value),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import FressnapfTrackerConfigEntry, FressnapfTrackerDataUpdateCoordinator
|
||||
from .entity import FressnapfTrackerBaseEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"pet": {
|
||||
"default": "mdi:paw"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"energy_saving": {
|
||||
"default": "mdi:sleep",
|
||||
"state": {
|
||||
"off": "mdi:sleep-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
95
homeassistant/components/fressnapf_tracker/light.py
Normal file
95
homeassistant/components/fressnapf_tracker/light.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Light platform for fressnapf_tracker."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
LightEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import FressnapfTrackerEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
LIGHT_ENTITY_DESCRIPTION = LightEntityDescription(
|
||||
translation_key="led",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
key="led_brightness_value",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FressnapfTrackerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Fressnapf Tracker lights."""
|
||||
|
||||
async_add_entities(
|
||||
FressnapfTrackerLight(coordinator, LIGHT_ENTITY_DESCRIPTION)
|
||||
for coordinator in entry.runtime_data
|
||||
if coordinator.data.led_activatable is not None
|
||||
and coordinator.data.led_activatable.has_led
|
||||
and coordinator.data.tracker_settings.features.flash_light
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerLight(FressnapfTrackerEntity, LightEntity):
|
||||
"""Fressnapf Tracker light."""
|
||||
|
||||
_attr_color_mode: ColorMode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes: set[ColorMode] = {ColorMode.BRIGHTNESS}
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
if TYPE_CHECKING:
|
||||
# The entity is not created if led_brightness_value is None
|
||||
assert self.coordinator.data.led_brightness_value is not None
|
||||
return int(round((self.coordinator.data.led_brightness_value / 100) * 255))
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the device."""
|
||||
self.raise_if_not_activatable()
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
|
||||
brightness = int((brightness / 255) * 100)
|
||||
await self.coordinator.client.set_led_brightness(brightness)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the device."""
|
||||
await self.coordinator.client.set_led_brightness(0)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
def raise_if_not_activatable(self) -> None:
|
||||
"""Raise error with reasoning if light is not activatable."""
|
||||
if TYPE_CHECKING:
|
||||
# The entity is not created if led_activatable is None
|
||||
assert self.coordinator.data.led_activatable is not None
|
||||
error_type: str | None = None
|
||||
if not self.coordinator.data.led_activatable.seen_recently:
|
||||
error_type = "not_seen_recently"
|
||||
elif not self.coordinator.data.led_activatable.not_charging:
|
||||
error_type = "charging"
|
||||
elif not self.coordinator.data.led_activatable.nonempty_battery:
|
||||
error_type = "low_battery"
|
||||
if error_type is not None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=error_type,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if device is on."""
|
||||
if self.coordinator.data.led_brightness_value is not None:
|
||||
return self.coordinator.data.led_brightness_value > 0
|
||||
return False
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fressnapftracker==0.1.2"]
|
||||
"requirements": ["fressnapftracker==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -28,20 +28,26 @@ rules:
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and thus does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and thus does not support discovery.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
@@ -50,12 +56,15 @@ rules:
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have many entities. All of them are fundamental.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
@@ -18,6 +18,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .entity import FressnapfTrackerEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FressnapfTrackerSensorDescription(SensorEntityDescription):
|
||||
|
||||
@@ -47,10 +47,26 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"deep_sleep": {
|
||||
"name": "Deep sleep"
|
||||
"light": {
|
||||
"led": {
|
||||
"name": "Flashlight"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"energy_saving": {
|
||||
"name": "Sleep mode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"charging": {
|
||||
"message": "The flashlight cannot be activated while charging."
|
||||
},
|
||||
"low_battery": {
|
||||
"message": "The flashlight cannot be activated due to low battery."
|
||||
},
|
||||
"not_seen_recently": {
|
||||
"message": "The flashlight cannot be activated when the tracker has not moved recently."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
homeassistant/components/fressnapf_tracker/switch.py
Normal file
60
homeassistant/components/fressnapf_tracker/switch.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Switch platform for Fressnapf Tracker."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchDeviceClass,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .entity import FressnapfTrackerEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
SWITCH_ENTITY_DESCRIPTION = SwitchEntityDescription(
|
||||
translation_key="energy_saving",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
key="energy_saving",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FressnapfTrackerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Fressnapf Tracker switches."""
|
||||
|
||||
async_add_entities(
|
||||
FressnapfTrackerSwitch(coordinator, SWITCH_ENTITY_DESCRIPTION)
|
||||
for coordinator in entry.runtime_data
|
||||
if coordinator.data.tracker_settings.features.energy_saving_mode
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerSwitch(FressnapfTrackerEntity, SwitchEntity):
|
||||
"""Fressnapf Tracker switch."""
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the device."""
|
||||
await self.coordinator.client.set_energy_saving(True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the device."""
|
||||
await self.coordinator.client.set_energy_saving(False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if device is on."""
|
||||
if TYPE_CHECKING:
|
||||
# The entity is not created if energy_saving is None
|
||||
assert self.coordinator.data.energy_saving is not None
|
||||
return self.coordinator.data.energy_saving.value == 1
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251202.0"]
|
||||
"requirements": ["home-assistant-frontend==20251203.0"]
|
||||
}
|
||||
|
||||
58
homeassistant/components/gentex_homelink/__init__.py
Normal file
58
homeassistant/components/gentex_homelink/__init__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""The homelink integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homelink.mqtt_provider import MQTTProvider
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
|
||||
from . import oauth2
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeLinkConfigEntry, HomeLinkCoordinator, HomeLinkData
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.EVENT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
|
||||
"""Set up homelink from a config entry."""
|
||||
auth_implementation = oauth2.SRPAuthImplementation(hass, DOMAIN)
|
||||
|
||||
config_entry_oauth2_flow.async_register_implementation(
|
||||
hass, DOMAIN, auth_implementation
|
||||
)
|
||||
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
authenticated_session = oauth2.AsyncConfigEntryAuth(
|
||||
aiohttp_client.async_get_clientsession(hass), session
|
||||
)
|
||||
|
||||
provider = MQTTProvider(authenticated_session)
|
||||
coordinator = HomeLinkCoordinator(hass, provider, entry)
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, coordinator.async_on_unload
|
||||
)
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = HomeLinkData(
|
||||
provider=provider, coordinator=coordinator, last_update_id=None
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HomeLinkConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.coordinator.async_on_unload(None)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""application_credentials platform for the gentex homelink integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from . import oauth2
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
hass: HomeAssistant, auth_domain: str, _credential: ClientCredential
|
||||
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
|
||||
"""Return custom SRPAuth implementation."""
|
||||
return oauth2.SRPAuthImplementation(hass, auth_domain)
|
||||
66
homeassistant/components/gentex_homelink/config_flow.py
Normal file
66
homeassistant/components/gentex_homelink/config_flow.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Config flow for homelink."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import botocore.exceptions
|
||||
from homelink.auth.srp_auth import SRPAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .const import DOMAIN
|
||||
from .oauth2 import SRPAuthImplementation
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Config flow to handle homelink OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Set up the flow handler."""
|
||||
super().__init__()
|
||||
self.flow_impl = SRPAuthImplementation(self.hass, DOMAIN)
|
||||
|
||||
@property
|
||||
def logger(self):
|
||||
"""Get the logger."""
|
||||
return _LOGGER
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Ask for username and password."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
|
||||
|
||||
srp_auth = SRPAuth()
|
||||
try:
|
||||
tokens = await self.hass.async_add_executor_job(
|
||||
srp_auth.async_get_access_token,
|
||||
user_input[CONF_EMAIL],
|
||||
user_input[CONF_PASSWORD],
|
||||
)
|
||||
except botocore.exceptions.ClientError:
|
||||
_LOGGER.exception("Error authenticating homelink account")
|
||||
errors["base"] = "srp_auth_failed"
|
||||
except Exception:
|
||||
_LOGGER.exception("An unexpected error occurred")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self.external_data = {"tokens": tokens}
|
||||
return await self.async_step_creation()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
7
homeassistant/components/gentex_homelink/const.py
Normal file
7
homeassistant/components/gentex_homelink/const.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Constants for the homelink integration."""
|
||||
|
||||
DOMAIN = "gentex_homelink"
|
||||
OAUTH2_TOKEN = "https://auth.homelinkcloud.com/oauth2/token"
|
||||
POLLING_INTERVAL = 5
|
||||
|
||||
EVENT_PRESSED = "Pressed"
|
||||
113
homeassistant/components/gentex_homelink/coordinator.py
Normal file
113
homeassistant/components/gentex_homelink/coordinator.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Makes requests to the state server and stores the resulting data so that the buttons can access it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from homelink.model.device import Device
|
||||
from homelink.mqtt_provider import MQTTProvider
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .event import HomeLinkEventEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HomeLinkConfigEntry = ConfigEntry[HomeLinkData]
|
||||
type EventCallback = Callable[[HomeLinkEventData], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HomeLinkData:
|
||||
"""Class for HomeLink integration runtime data."""
|
||||
|
||||
provider: MQTTProvider
|
||||
coordinator: HomeLinkCoordinator
|
||||
last_update_id: str | None
|
||||
|
||||
|
||||
class HomeLinkEventData(TypedDict):
|
||||
"""Data for a single event."""
|
||||
|
||||
requestId: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class HomeLinkMQTTMessage(TypedDict):
|
||||
"""HomeLink MQTT Event message."""
|
||||
|
||||
type: str
|
||||
data: dict[str, HomeLinkEventData] # Each key is a button id
|
||||
|
||||
|
||||
class HomeLinkCoordinator:
|
||||
"""HomeLink integration coordinator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
provider: MQTTProvider,
|
||||
config_entry: HomeLinkConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize my coordinator."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.provider = provider
|
||||
self.device_data: list[Device] = []
|
||||
self.buttons: list[HomeLinkEventEntity] = []
|
||||
self._listeners: dict[str, EventCallback] = {}
|
||||
|
||||
@callback
|
||||
def async_add_event_listener(
|
||||
self, update_callback: EventCallback, target_event_id: str
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for updates."""
|
||||
self._listeners[target_event_id] = update_callback
|
||||
return partial(self.__async_remove_listener_internal, target_event_id)
|
||||
|
||||
def __async_remove_listener_internal(self, listener_id: str):
|
||||
del self._listeners[listener_id]
|
||||
|
||||
@callback
|
||||
def async_handle_state_data(self, data: dict[str, HomeLinkEventData]):
|
||||
"""Notify listeners."""
|
||||
for button_id, event in data.items():
|
||||
if listener := self._listeners.get(button_id):
|
||||
listener(event)
|
||||
|
||||
async def async_config_entry_first_refresh(self) -> None:
|
||||
"""Refresh data for the first time when a config entry is setup."""
|
||||
await self._async_setup()
|
||||
|
||||
async def async_on_unload(self, _event):
|
||||
"""Disconnect and unregister when unloaded."""
|
||||
await self.provider.disable()
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
await self.provider.enable(get_default_context())
|
||||
await self.discover_devices()
|
||||
self.provider.listen(self.on_message)
|
||||
|
||||
async def discover_devices(self):
|
||||
"""Discover devices and build the Entities."""
|
||||
self.device_data = await self.provider.discover()
|
||||
|
||||
def on_message(
|
||||
self: HomeLinkCoordinator, _topic: str, message: HomeLinkMQTTMessage
|
||||
):
|
||||
"MQTT Callback function."
|
||||
if message["type"] == "state":
|
||||
self.hass.add_job(self.async_handle_state_data, message["data"])
|
||||
if message["type"] == "requestSync":
|
||||
self.hass.add_job(
|
||||
self.hass.config_entries.async_reload,
|
||||
self.config_entry.entry_id,
|
||||
)
|
||||
83
homeassistant/components/gentex_homelink/event.py
Normal file
83
homeassistant/components/gentex_homelink/event.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Platform for Event integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.event import EventDeviceClass, EventEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, EVENT_PRESSED
|
||||
from .coordinator import HomeLinkCoordinator, HomeLinkEventData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the entities for the binary sensor."""
|
||||
coordinator = config_entry.runtime_data.coordinator
|
||||
for device in coordinator.device_data:
|
||||
buttons = [
|
||||
HomeLinkEventEntity(b.id, b.name, device.id, device.name, coordinator)
|
||||
for b in device.buttons
|
||||
]
|
||||
coordinator.buttons.extend(buttons)
|
||||
|
||||
async_add_entities(coordinator.buttons)
|
||||
|
||||
|
||||
# Updates are centralized by the coordinator.
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class HomeLinkEventEntity(EventEntity):
|
||||
"""Event Entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_event_types = [EVENT_PRESSED]
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
param_name: str,
|
||||
device_id: str,
|
||||
device_name: str,
|
||||
coordinator: HomeLinkCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the event entity."""
|
||||
|
||||
self.id: str = id
|
||||
self._attr_name: str = param_name
|
||||
self._attr_unique_id: str = id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device_id)},
|
||||
name=device_name,
|
||||
)
|
||||
self.coordinator = coordinator
|
||||
self.last_request_id: str | None = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_event_listener(
|
||||
self._handle_event_data_update, self.id
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_event_data_update(self, update_data: HomeLinkEventData) -> None:
|
||||
"""Update this button."""
|
||||
|
||||
if update_data["requestId"] != self.last_request_id:
|
||||
self._trigger_event(EVENT_PRESSED)
|
||||
self.last_request_id = update_data["requestId"]
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self):
|
||||
"""Request early polling. Left intentionally blank because it's not possible in this implementation."""
|
||||
11
homeassistant/components/gentex_homelink/manifest.json
Normal file
11
homeassistant/components/gentex_homelink/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "gentex_homelink",
|
||||
"name": "HomeLink",
|
||||
"codeowners": ["@niaexa", "@ryanjones-gentex"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/gentex_homelink",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["homelink-integration-api==0.0.1"]
|
||||
}
|
||||
114
homeassistant/components/gentex_homelink/oauth2.py
Normal file
114
homeassistant/components/gentex_homelink/oauth2.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""API for homelink bound to Home Assistant OAuth."""
|
||||
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientError, ClientSession
|
||||
from homelink.auth.abstract_auth import AbstractAuth
|
||||
from homelink.settings import COGNITO_CLIENT_ID
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import OAUTH2_TOKEN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SRPAuthImplementation(config_entry_oauth2_flow.AbstractOAuth2Implementation):
|
||||
"""Base class to abstract OAuth2 authentication."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, domain) -> None:
|
||||
"""Initialize the SRP Auth implementation."""
|
||||
|
||||
self.hass = hass
|
||||
self._domain = domain
|
||||
self.client_id = COGNITO_CLIENT_ID
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Name of the implementation."""
|
||||
return "SRPAuth"
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
"""Domain that is providing the implementation."""
|
||||
return self._domain
|
||||
|
||||
async def async_generate_authorize_url(self, flow_id: str) -> str:
|
||||
"""Left intentionally blank because the auth is handled by SRP."""
|
||||
return ""
|
||||
|
||||
async def async_resolve_external_data(self, external_data) -> dict:
|
||||
"""Format the token from the source appropriately for HomeAssistant."""
|
||||
tokens = external_data["tokens"]
|
||||
new_token = {}
|
||||
new_token["access_token"] = tokens["AuthenticationResult"]["AccessToken"]
|
||||
new_token["refresh_token"] = tokens["AuthenticationResult"]["RefreshToken"]
|
||||
new_token["token_type"] = tokens["AuthenticationResult"]["TokenType"]
|
||||
new_token["expires_in"] = tokens["AuthenticationResult"]["ExpiresIn"]
|
||||
new_token["expires_at"] = (
|
||||
time.time() + tokens["AuthenticationResult"]["ExpiresIn"]
|
||||
)
|
||||
|
||||
return new_token
|
||||
|
||||
async def _token_request(self, data: dict) -> dict:
|
||||
"""Make a token request."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
data["client_id"] = self.client_id
|
||||
|
||||
_LOGGER.debug("Sending token request to %s", OAUTH2_TOKEN)
|
||||
resp = await session.post(OAUTH2_TOKEN, data=data)
|
||||
if resp.status >= 400:
|
||||
try:
|
||||
error_response = await resp.json()
|
||||
except (ClientError, JSONDecodeError):
|
||||
error_response = {}
|
||||
error_code = error_response.get("error", "unknown")
|
||||
error_description = error_response.get(
|
||||
"error_description", "unknown error"
|
||||
)
|
||||
_LOGGER.error(
|
||||
"Token request for %s failed (%s): %s",
|
||||
self.domain,
|
||||
error_code,
|
||||
error_description,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return cast(dict, await resp.json())
|
||||
|
||||
async def _async_refresh_token(self, token: dict) -> dict:
|
||||
"""Refresh tokens."""
|
||||
new_token = await self._token_request(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"refresh_token": token["refresh_token"],
|
||||
}
|
||||
)
|
||||
return {**token, **new_token}
|
||||
|
||||
|
||||
class AsyncConfigEntryAuth(AbstractAuth):
|
||||
"""Provide homelink authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize homelink auth."""
|
||||
super().__init__(websession)
|
||||
self._oauth_session = oauth_session
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
if not self._oauth_session.valid_token:
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
|
||||
return self._oauth_session.token["access_token"]
|
||||
76
homeassistant/components/gentex_homelink/quality_scale.yaml
Normal file
76
homeassistant/components/gentex_homelink/quality_scale.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register any service actions
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: Integration does not poll
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register any service actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
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: Integration does not register any service 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: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: It is not necessary to update IP addresses of devices or services in this Integration
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: done
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Entities are not noisy and are expected to be enabled by default
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: Entity properties are user-defined, and therefore cannot be translated
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Entities in this integration do not use icons, and therefore do not require translation
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
38
homeassistant/components/gentex_homelink/strings.json
Normal file
38
homeassistant/components/gentex_homelink/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"error": {
|
||||
"srp_auth_failed": "Error authenticating HomeLink account",
|
||||
"unknown": "An unknown error occurred. Please try again later"
|
||||
},
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Email address associated with your HomeLink account",
|
||||
"password": "Password associated with your HomeLink account"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,14 @@
|
||||
"user": "Add location"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"location": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]"
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"location": "[%key:component::google_air_quality::config::step::user::data_description::location%]",
|
||||
"name": "[%key:component::google_air_quality::config::step::user::data_description::name%]"
|
||||
},
|
||||
"description": "Select the coordinates for which you want to create an entry.",
|
||||
"title": "Air quality data location"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""The Growatt server PV inverter sensor integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
|
||||
import growattServer
|
||||
from requests import RequestException
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -35,8 +37,7 @@ def get_device_list_classic(
|
||||
# Log in to api and fetch first plant if no plant id is defined.
|
||||
try:
|
||||
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
|
||||
# DEBUG: Log the actual response structure
|
||||
except Exception as ex:
|
||||
except (RequestException, JSONDecodeError) as ex:
|
||||
raise ConfigEntryError(
|
||||
f"Error communicating with Growatt API during login: {ex}"
|
||||
) from ex
|
||||
@@ -53,7 +54,7 @@ def get_device_list_classic(
|
||||
if plant_id == DEFAULT_PLANT_ID:
|
||||
try:
|
||||
plant_info = api.plant_list(user_id)
|
||||
except Exception as ex:
|
||||
except (RequestException, JSONDecodeError) as ex:
|
||||
raise ConfigEntryError(
|
||||
f"Error communicating with Growatt API during plant list: {ex}"
|
||||
) from ex
|
||||
@@ -64,7 +65,7 @@ def get_device_list_classic(
|
||||
# Get a list of devices for specified plant to add sensors for.
|
||||
try:
|
||||
devices = api.device_list(plant_id)
|
||||
except Exception as ex:
|
||||
except (RequestException, JSONDecodeError) as ex:
|
||||
raise ConfigEntryError(
|
||||
f"Error communicating with Growatt API during device list: {ex}"
|
||||
) from ex
|
||||
|
||||
@@ -28,9 +28,7 @@ rules:
|
||||
status: todo
|
||||
comment: Update server URL dropdown to show regional descriptions (e.g., 'China', 'United States') instead of raw URLs.
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable:
|
||||
status: todo
|
||||
comment: Replace bare Exception catches in __init__.py with specific growattServer exceptions.
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.components.cover import (
|
||||
SERVICE_CLOSE_COVER,
|
||||
SERVICE_OPEN_COVER,
|
||||
SERVICE_SET_COVER_POSITION,
|
||||
SERVICE_STOP_COVER,
|
||||
CoverDeviceClass,
|
||||
)
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
@@ -38,6 +39,7 @@ from homeassistant.components.valve import (
|
||||
SERVICE_CLOSE_VALVE,
|
||||
SERVICE_OPEN_VALVE,
|
||||
SERVICE_SET_VALVE_POSITION,
|
||||
SERVICE_STOP_VALVE,
|
||||
ValveDeviceClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
@@ -143,6 +145,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
NevermindIntentHandler(),
|
||||
)
|
||||
intent.async_register(hass, SetPositionIntentHandler())
|
||||
intent.async_register(hass, StopMovingIntentHandler())
|
||||
intent.async_register(hass, StartTimerIntentHandler())
|
||||
intent.async_register(hass, CancelTimerIntentHandler())
|
||||
intent.async_register(hass, CancelAllTimersIntentHandler())
|
||||
@@ -433,6 +436,31 @@ class SetPositionIntentHandler(intent.DynamicServiceIntentHandler):
|
||||
raise intent.IntentHandleError(f"Domain not supported: {state.domain}")
|
||||
|
||||
|
||||
class StopMovingIntentHandler(intent.DynamicServiceIntentHandler):
|
||||
"""Intent handler for stopping covers and valves."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create stop moving handler."""
|
||||
super().__init__(
|
||||
intent.INTENT_STOP_MOVING,
|
||||
description="Stops a moving device or entity",
|
||||
platforms={COVER_DOMAIN, VALVE_DOMAIN},
|
||||
device_classes={CoverDeviceClass, ValveDeviceClass},
|
||||
)
|
||||
|
||||
def get_domain_and_service(
|
||||
self, intent_obj: intent.Intent, state: State
|
||||
) -> tuple[str, str]:
|
||||
"""Get the domain and service name to call."""
|
||||
if state.domain == COVER_DOMAIN:
|
||||
return (COVER_DOMAIN, SERVICE_STOP_COVER)
|
||||
|
||||
if state.domain == VALVE_DOMAIN:
|
||||
return (VALVE_DOMAIN, SERVICE_STOP_VALVE)
|
||||
|
||||
raise intent.IntentHandleError(f"Domain not supported: {state.domain}")
|
||||
|
||||
|
||||
class GetCurrentDateIntentHandler(intent.IntentHandler):
|
||||
"""Gets the current date."""
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"title": "The blinker fluid is empty and needs to be refilled"
|
||||
},
|
||||
"special_repair": {
|
||||
"description": "This is a special repair created by a preview feature! This demonstrates how lab features can interact with the Home Assistant repair system. You can disable this by turning off the kitchen sink special repair feature in Settings > System > Labs.",
|
||||
"description": "This is a special repair created by a preview feature! This demonstrates how Labs features can interact with the Home Assistant repair system. You can disable this by turning off the kitchen sink special repair feature in Settings > System > Labs.",
|
||||
"title": "Special repair feature preview"
|
||||
},
|
||||
"transmogrifier_deprecated": {
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"preview_features": {
|
||||
"special_repair": {
|
||||
"description": "Creates a **special repair issue** when enabled.\n\nThis demonstrates how lab features can interact with other Home Assistant integrations.",
|
||||
"description": "Creates a **special repair issue** when enabled.\n\nThis demonstrates how Labs features can interact with other Home Assistant integrations.",
|
||||
"disable_confirmation": "This will remove the special repair issue. Don't worry, this is just a demonstration feature.",
|
||||
"enable_confirmation": "This will create a special repair issue to demonstrate Labs preview features. This is just an example and won't affect your actual system.",
|
||||
"name": "Special repair"
|
||||
|
||||
@@ -51,5 +51,10 @@
|
||||
"send": {
|
||||
"service": "mdi:email-arrow-right"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"telegram": {
|
||||
"trigger": "mdi:email-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,5 +773,41 @@
|
||||
},
|
||||
"name": "Send to KNX bus"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"telegram": {
|
||||
"description": "Triggers when a KNX telegram is received or sent.",
|
||||
"fields": {
|
||||
"destination": {
|
||||
"description": "[%key:component::knx::device_automation::extra_fields_descriptions::destination%]",
|
||||
"name": "[%key:component::knx::device_automation::extra_fields::destination%]"
|
||||
},
|
||||
"group_value_read": {
|
||||
"description": "[%key:component::knx::device_automation::extra_fields_descriptions::group_value_read%]",
|
||||
"name": "[%key:component::knx::device_automation::extra_fields::group_value_read%]"
|
||||
},
|
||||
"group_value_response": {
|
||||
"description": "[%key:component::knx::device_automation::extra_fields_descriptions::group_value_response%]",
|
||||
"name": "[%key:component::knx::device_automation::extra_fields::group_value_response%]"
|
||||
},
|
||||
"group_value_write": {
|
||||
"description": "[%key:component::knx::device_automation::extra_fields_descriptions::group_value_write%]",
|
||||
"name": "[%key:component::knx::device_automation::extra_fields::group_value_write%]"
|
||||
},
|
||||
"incoming": {
|
||||
"description": "[%key:component::knx::device_automation::extra_fields_descriptions::incoming%]",
|
||||
"name": "[%key:component::knx::device_automation::extra_fields::incoming%]"
|
||||
},
|
||||
"outgoing": {
|
||||
"description": "[%key:component::knx::device_automation::extra_fields_descriptions::outgoing%]",
|
||||
"name": "[%key:component::knx::device_automation::extra_fields::outgoing%]"
|
||||
},
|
||||
"type": {
|
||||
"description": "DPT used for payload decoding. If not set, it will be sourced from project file.",
|
||||
"name": "DPT"
|
||||
}
|
||||
},
|
||||
"name": "KNX telegram"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
homeassistant/components/knx/triggers.yaml
Normal file
39
homeassistant/components/knx/triggers.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# Describes the format for KNX triggers
|
||||
|
||||
telegram:
|
||||
fields:
|
||||
destination:
|
||||
example: "1/2/3"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
group_value_write:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
group_value_response:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
group_value_read:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
incoming:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
outgoing:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
type:
|
||||
example: "9.001"
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
@@ -86,6 +86,12 @@
|
||||
"current_phase": {
|
||||
"default": "mdi:state-machine"
|
||||
},
|
||||
"door_closed_events": {
|
||||
"default": "mdi:door-closed"
|
||||
},
|
||||
"door_open_events": {
|
||||
"default": "mdi:door-open"
|
||||
},
|
||||
"esa_opt_out_state": {
|
||||
"default": "mdi:home-lightning-bolt"
|
||||
},
|
||||
|
||||
@@ -1488,4 +1488,30 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="DoorLockDoorOpenEvents",
|
||||
translation_key="door_open_events",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.DoorLock.Attributes.DoorOpenEvents,),
|
||||
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="DoorLockDoorClosedEvents",
|
||||
translation_key="door_closed_events",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.DoorLock.Attributes.DoorClosedEvents,),
|
||||
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -375,6 +375,12 @@
|
||||
"current_phase": {
|
||||
"name": "Current phase"
|
||||
},
|
||||
"door_closed_events": {
|
||||
"name": "Door closed events"
|
||||
},
|
||||
"door_open_events": {
|
||||
"name": "Door open events"
|
||||
},
|
||||
"energy_exported": {
|
||||
"name": "Energy exported"
|
||||
},
|
||||
|
||||
@@ -81,6 +81,9 @@ async def async_setup_entry(
|
||||
SERVICE_PUBLISH,
|
||||
SERVICE_PUBLISH_SCHEMA,
|
||||
"publish",
|
||||
description_placeholders={
|
||||
"markdown_guide_url": "https://www.markdownguide.org/basic-syntax/"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -345,7 +345,7 @@
|
||||
"name": "Icon URL"
|
||||
},
|
||||
"markdown": {
|
||||
"description": "Enable Markdown formatting for the message body. See the Markdown guide for syntax details: https://www.markdownguide.org/basic-syntax/.",
|
||||
"description": "Enable Markdown formatting for the message body. See the Markdown guide for syntax details: {markdown_guide_url}.",
|
||||
"name": "Format as Markdown"
|
||||
},
|
||||
"message": {
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["oralb_ble"],
|
||||
"requirements": ["oralb-ble==0.17.6"]
|
||||
"requirements": ["oralb-ble==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import track_point_in_utc_time
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.async_ import run_callback_threadsafe
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,7 +102,18 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
except OSError:
|
||||
_LOGGER.error("Pilight send failed for %s", str(message_data))
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_NAME, send_code, schema=RF_CODE_SCHEMA)
|
||||
def _register_service() -> None:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_NAME,
|
||||
send_code,
|
||||
schema=RF_CODE_SCHEMA,
|
||||
description_placeholders={
|
||||
"pilight_protocols_docs_url": "https://manual.pilight.org/protocols/index.html"
|
||||
},
|
||||
)
|
||||
|
||||
run_callback_threadsafe(hass.loop, _register_service).result()
|
||||
|
||||
# Publish received codes on the HA event bus
|
||||
# A whitelist of codes to be published in the event bus
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "Sends RF code to Pilight device.",
|
||||
"fields": {
|
||||
"protocol": {
|
||||
"description": "Protocol that Pilight recognizes. See https://manual.pilight.org/protocols/index.html for supported protocols and additional parameters that each protocol supports.",
|
||||
"description": "Protocol that Pilight recognizes. See {pilight_protocols_docs_url} for supported protocols and additional parameters that each protocol supports.",
|
||||
"name": "Protocol"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -251,13 +251,7 @@ class PlaystationNetworkFriendDataCoordinator(
|
||||
def _update_data(self) -> PlaystationNetworkData:
|
||||
"""Update friend status data."""
|
||||
try:
|
||||
return PlaystationNetworkData(
|
||||
username=self.user.online_id,
|
||||
account_id=self.user.account_id,
|
||||
presence=self.user.get_presence(),
|
||||
profile=self.profile,
|
||||
trophy_summary=self.user.trophy_summary(),
|
||||
)
|
||||
presence = self.user.get_presence()
|
||||
except PSNAWPForbiddenError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -267,6 +261,19 @@ class PlaystationNetworkFriendDataCoordinator(
|
||||
except PSNAWPError:
|
||||
raise
|
||||
|
||||
try:
|
||||
trophy_summary = self.user.trophy_summary()
|
||||
except PSNAWPForbiddenError:
|
||||
trophy_summary = None
|
||||
|
||||
return PlaystationNetworkData(
|
||||
username=self.user.online_id,
|
||||
account_id=self.user.account_id,
|
||||
profile=self.profile,
|
||||
presence=presence,
|
||||
trophy_summary=trophy_summary,
|
||||
)
|
||||
|
||||
async def update_data(self) -> PlaystationNetworkData:
|
||||
"""Update friend status data."""
|
||||
return await self.hass.async_add_executor_job(self._update_data)
|
||||
|
||||
@@ -54,8 +54,11 @@ from .const import (
|
||||
)
|
||||
from .coordinator import RainMachineDataUpdateCoordinator
|
||||
|
||||
DEFAULT_SSL = True
|
||||
API_URL_REFERENCE = (
|
||||
"https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post"
|
||||
)
|
||||
|
||||
DEFAULT_SSL = True
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -455,7 +458,15 @@ async def async_setup_entry( # noqa: C901
|
||||
):
|
||||
if hass.services.has_service(DOMAIN, service_name):
|
||||
continue
|
||||
hass.services.async_register(DOMAIN, service_name, method, schema=schema)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
service_name,
|
||||
method,
|
||||
schema=schema,
|
||||
description_placeholders={
|
||||
"api_url": API_URL_REFERENCE,
|
||||
},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"name": "Push flow meter data"
|
||||
},
|
||||
"push_weather_data": {
|
||||
"description": "Sends weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integration.\nSee details of RainMachine API here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.",
|
||||
"description": "Sends weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integration.\nSee details of RainMachine API here: {api_url}",
|
||||
"fields": {
|
||||
"condition": {
|
||||
"description": "Current weather condition code (WNUM).",
|
||||
|
||||
@@ -422,6 +422,8 @@ class ReolinkHost:
|
||||
"name": self._api.nvr_name,
|
||||
"base_url": self._base_url,
|
||||
"network_link": "https://my.home-assistant.io/redirect/network/",
|
||||
"example_ip": "192.168.1.10",
|
||||
"example_url": "http://192.168.1.10:8123",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -436,6 +438,8 @@ class ReolinkHost:
|
||||
translation_placeholders={
|
||||
"base_url": self._base_url,
|
||||
"network_link": "https://my.home-assistant.io/redirect/network/",
|
||||
"example_ip": "192.168.1.10",
|
||||
"example_url": "http://192.168.1.10:8123",
|
||||
},
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.16.6"]
|
||||
"requirements": ["reolink-aio==0.17.1"]
|
||||
}
|
||||
|
||||
@@ -1004,7 +1004,7 @@
|
||||
"title": "Reolink firmware update required"
|
||||
},
|
||||
"https_webhook": {
|
||||
"description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device",
|
||||
"description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `{example_url}` where `{example_ip}` is the IP of the Home Assistant device",
|
||||
"title": "Reolink webhook URL uses HTTPS (SSL)"
|
||||
},
|
||||
"password_too_long": {
|
||||
@@ -1016,7 +1016,7 @@
|
||||
"title": "Reolink incompatible with global SSL certificate"
|
||||
},
|
||||
"webhook_url": {
|
||||
"description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL. Using fast motion/AI state polling until the first ONVIF push is received.",
|
||||
"description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `{example_url}` where `{example_ip}` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL. Using fast motion/AI state polling until the first ONVIF push is received.",
|
||||
"title": "Reolink webhook URL unreachable"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ from roborock import (
|
||||
from roborock.data import UserData
|
||||
from roborock.devices.device import RoborockDevice
|
||||
from roborock.devices.device_manager import UserParams, create_device_manager
|
||||
from roborock.map.map_parser import MapParserConfig
|
||||
|
||||
from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -24,7 +25,16 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS
|
||||
from .const import (
|
||||
CONF_BASE_URL,
|
||||
CONF_SHOW_BACKGROUND,
|
||||
CONF_USER_DATA,
|
||||
DEFAULT_DRAWABLES,
|
||||
DOMAIN,
|
||||
DRAWABLES,
|
||||
MAP_SCALE,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import (
|
||||
RoborockConfigEntry,
|
||||
RoborockCoordinators,
|
||||
@@ -56,6 +66,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
user_params,
|
||||
cache=cache,
|
||||
session=async_get_clientsession(hass),
|
||||
map_parser_config=MapParserConfig(
|
||||
drawables=[
|
||||
drawable
|
||||
for drawable, default_value in DEFAULT_DRAWABLES.items()
|
||||
if entry.options.get(DRAWABLES, {}).get(drawable, default_value)
|
||||
],
|
||||
show_background=entry.options.get(CONF_SHOW_BACKGROUND, False),
|
||||
map_scale=MAP_SCALE,
|
||||
),
|
||||
)
|
||||
except RoborockInvalidCredentials as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==3.9.2",
|
||||
"python-roborock==3.10.2",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["sharkiq"],
|
||||
"requirements": ["sharkiq==1.4.2"]
|
||||
"requirements": ["sharkiq==1.5.0"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from pysmartthings import Capability, Command, SmartThings
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -33,6 +34,13 @@ CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] =
|
||||
key=Capability.CUSTOM_WATER_FILTER,
|
||||
translation_key="reset_water_filter",
|
||||
command=Command.RESET_WATER_FILTER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
Capability.SAMSUNG_CE_HOOD_FILTER: SmartThingsButtonDescription(
|
||||
key=Capability.SAMSUNG_CE_HOOD_FILTER,
|
||||
translation_key="reset_hood_filter",
|
||||
command=Command.RESET_HOOD_FILTER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"reset_hood_filter": {
|
||||
"name": "Reset filter"
|
||||
},
|
||||
"reset_water_filter": {
|
||||
"name": "Reset water filter"
|
||||
},
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["solarlog_cli"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["solarlog_cli==0.6.0"]
|
||||
"requirements": ["solarlog_cli==0.6.1"]
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
|
||||
def _get_starlink_data(self) -> StarlinkData:
|
||||
"""Retrieve Starlink data."""
|
||||
context = self.channel_context
|
||||
status = status_data(context)
|
||||
location = location_data(context)
|
||||
sleep = get_sleep_config(context)
|
||||
status, obstruction, alert = status_data(context)
|
||||
|
||||
@@ -28,6 +28,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import now
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .coordinator import StarlinkConfigEntry, StarlinkData
|
||||
from .entity import StarlinkEntity
|
||||
@@ -91,6 +92,10 @@ class StarlinkAccumulationSensor(StarlinkSensorEntity, RestoreSensor):
|
||||
self._attr_native_value = last_native_value
|
||||
|
||||
|
||||
uptime_to_stable_datetime = ignore_variance(
|
||||
lambda value: now() - timedelta(seconds=value), timedelta(minutes=1)
|
||||
)
|
||||
|
||||
SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
|
||||
StarlinkSensorEntityDescription(
|
||||
key="ping",
|
||||
@@ -150,9 +155,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
|
||||
translation_key="last_restart",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: (
|
||||
now() - timedelta(seconds=data.status["uptime"], milliseconds=-500)
|
||||
).replace(microsecond=0),
|
||||
value_fn=lambda data: uptime_to_stable_datetime(data.status["uptime"]),
|
||||
entity_class=StarlinkSensorEntity,
|
||||
),
|
||||
StarlinkSensorEntityDescription(
|
||||
|
||||
@@ -524,6 +524,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async_send_telegram_message,
|
||||
schema=schema,
|
||||
supports_response=supports_response,
|
||||
description_placeholders={
|
||||
"formatting_options_url": "https://core.telegram.org/bots/api#formatting-options"
|
||||
},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]"
|
||||
},
|
||||
"caption": {
|
||||
"description": "The title of the media.",
|
||||
"description": "[%key:component::telegram_bot::services::send_photo::fields::caption::description%]",
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]"
|
||||
},
|
||||
"chat_id": {
|
||||
@@ -499,7 +499,7 @@
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]"
|
||||
},
|
||||
"caption": {
|
||||
"description": "The title of the animation.",
|
||||
"description": "[%key:component::telegram_bot::services::send_photo::fields::caption::description%]",
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]"
|
||||
},
|
||||
"config_entry_id": {
|
||||
@@ -600,7 +600,7 @@
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]"
|
||||
},
|
||||
"caption": {
|
||||
"description": "The title of the document.",
|
||||
"description": "[%key:component::telegram_bot::services::send_photo::fields::caption::description%]",
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]"
|
||||
},
|
||||
"config_entry_id": {
|
||||
@@ -745,7 +745,7 @@
|
||||
"name": "Keyboard"
|
||||
},
|
||||
"message": {
|
||||
"description": "Message body of the notification.",
|
||||
"description": "Message body of the notification.\nCan't parse entities? Format your message according to the [formatting options]({formatting_options_url}).",
|
||||
"name": "Message"
|
||||
},
|
||||
"message_tag": {
|
||||
@@ -757,7 +757,7 @@
|
||||
"name": "Message thread ID"
|
||||
},
|
||||
"parse_mode": {
|
||||
"description": "Parser for the message text.",
|
||||
"description": "Parser for the message text.\nSee [formatting options]({formatting_options_url}) for more details.",
|
||||
"name": "Parse mode"
|
||||
},
|
||||
"reply_to_message_id": {
|
||||
@@ -787,7 +787,7 @@
|
||||
"name": "Authentication method"
|
||||
},
|
||||
"caption": {
|
||||
"description": "The title of the image.",
|
||||
"description": "The title of the media.\nCan't parse entities? Format your message according to the [formatting options]({formatting_options_url}).",
|
||||
"name": "Caption"
|
||||
},
|
||||
"config_entry_id": {
|
||||
@@ -991,7 +991,7 @@
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]"
|
||||
},
|
||||
"caption": {
|
||||
"description": "The title of the video.",
|
||||
"description": "[%key:component::telegram_bot::services::send_photo::fields::caption::description%]",
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]"
|
||||
},
|
||||
"config_entry_id": {
|
||||
@@ -1070,7 +1070,7 @@
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]"
|
||||
},
|
||||
"caption": {
|
||||
"description": "The title of the voice message.",
|
||||
"description": "[%key:component::telegram_bot::services::send_photo::fields::caption::description%]",
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]"
|
||||
},
|
||||
"config_entry_id": {
|
||||
|
||||
@@ -28,6 +28,9 @@ from homeassistant.helpers import discovery, issue_registry as ir
|
||||
from homeassistant.helpers.device import (
|
||||
async_remove_stale_devices_links_keep_current_device,
|
||||
)
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
)
|
||||
from homeassistant.helpers.reload import async_reload_integration_platforms
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -116,6 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.7
|
||||
async_remove_stale_devices_links_keep_current_device(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
@@ -154,6 +158,41 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from version %s.%s",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
if config_entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if config_entry.version == 1:
|
||||
if config_entry.minor_version < 2:
|
||||
# Remove the template config entry from the source device
|
||||
if source_device_id := config_entry.options.get(CONF_DEVICE_ID):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
)
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, version=1, minor_version=2
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to configuration version %s.%s successful",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None:
|
||||
"""Process config."""
|
||||
coordinators = hass.data.pop(DATA_COORDINATORS, None)
|
||||
|
||||
@@ -697,6 +697,9 @@ class TemplateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
options_flow = OPTIONS_FLOW
|
||||
options_flow_reloads = True
|
||||
|
||||
MINOR_VERSION = 2
|
||||
VERSION = 1
|
||||
|
||||
@callback
|
||||
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
|
||||
"""Return config entry title."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Helpers for template integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
import hashlib
|
||||
import itertools
|
||||
import logging
|
||||
@@ -33,6 +33,7 @@ from homeassistant.helpers.entity_platform import (
|
||||
async_get_platforms,
|
||||
)
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity
|
||||
from homeassistant.helpers.script_variables import ScriptVariables
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import yaml as yaml_util
|
||||
@@ -190,12 +191,12 @@ def async_create_template_tracking_entities(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
def _format_template(value: Any) -> Any:
|
||||
def _format_template(value: Any, field: str | None = None) -> Any:
|
||||
if isinstance(value, template.Template):
|
||||
return value.template
|
||||
|
||||
if isinstance(value, Enum):
|
||||
return value.name
|
||||
if isinstance(value, StrEnum):
|
||||
return value.value
|
||||
|
||||
if isinstance(value, (int, float, str, bool)):
|
||||
return value
|
||||
@@ -207,14 +208,13 @@ def format_migration_config(
|
||||
config: ConfigType | list[ConfigType], depth: int = 0
|
||||
) -> ConfigType | list[ConfigType]:
|
||||
"""Recursive method to format templates as strings from ConfigType."""
|
||||
types = (dict, list)
|
||||
if depth > 9:
|
||||
raise RecursionError
|
||||
|
||||
if isinstance(config, list):
|
||||
items = []
|
||||
for item in config:
|
||||
if isinstance(item, types):
|
||||
if isinstance(item, (dict, list)):
|
||||
if len(item) > 0:
|
||||
items.append(format_migration_config(item, depth + 1))
|
||||
else:
|
||||
@@ -223,9 +223,18 @@ def format_migration_config(
|
||||
|
||||
formatted_config = {}
|
||||
for field, value in config.items():
|
||||
if isinstance(value, types):
|
||||
if isinstance(value, dict):
|
||||
if len(value) > 0:
|
||||
formatted_config[field] = format_migration_config(value, depth + 1)
|
||||
elif isinstance(value, list):
|
||||
if len(value) > 0:
|
||||
formatted_config[field] = format_migration_config(value, depth + 1)
|
||||
else:
|
||||
formatted_config[field] = []
|
||||
elif isinstance(value, ScriptVariables):
|
||||
formatted_config[field] = format_migration_config(
|
||||
value.as_dict(), depth + 1
|
||||
)
|
||||
else:
|
||||
formatted_config[field] = _format_template(value)
|
||||
|
||||
@@ -261,8 +270,6 @@ def create_legacy_template_issue(
|
||||
config.pop(CONF_PLATFORM, None)
|
||||
modified_yaml = format_migration_config(config)
|
||||
yaml_config = yaml_util.dump({DOMAIN: [{domain: [modified_yaml]}]})
|
||||
# Format to show up properly in a numbered bullet on the repair.
|
||||
yaml_config = " ```\n " + yaml_config.replace("\n", "\n ") + "```"
|
||||
except RecursionError:
|
||||
yaml_config = f"{DOMAIN}:\n - {domain}: - ..."
|
||||
|
||||
@@ -278,6 +285,7 @@ def create_legacy_template_issue(
|
||||
"domain": domain,
|
||||
"breadcrumb": breadcrumb,
|
||||
"config": yaml_config,
|
||||
"filename": "<filename>",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -529,7 +529,7 @@
|
||||
"title": "Deprecated battery level option in {entity_name}"
|
||||
},
|
||||
"deprecated_legacy_templates": {
|
||||
"description": "The legacy `platform: template` syntax for `{domain}` is being removed. Please migrate `{breadcrumb}` to the modern template syntax.\n\n1. Remove existing template definition.\n2. Add new template definition:\n{config}\n3. Restart Home Assistant or reload template entities.",
|
||||
"description": "The legacy `platform: template` syntax for `{domain}` is being removed. Please migrate `{breadcrumb}` to the modern template syntax.\n#### Step 1 - Remove legacy configuration\nRemove the `{breadcrumb}` template definition from the `configuration.yaml` `{domain}:` section.\n\n**Note:** If you are using `{domain}: !include {filename}.yaml` in `configuration.yaml`, remove the {domain} definition from the included `{filename}.yaml`.\n#### Step 2 - Add the modern configuration\nAdd new template definition inside `configuration.yaml`:\n\n```{config}```\n\n**Note:** If there are any existing `template:` sections in your configuration, make sure to omit the `template:` line from the yaml above. There can only be 1 `template:` section in `configuration.yaml`. Also, ensure the indentation is aligned with the existing entities within the `template:` section.\n#### Step 3 - Restart Home Assistant or reload template entities",
|
||||
"title": "Legacy {domain} template deprecation"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -325,6 +325,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
vol.Required(ATTR_TOU_SETTINGS): dict,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"time_of_use_url": "https://developer.tesla.com/docs/fleet-api#time_of_use_settings"
|
||||
},
|
||||
)
|
||||
|
||||
async def add_charge_schedule(call: ServiceCall) -> None:
|
||||
|
||||
@@ -1358,7 +1358,7 @@
|
||||
"name": "Energy Site"
|
||||
},
|
||||
"tou_settings": {
|
||||
"description": "See https://developer.tesla.com/docs/fleet-api#time_of_use_settings for details.",
|
||||
"description": "See {time_use_url} for details.",
|
||||
"name": "Settings"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -208,6 +208,18 @@ def _get_temperature_wrappers(
|
||||
device, DPCode.TEMP_SET_F, prefer_function=True
|
||||
)
|
||||
|
||||
# If there is a temp unit convert dpcode, override empty units
|
||||
if (
|
||||
temp_unit_convert := DPCodeEnumWrapper.find_dpcode(
|
||||
device, DPCode.TEMP_UNIT_CONVERT
|
||||
)
|
||||
) is not None:
|
||||
for wrapper in (temp_current, temp_current_f, temp_set, temp_set_f):
|
||||
if wrapper is not None and not wrapper.type_information.unit:
|
||||
wrapper.type_information.unit = temp_unit_convert.read_device_status(
|
||||
device
|
||||
)
|
||||
|
||||
# Get wrappers for celsius and fahrenheit
|
||||
# We need to check the unit of measurement
|
||||
current_celsius = _get_temperature_wrapper(
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import DOMAIN, DPCode
|
||||
from .models import DEVICE_WARNINGS
|
||||
from .type_information import DEVICE_WARNINGS
|
||||
|
||||
_REDACTED_DPCODES = {
|
||||
DPCode.ALARM_MESSAGE,
|
||||
|
||||
@@ -80,7 +80,7 @@ class _FanSpeedEnumWrapper(DPCodeEnumWrapper):
|
||||
"""Get the number of speeds supported by the fan."""
|
||||
return len(self.type_information.range)
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None: # type: ignore[override]
|
||||
def read_device_status(self, device: CustomerDevice) -> int | None:
|
||||
"""Get the current speed as a percentage."""
|
||||
if (value := super().read_device_status(device)) is None:
|
||||
return None
|
||||
|
||||
@@ -35,8 +35,8 @@ from .models import (
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
DPCodeJsonWrapper,
|
||||
IntegerTypeData,
|
||||
)
|
||||
from .type_information import IntegerTypeInformation
|
||||
from .util import remap_value
|
||||
|
||||
|
||||
@@ -138,24 +138,24 @@ class _ColorTempWrapper(DPCodeIntegerWrapper):
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_H_TYPE = IntegerTypeData(
|
||||
DEFAULT_H_TYPE = IntegerTypeInformation(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1
|
||||
)
|
||||
DEFAULT_S_TYPE = IntegerTypeData(
|
||||
DEFAULT_S_TYPE = IntegerTypeInformation(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1
|
||||
)
|
||||
DEFAULT_V_TYPE = IntegerTypeData(
|
||||
DEFAULT_V_TYPE = IntegerTypeInformation(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_H_TYPE_V2 = IntegerTypeData(
|
||||
DEFAULT_H_TYPE_V2 = IntegerTypeInformation(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1
|
||||
)
|
||||
DEFAULT_S_TYPE_V2 = IntegerTypeData(
|
||||
DEFAULT_S_TYPE_V2 = IntegerTypeInformation(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1
|
||||
)
|
||||
DEFAULT_V_TYPE_V2 = IntegerTypeData(
|
||||
DEFAULT_V_TYPE_V2 = IntegerTypeInformation(
|
||||
dpcode=DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1
|
||||
)
|
||||
|
||||
@@ -578,15 +578,15 @@ def _get_color_data_wrapper(
|
||||
if function_data := json_loads_object(
|
||||
cast(str, color_data_wrapper.type_information.type_data)
|
||||
):
|
||||
color_data_wrapper.h_type = IntegerTypeData(
|
||||
color_data_wrapper.h_type = IntegerTypeInformation(
|
||||
dpcode=color_data_wrapper.dpcode,
|
||||
**cast(dict, function_data["h"]),
|
||||
)
|
||||
color_data_wrapper.s_type = IntegerTypeData(
|
||||
color_data_wrapper.s_type = IntegerTypeInformation(
|
||||
dpcode=color_data_wrapper.dpcode,
|
||||
**cast(dict, function_data["s"]),
|
||||
)
|
||||
color_data_wrapper.v_type = IntegerTypeData(
|
||||
color_data_wrapper.v_type = IntegerTypeInformation(
|
||||
dpcode=color_data_wrapper.dpcode,
|
||||
**cast(dict, function_data["v"]),
|
||||
)
|
||||
|
||||
@@ -3,166 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, Self, cast, overload
|
||||
from typing import Any, Self
|
||||
|
||||
from tuya_sharing import CustomerDevice
|
||||
|
||||
from homeassistant.util.json import json_loads, json_loads_object
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .const import LOGGER, DPType
|
||||
from .util import parse_dptype, remap_value
|
||||
|
||||
# Dictionary to track logged warnings to avoid spamming logs
|
||||
# Keyed by device ID
|
||||
DEVICE_WARNINGS: dict[str, set[str]] = {}
|
||||
|
||||
|
||||
def _should_log_warning(device_id: str, warning_key: str) -> bool:
|
||||
"""Check if a warning has already been logged for a device and add it if not.
|
||||
|
||||
Returns: False if the warning was already logged, True if it was added.
|
||||
"""
|
||||
if (device_warnings := DEVICE_WARNINGS.get(device_id)) is None:
|
||||
device_warnings = set()
|
||||
DEVICE_WARNINGS[device_id] = device_warnings
|
||||
if warning_key in device_warnings:
|
||||
return False
|
||||
DEVICE_WARNINGS[device_id].add(warning_key)
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class TypeInformation:
|
||||
"""Type information.
|
||||
|
||||
As provided by the SDK, from `device.function` / `device.status_range`.
|
||||
"""
|
||||
|
||||
dpcode: str
|
||||
type_data: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a TypeInformation object."""
|
||||
return cls(dpcode=dpcode, type_data=type_data)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class IntegerTypeData(TypeInformation):
|
||||
"""Integer Type Data."""
|
||||
|
||||
min: int
|
||||
max: int
|
||||
scale: int
|
||||
step: int
|
||||
unit: str | None = None
|
||||
|
||||
@property
|
||||
def max_scaled(self) -> float:
|
||||
"""Return the max scaled."""
|
||||
return self.scale_value(self.max)
|
||||
|
||||
@property
|
||||
def min_scaled(self) -> float:
|
||||
"""Return the min scaled."""
|
||||
return self.scale_value(self.min)
|
||||
|
||||
@property
|
||||
def step_scaled(self) -> float:
|
||||
"""Return the step scaled."""
|
||||
return self.step / (10**self.scale)
|
||||
|
||||
def scale_value(self, value: int) -> float:
|
||||
"""Scale a value."""
|
||||
return value / (10**self.scale)
|
||||
|
||||
def scale_value_back(self, value: float) -> int:
|
||||
"""Return raw value for scaled."""
|
||||
return round(value * (10**self.scale))
|
||||
|
||||
def remap_value_to(
|
||||
self,
|
||||
value: float,
|
||||
to_min: float = 0,
|
||||
to_max: float = 255,
|
||||
reverse: bool = False,
|
||||
) -> float:
|
||||
"""Remap a value from this range to a new range."""
|
||||
return remap_value(value, self.min, self.max, to_min, to_max, reverse)
|
||||
|
||||
def remap_value_from(
|
||||
self,
|
||||
value: float,
|
||||
from_min: float = 0,
|
||||
from_max: float = 255,
|
||||
reverse: bool = False,
|
||||
) -> float:
|
||||
"""Remap a value from its current range to this range."""
|
||||
return remap_value(value, from_min, from_max, self.min, self.max, reverse)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a IntegerTypeData object."""
|
||||
if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))):
|
||||
return None
|
||||
|
||||
return cls(
|
||||
dpcode=dpcode,
|
||||
type_data=type_data,
|
||||
min=int(parsed["min"]),
|
||||
max=int(parsed["max"]),
|
||||
scale=int(parsed["scale"]),
|
||||
step=int(parsed["step"]),
|
||||
unit=parsed.get("unit"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BitmapTypeInformation(TypeInformation):
|
||||
"""Bitmap type information."""
|
||||
|
||||
label: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a BitmapTypeInformation object."""
|
||||
if not (parsed := json_loads_object(type_data)):
|
||||
return None
|
||||
return cls(
|
||||
dpcode=dpcode,
|
||||
type_data=type_data,
|
||||
**cast(dict[str, list[str]], parsed),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class EnumTypeData(TypeInformation):
|
||||
"""Enum Type Data."""
|
||||
|
||||
range: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a EnumTypeData object."""
|
||||
if not (parsed := json_loads_object(type_data)):
|
||||
return None
|
||||
return cls(
|
||||
dpcode=dpcode,
|
||||
type_data=type_data,
|
||||
**cast(dict[str, list[str]], parsed),
|
||||
)
|
||||
|
||||
|
||||
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||
DPType.BITMAP: BitmapTypeInformation,
|
||||
DPType.BOOLEAN: TypeInformation,
|
||||
DPType.ENUM: EnumTypeData,
|
||||
DPType.INTEGER: IntegerTypeData,
|
||||
DPType.JSON: TypeInformation,
|
||||
DPType.RAW: TypeInformation,
|
||||
DPType.STRING: TypeInformation,
|
||||
}
|
||||
from .const import DPType
|
||||
from .type_information import (
|
||||
EnumTypeInformation,
|
||||
IntegerTypeInformation,
|
||||
TypeInformation,
|
||||
find_dpcode,
|
||||
)
|
||||
|
||||
|
||||
class DeviceWrapper:
|
||||
@@ -234,6 +87,12 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
super().__init__(dpcode)
|
||||
self.type_information = type_information
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> Any | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
return self.type_information.process_raw_value(
|
||||
self._read_device_status_raw(device), device
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find_dpcode(
|
||||
cls,
|
||||
@@ -274,12 +133,6 @@ class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
|
||||
DPTYPE = DPType.BOOLEAN
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) in (True, False):
|
||||
return raw_value
|
||||
return None
|
||||
|
||||
def _convert_value_to_raw_value(
|
||||
self, device: CustomerDevice, value: Any
|
||||
) -> Any | None:
|
||||
@@ -303,34 +156,11 @@ class DPCodeJsonWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
return json_loads(raw_value)
|
||||
|
||||
|
||||
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
|
||||
"""Simple wrapper for EnumTypeData values."""
|
||||
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]):
|
||||
"""Simple wrapper for EnumTypeInformation values."""
|
||||
|
||||
DPTYPE = DPType.ENUM
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> str | None:
|
||||
"""Read the device value for the dpcode.
|
||||
|
||||
Values outside of the list defined by the Enum type information will
|
||||
return None.
|
||||
"""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
if raw_value not in self.type_information.range:
|
||||
if _should_log_warning(
|
||||
device.id, f"enum_out_range|{self.dpcode}|{raw_value}"
|
||||
):
|
||||
LOGGER.warning(
|
||||
"Found invalid enum value `%s` for datapoint `%s` in product id `%s`,"
|
||||
" expected one of `%s`; please report this defect to Tuya support",
|
||||
raw_value,
|
||||
self.dpcode,
|
||||
device.product_id,
|
||||
self.type_information.range,
|
||||
)
|
||||
return None
|
||||
return raw_value
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
if value in self.type_information.range:
|
||||
@@ -342,25 +172,16 @@ class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
|
||||
)
|
||||
|
||||
|
||||
class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
|
||||
"""Simple wrapper for IntegerTypeData values."""
|
||||
class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeInformation]):
|
||||
"""Simple wrapper for IntegerTypeInformation values."""
|
||||
|
||||
DPTYPE = DPType.INTEGER
|
||||
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeData) -> None:
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
|
||||
"""Init DPCodeIntegerWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.native_unit = type_information.unit
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode.
|
||||
|
||||
Value will be scaled based on the Integer type information.
|
||||
"""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
return raw_value / (10**self.type_information.scale)
|
||||
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
new_value = round(value * (10**self.type_information.scale))
|
||||
@@ -379,10 +200,6 @@ class DPCodeStringWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
|
||||
DPTYPE = DPType.STRING
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> str | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
return self._read_device_status_raw(device)
|
||||
|
||||
|
||||
class DPCodeBitmapBitWrapper(DPCodeWrapper):
|
||||
"""Simple wrapper for a specific bit in bitmap values."""
|
||||
@@ -414,82 +231,3 @@ class DPCodeBitmapBitWrapper(DPCodeWrapper):
|
||||
type_information.dpcode, type_information.label.index(bitmap_key)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.BITMAP],
|
||||
) -> BitmapTypeInformation | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.ENUM],
|
||||
) -> EnumTypeData | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.INTEGER],
|
||||
) -> IntegerTypeData | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.BOOLEAN, DPType.JSON, DPType.RAW],
|
||||
) -> TypeInformation | None: ...
|
||||
|
||||
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: DPType,
|
||||
) -> TypeInformation | None:
|
||||
"""Find type information for a matching DP code available for this device."""
|
||||
if not (type_information_cls := _TYPE_INFORMATION_MAPPINGS.get(dptype)):
|
||||
raise NotImplementedError(f"find_dpcode not supported for {dptype}")
|
||||
|
||||
if dpcodes is None:
|
||||
return None
|
||||
|
||||
if not isinstance(dpcodes, tuple):
|
||||
dpcodes = (dpcodes,)
|
||||
|
||||
lookup_tuple = (
|
||||
(device.function, device.status_range)
|
||||
if prefer_function
|
||||
else (device.status_range, device.function)
|
||||
)
|
||||
|
||||
for dpcode in dpcodes:
|
||||
for device_specs in lookup_tuple:
|
||||
if (
|
||||
(current_definition := device_specs.get(dpcode))
|
||||
and parse_dptype(current_definition.type) is dptype
|
||||
and (
|
||||
type_information := type_information_cls.from_json(
|
||||
dpcode=dpcode, type_data=current_definition.values
|
||||
)
|
||||
)
|
||||
):
|
||||
return type_information
|
||||
|
||||
return None
|
||||
|
||||
@@ -25,7 +25,7 @@ from .const import (
|
||||
DPCode,
|
||||
)
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeIntegerWrapper, IntegerTypeData
|
||||
from .models import DPCodeIntegerWrapper
|
||||
|
||||
NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = {
|
||||
DeviceCategory.BH: (
|
||||
@@ -483,8 +483,6 @@ async def async_setup_entry(
|
||||
class TuyaNumberEntity(TuyaEntity, NumberEntity):
|
||||
"""Tuya Number Entity."""
|
||||
|
||||
_number: IntegerTypeData | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CustomerDevice,
|
||||
|
||||
@@ -46,12 +46,12 @@ from .models import (
|
||||
DPCodeJsonWrapper,
|
||||
DPCodeTypeInformationWrapper,
|
||||
DPCodeWrapper,
|
||||
EnumTypeData,
|
||||
)
|
||||
from .raw_data_models import ElectricityData
|
||||
from .type_information import EnumTypeInformation
|
||||
|
||||
|
||||
class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
|
||||
class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]):
|
||||
"""Custom DPCode Wrapper for converting enum to wind direction."""
|
||||
|
||||
DPTYPE = DPType.ENUM
|
||||
|
||||
339
homeassistant/components/tuya/type_information.py
Normal file
339
homeassistant/components/tuya/type_information.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""Type information classes for the Tuya integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, Self, cast, overload
|
||||
|
||||
from tuya_sharing import CustomerDevice
|
||||
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
from .const import LOGGER, DPType
|
||||
from .util import parse_dptype, remap_value
|
||||
|
||||
# Dictionary to track logged warnings to avoid spamming logs
|
||||
# Keyed by device ID
|
||||
DEVICE_WARNINGS: dict[str, set[str]] = {}
|
||||
|
||||
|
||||
def _should_log_warning(device_id: str, warning_key: str) -> bool:
|
||||
"""Check if a warning has already been logged for a device and add it if not.
|
||||
|
||||
Returns: True if the warning should be logged, False if it was already logged.
|
||||
"""
|
||||
if (device_warnings := DEVICE_WARNINGS.get(device_id)) is None:
|
||||
device_warnings = set()
|
||||
DEVICE_WARNINGS[device_id] = device_warnings
|
||||
if warning_key in device_warnings:
|
||||
return False
|
||||
DEVICE_WARNINGS[device_id].add(warning_key)
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class TypeInformation[T]:
|
||||
"""Type information.
|
||||
|
||||
As provided by the SDK, from `device.function` / `device.status_range`.
|
||||
"""
|
||||
|
||||
dpcode: str
|
||||
type_data: str | None = None
|
||||
|
||||
def process_raw_value(
|
||||
self, raw_value: Any | None, device: CustomerDevice
|
||||
) -> T | None:
|
||||
"""Read and process raw value against this type information.
|
||||
|
||||
Base implementation does no validation, subclasses may override to provide
|
||||
specific validation.
|
||||
"""
|
||||
return raw_value
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a TypeInformation object."""
|
||||
return cls(dpcode=dpcode, type_data=type_data)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BitmapTypeInformation(TypeInformation[int]):
|
||||
"""Bitmap type information."""
|
||||
|
||||
label: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return a BitmapTypeInformation object."""
|
||||
if not (parsed := json_loads_object(type_data)):
|
||||
return None
|
||||
return cls(
|
||||
dpcode=dpcode,
|
||||
type_data=type_data,
|
||||
**cast(dict[str, list[str]], parsed),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BooleanTypeInformation(TypeInformation[bool]):
|
||||
"""Boolean type information."""
|
||||
|
||||
def process_raw_value(
|
||||
self, raw_value: Any | None, device: CustomerDevice
|
||||
) -> bool | None:
|
||||
"""Read and process raw value against this type information."""
|
||||
if raw_value is None:
|
||||
return None
|
||||
# Validate input against defined range
|
||||
if raw_value not in (True, False):
|
||||
if _should_log_warning(
|
||||
device.id, f"boolean_out_range|{self.dpcode}|{raw_value}"
|
||||
):
|
||||
LOGGER.warning(
|
||||
"Found invalid boolean value `%s` for datapoint `%s` in product "
|
||||
"id `%s`, expected one of `%s`; please report this defect to "
|
||||
"Tuya support",
|
||||
raw_value,
|
||||
self.dpcode,
|
||||
device.product_id,
|
||||
(True, False),
|
||||
)
|
||||
return None
|
||||
return raw_value
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class EnumTypeInformation(TypeInformation[str]):
|
||||
"""Enum type information."""
|
||||
|
||||
range: list[str]
|
||||
|
||||
def process_raw_value(
|
||||
self, raw_value: Any | None, device: CustomerDevice
|
||||
) -> str | None:
|
||||
"""Read and process raw value against this type information."""
|
||||
if raw_value is None:
|
||||
return None
|
||||
# Validate input against defined range
|
||||
if raw_value not in self.range:
|
||||
if _should_log_warning(
|
||||
device.id, f"enum_out_range|{self.dpcode}|{raw_value}"
|
||||
):
|
||||
LOGGER.warning(
|
||||
"Found invalid enum value `%s` for datapoint `%s` in product "
|
||||
"id `%s`, expected one of `%s`; please report this defect to "
|
||||
"Tuya support",
|
||||
raw_value,
|
||||
self.dpcode,
|
||||
device.product_id,
|
||||
self.range,
|
||||
)
|
||||
return None
|
||||
return raw_value
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return an EnumTypeInformation object."""
|
||||
if not (parsed := json_loads_object(type_data)):
|
||||
return None
|
||||
return cls(
|
||||
dpcode=dpcode,
|
||||
type_data=type_data,
|
||||
**cast(dict[str, list[str]], parsed),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class IntegerTypeInformation(TypeInformation[float]):
|
||||
"""Integer type information."""
|
||||
|
||||
min: int
|
||||
max: int
|
||||
scale: int
|
||||
step: int
|
||||
unit: str | None = None
|
||||
|
||||
@property
|
||||
def max_scaled(self) -> float:
|
||||
"""Return the max scaled."""
|
||||
return self.scale_value(self.max)
|
||||
|
||||
@property
|
||||
def min_scaled(self) -> float:
|
||||
"""Return the min scaled."""
|
||||
return self.scale_value(self.min)
|
||||
|
||||
@property
|
||||
def step_scaled(self) -> float:
|
||||
"""Return the step scaled."""
|
||||
return self.step / (10**self.scale)
|
||||
|
||||
def scale_value(self, value: int) -> float:
|
||||
"""Scale a value."""
|
||||
return value / (10**self.scale)
|
||||
|
||||
def scale_value_back(self, value: float) -> int:
|
||||
"""Return raw value for scaled."""
|
||||
return round(value * (10**self.scale))
|
||||
|
||||
def remap_value_to(
|
||||
self,
|
||||
value: float,
|
||||
to_min: float = 0,
|
||||
to_max: float = 255,
|
||||
reverse: bool = False,
|
||||
) -> float:
|
||||
"""Remap a value from this range to a new range."""
|
||||
return remap_value(value, self.min, self.max, to_min, to_max, reverse)
|
||||
|
||||
def remap_value_from(
|
||||
self,
|
||||
value: float,
|
||||
from_min: float = 0,
|
||||
from_max: float = 255,
|
||||
reverse: bool = False,
|
||||
) -> float:
|
||||
"""Remap a value from its current range to this range."""
|
||||
return remap_value(value, from_min, from_max, self.min, self.max, reverse)
|
||||
|
||||
def process_raw_value(
|
||||
self, raw_value: Any | None, device: CustomerDevice
|
||||
) -> float | None:
|
||||
"""Read and process raw value against this type information."""
|
||||
if raw_value is None:
|
||||
return None
|
||||
# Validate input against defined range
|
||||
if not isinstance(raw_value, int) or not (self.min <= raw_value <= self.max):
|
||||
if _should_log_warning(
|
||||
device.id, f"integer_out_range|{self.dpcode}|{raw_value}"
|
||||
):
|
||||
LOGGER.warning(
|
||||
"Found invalid integer value `%s` for datapoint `%s` in product "
|
||||
"id `%s`, expected integer value between %s and %s; please report "
|
||||
"this defect to Tuya support",
|
||||
raw_value,
|
||||
self.dpcode,
|
||||
device.product_id,
|
||||
self.min,
|
||||
self.max,
|
||||
)
|
||||
|
||||
return None
|
||||
return raw_value / (10**self.scale)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: str, type_data: str) -> Self | None:
|
||||
"""Load JSON string and return an IntegerTypeInformation object."""
|
||||
if not (parsed := cast(dict[str, Any] | None, json_loads_object(type_data))):
|
||||
return None
|
||||
|
||||
return cls(
|
||||
dpcode=dpcode,
|
||||
type_data=type_data,
|
||||
min=int(parsed["min"]),
|
||||
max=int(parsed["max"]),
|
||||
scale=int(parsed["scale"]),
|
||||
step=int(parsed["step"]),
|
||||
unit=parsed.get("unit"),
|
||||
)
|
||||
|
||||
|
||||
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||
DPType.BITMAP: BitmapTypeInformation,
|
||||
DPType.BOOLEAN: BooleanTypeInformation,
|
||||
DPType.ENUM: EnumTypeInformation,
|
||||
DPType.INTEGER: IntegerTypeInformation,
|
||||
DPType.JSON: TypeInformation,
|
||||
DPType.RAW: TypeInformation,
|
||||
DPType.STRING: TypeInformation,
|
||||
}
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.BITMAP],
|
||||
) -> BitmapTypeInformation | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.BOOLEAN],
|
||||
) -> BooleanTypeInformation | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.ENUM],
|
||||
) -> EnumTypeInformation | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.INTEGER],
|
||||
) -> IntegerTypeInformation | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.JSON, DPType.RAW],
|
||||
) -> TypeInformation | None: ...
|
||||
|
||||
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | tuple[str, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: DPType,
|
||||
) -> TypeInformation | None:
|
||||
"""Find type information for a matching DP code available for this device."""
|
||||
if not (type_information_cls := _TYPE_INFORMATION_MAPPINGS.get(dptype)):
|
||||
raise NotImplementedError(f"find_dpcode not supported for {dptype}")
|
||||
|
||||
if dpcodes is None:
|
||||
return None
|
||||
|
||||
if not isinstance(dpcodes, tuple):
|
||||
dpcodes = (dpcodes,)
|
||||
|
||||
lookup_tuple = (
|
||||
(device.function, device.status_range)
|
||||
if prefer_function
|
||||
else (device.status_range, device.function)
|
||||
)
|
||||
|
||||
for dpcode in dpcodes:
|
||||
for device_specs in lookup_tuple:
|
||||
if (
|
||||
(current_definition := device_specs.get(dpcode))
|
||||
and parse_dptype(current_definition.type) is dptype
|
||||
and (
|
||||
type_information := type_information_cls.from_json(
|
||||
dpcode=dpcode, type_data=current_definition.values
|
||||
)
|
||||
)
|
||||
):
|
||||
return type_information
|
||||
|
||||
return None
|
||||
@@ -64,9 +64,9 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
|
||||
|
||||
@callback
|
||||
def discover(devices):
|
||||
def discover(devices: list[VeSyncBaseDevice]) -> None:
|
||||
"""Add new devices to platform."""
|
||||
_setup_entities(devices, async_add_entities)
|
||||
_setup_entities(devices, async_add_entities, coordinator)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
|
||||
@@ -78,7 +78,11 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
@callback
|
||||
def _setup_entities(devices, async_add_entities, coordinator):
|
||||
def _setup_entities(
|
||||
devices: list[VeSyncBaseDevice],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
coordinator: VeSyncDataCoordinator,
|
||||
) -> None:
|
||||
"""Add entity."""
|
||||
async_add_entities(
|
||||
(
|
||||
|
||||
@@ -50,7 +50,7 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
|
||||
|
||||
@callback
|
||||
def discover(devices):
|
||||
def discover(devices: list[VeSyncBaseDevice]) -> None:
|
||||
"""Add new devices to platform."""
|
||||
_setup_entities(devices, async_add_entities, coordinator)
|
||||
|
||||
@@ -66,9 +66,9 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def _setup_entities(
|
||||
devices: list[VeSyncBaseDevice],
|
||||
async_add_entities,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
coordinator: VeSyncDataCoordinator,
|
||||
):
|
||||
) -> None:
|
||||
"""Check if device is fan and add entity."""
|
||||
|
||||
async_add_entities(
|
||||
|
||||
@@ -53,7 +53,7 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
|
||||
|
||||
@callback
|
||||
def discover(devices):
|
||||
def discover(devices: list[VeSyncBaseDevice]) -> None:
|
||||
"""Add new devices to platform."""
|
||||
_setup_entities(devices, async_add_entities, coordinator)
|
||||
|
||||
@@ -73,7 +73,7 @@ def _setup_entities(
|
||||
devices: list[VeSyncBaseDevice],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
coordinator: VeSyncDataCoordinator,
|
||||
):
|
||||
) -> None:
|
||||
"""Add humidifier entities."""
|
||||
async_add_entities(VeSyncHumidifierHA(dev, coordinator) for dev in devices)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
|
||||
|
||||
@callback
|
||||
def discover(devices):
|
||||
def discover(devices: list[VeSyncBaseDevice]) -> None:
|
||||
"""Add new devices to platform."""
|
||||
_setup_entities(devices, async_add_entities, coordinator)
|
||||
|
||||
@@ -54,9 +54,9 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def _setup_entities(
|
||||
devices: list[VeSyncBaseDevice],
|
||||
async_add_entities,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
coordinator: VeSyncDataCoordinator,
|
||||
):
|
||||
) -> None:
|
||||
"""Check if device is a light and add entity."""
|
||||
entities: list[VeSyncBaseLightHA] = []
|
||||
for dev in devices:
|
||||
|
||||
@@ -61,7 +61,7 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
|
||||
|
||||
@callback
|
||||
def discover(devices):
|
||||
def discover(devices: list[VeSyncBaseDevice]) -> None:
|
||||
"""Add new devices to platform."""
|
||||
_setup_entities(devices, async_add_entities, coordinator)
|
||||
|
||||
@@ -79,7 +79,7 @@ def _setup_entities(
|
||||
devices: list[VeSyncBaseDevice],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
coordinator: VeSyncDataCoordinator,
|
||||
):
|
||||
) -> None:
|
||||
"""Add number entities."""
|
||||
|
||||
async_add_entities(
|
||||
|
||||
@@ -115,7 +115,7 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
|
||||
|
||||
@callback
|
||||
def discover(devices):
|
||||
def discover(devices: list[VeSyncBaseDevice]) -> None:
|
||||
"""Add new devices to platform."""
|
||||
_setup_entities(devices, async_add_entities, coordinator)
|
||||
|
||||
@@ -133,7 +133,7 @@ def _setup_entities(
|
||||
devices: list[VeSyncBaseDevice],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
coordinator: VeSyncDataCoordinator,
|
||||
):
|
||||
) -> None:
|
||||
"""Add select entities."""
|
||||
|
||||
async_add_entities(
|
||||
|
||||
@@ -162,7 +162,7 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
|
||||
|
||||
@callback
|
||||
def discover(devices):
|
||||
def discover(devices: list[VeSyncBaseDevice]) -> None:
|
||||
"""Add new devices to platform."""
|
||||
_setup_entities(devices, async_add_entities, coordinator)
|
||||
|
||||
@@ -180,7 +180,7 @@ def _setup_entities(
|
||||
devices: list[VeSyncBaseDevice],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
coordinator: VeSyncDataCoordinator,
|
||||
):
|
||||
) -> None:
|
||||
"""Check if device is online and add entity."""
|
||||
|
||||
async_add_entities(
|
||||
|
||||
@@ -77,7 +77,7 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
|
||||
|
||||
@callback
|
||||
def discover(devices):
|
||||
def discover(devices: list[VeSyncBaseDevice]) -> None:
|
||||
"""Add new devices to platform."""
|
||||
_setup_entities(devices, async_add_entities, coordinator)
|
||||
|
||||
@@ -93,9 +93,9 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def _setup_entities(
|
||||
devices: list[VeSyncBaseDevice],
|
||||
async_add_entities,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
coordinator: VeSyncDataCoordinator,
|
||||
):
|
||||
) -> None:
|
||||
"""Check if device is online and add entity."""
|
||||
async_add_entities(
|
||||
VeSyncSwitchEntity(dev, description, coordinator)
|
||||
|
||||
@@ -22,7 +22,7 @@ async def async_setup_entry(
|
||||
coordinator = hass.data[DOMAIN][VS_COORDINATOR]
|
||||
|
||||
@callback
|
||||
def discover(devices):
|
||||
def discover(devices: list[VeSyncBaseDevice]) -> None:
|
||||
"""Add new devices to platform."""
|
||||
_setup_entities(devices, async_add_entities, coordinator)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user