mirror of
https://github.com/home-assistant/core.git
synced 2025-11-24 18:26:58 +00:00
Compare commits
149 Commits
adguard/ad
...
tibber_dat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6277d0ec2 | ||
|
|
ac69712a51 | ||
|
|
f0e75ba0ed | ||
|
|
e64598e7f5 | ||
|
|
e6f9a8e7d6 | ||
|
|
1e8b42f843 | ||
|
|
430eee0b28 | ||
|
|
b4799aa7ea | ||
|
|
ab45460069 | ||
|
|
c8fd6db3ff | ||
|
|
0a9f200ca4 | ||
|
|
8591335660 | ||
|
|
c01089e994 | ||
|
|
79a7daf89d | ||
|
|
d22867b852 | ||
|
|
ddb74c5af4 | ||
|
|
9aec7b12c2 | ||
|
|
bf42e3769a | ||
|
|
43f40c6f0e | ||
|
|
03ac634e6d | ||
|
|
a204e85d84 | ||
|
|
79c7ad7646 | ||
|
|
704d4c896d | ||
|
|
5b6a4b0fea | ||
|
|
ef5573c693 | ||
|
|
45aecd525a | ||
|
|
ce1146492e | ||
|
|
1ce890b105 | ||
|
|
3e7bef77e5 | ||
|
|
1222828852 | ||
|
|
1ef64582eb | ||
|
|
d363bd63eb | ||
|
|
5916af1115 | ||
|
|
f8bf7ec1ff | ||
|
|
41e42b9581 | ||
|
|
51f68f2776 | ||
|
|
773cb7424c | ||
|
|
eefab75ef0 | ||
|
|
81b4122b73 | ||
|
|
fc8f8b39b4 | ||
|
|
ec0918027e | ||
|
|
8a54f8d4e2 | ||
|
|
5c27126b6d | ||
|
|
e069aff0e2 | ||
|
|
733526fae3 | ||
|
|
1ef001f8e9 | ||
|
|
7732377fde | ||
|
|
b7786e589b | ||
|
|
4f60970a91 | ||
|
|
1c1286dd57 | ||
|
|
41c9f08f60 | ||
|
|
fc4bfab0f7 | ||
|
|
769a12f74e | ||
|
|
dabaa2bc5e | ||
|
|
b674828a91 | ||
|
|
761da66658 | ||
|
|
c8aba62301 | ||
|
|
07ab2e6805 | ||
|
|
f62e0c8c08 | ||
|
|
6ca00f9dbb | ||
|
|
0fba80e30f | ||
|
|
7073c40385 | ||
|
|
8fb9d92daf | ||
|
|
2d81665f99 | ||
|
|
b398935539 | ||
|
|
95f588aae1 | ||
|
|
ffe524d95a | ||
|
|
d7aa939f83 | ||
|
|
77b349d00f | ||
|
|
1c036128fa | ||
|
|
16d898cc8e | ||
|
|
a7225c7cd4 | ||
|
|
433a429c5a | ||
|
|
c4770ed423 | ||
|
|
df329fd273 | ||
|
|
6eb40574bc | ||
|
|
4fd1ef5483 | ||
|
|
7ec5d5305d | ||
|
|
7f31d2538e | ||
|
|
e1943307cf | ||
|
|
a06529d187 | ||
|
|
21554af6a1 | ||
|
|
ee05adfca1 | ||
|
|
168c915b5f | ||
|
|
6c80be52af | ||
|
|
ead92cdf82 | ||
|
|
c0f0cfef59 | ||
|
|
cefc0ba96e | ||
|
|
ad091b1062 | ||
|
|
876bc6d8c4 | ||
|
|
9f206d4363 | ||
|
|
a2d11e6d98 | ||
|
|
3b38af3984 | ||
|
|
3875f91bb9 | ||
|
|
c813776b0c | ||
|
|
3afb421cba | ||
|
|
c16633568b | ||
|
|
87f8ff2bb4 | ||
|
|
b423303f1e | ||
|
|
f6ff222679 | ||
|
|
0152fa0c03 | ||
|
|
37ebbe83bc | ||
|
|
63e036d39e | ||
|
|
f0cbf34a78 | ||
|
|
596bc89ee6 | ||
|
|
b8c877e1d2 | ||
|
|
197d9781cb | ||
|
|
f3f323637e | ||
|
|
9748abc103 | ||
|
|
596f049971 | ||
|
|
dee80cb6f5 | ||
|
|
b4ab73468b | ||
|
|
a300199a97 | ||
|
|
09dd765583 | ||
|
|
0c8b765415 | ||
|
|
0824ec502f | ||
|
|
9e0e353a5f | ||
|
|
e934b006e2 | ||
|
|
05479bb8fd | ||
|
|
d07247566d | ||
|
|
19e6097df6 | ||
|
|
2cff3cf29c | ||
|
|
5cac9b8e5e | ||
|
|
c2a516ea32 | ||
|
|
192b38d3e2 | ||
|
|
bb018e3546 | ||
|
|
4919d73cc5 | ||
|
|
b4aae93c45 | ||
|
|
1f9c244c5c | ||
|
|
9fa1b1b8df | ||
|
|
f3ac3ecf05 | ||
|
|
9477b2206b | ||
|
|
f3ddffb5ff | ||
|
|
9bdfa77fa0 | ||
|
|
c65003009f | ||
|
|
0f722109b7 | ||
|
|
f7d86dec3c | ||
|
|
6b49c8a70c | ||
|
|
ab9a8f3e53 | ||
|
|
4e12628266 | ||
|
|
e6d8d4de42 | ||
|
|
6620b90eb4 | ||
|
|
6fd3af8891 | ||
|
|
46979b8418 | ||
|
|
1718a11de2 | ||
|
|
2016b1d8c7 | ||
|
|
4b72e45fc2 | ||
|
|
ead5ce905b | ||
|
|
f233f2da3f |
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
|
||||
uses: actions/ai-inference@5022b33bc1431add9b2831934daf8147a2ad9331 # v2.0.2
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
|
||||
uses: actions/ai-inference@5022b33bc1431add9b2831934daf8147a2ad9331 # v2.0.2
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -391,6 +391,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dsmr/ @Robbie1221
|
||||
/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
||||
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
||||
/homeassistant/components/duckdns/ @tr4nt0r
|
||||
/tests/components/duckdns/ @tr4nt0r
|
||||
/homeassistant/components/duke_energy/ @hunterjm
|
||||
/tests/components/duke_energy/ @hunterjm
|
||||
/homeassistant/components/duotecno/ @cereal2nd
|
||||
|
||||
@@ -21,10 +21,10 @@ from .const import (
|
||||
ATTR_ITEM_NUMBER,
|
||||
ATTR_SERIAL_NUMBER,
|
||||
ATTR_TYPE_NUMBER,
|
||||
COMPATIBLE_MODELS,
|
||||
CONF_SERIAL_NUMBER,
|
||||
DEFAULT_MODEL,
|
||||
DOMAIN,
|
||||
SELECTABLE_MODELS,
|
||||
)
|
||||
from .util import get_serial_number_from_jid
|
||||
|
||||
@@ -70,7 +70,7 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_MODEL, default=DEFAULT_MODEL): SelectSelector(
|
||||
SelectSelectorConfig(options=COMPATIBLE_MODELS)
|
||||
SelectSelectorConfig(options=SELECTABLE_MODELS)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -62,6 +62,7 @@ class BangOlufsenMediaType(StrEnum):
|
||||
class BangOlufsenModel(StrEnum):
|
||||
"""Enum for compatible model names."""
|
||||
|
||||
# Mozart devices
|
||||
BEOCONNECT_CORE = "Beoconnect Core"
|
||||
BEOLAB_8 = "BeoLab 8"
|
||||
BEOLAB_28 = "BeoLab 28"
|
||||
@@ -71,7 +72,26 @@ class BangOlufsenModel(StrEnum):
|
||||
BEOSOUND_BALANCE = "Beosound Balance"
|
||||
BEOSOUND_EMERGE = "Beosound Emerge"
|
||||
BEOSOUND_LEVEL = "Beosound Level"
|
||||
BEOSOUND_PREMIERE = "Beosound Premiere"
|
||||
BEOSOUND_THEATRE = "Beosound Theatre"
|
||||
# Remote devices
|
||||
BEOREMOTE_ONE = "Beoremote One"
|
||||
|
||||
|
||||
# Physical "buttons" on devices
|
||||
class BangOlufsenButtons(StrEnum):
|
||||
"""Enum for device buttons."""
|
||||
|
||||
BLUETOOTH = "Bluetooth"
|
||||
MICROPHONE = "Microphone"
|
||||
NEXT = "Next"
|
||||
PLAY_PAUSE = "PlayPause"
|
||||
PRESET_1 = "Preset1"
|
||||
PRESET_2 = "Preset2"
|
||||
PRESET_3 = "Preset3"
|
||||
PRESET_4 = "Preset4"
|
||||
PREVIOUS = "Previous"
|
||||
VOLUME = "Volume"
|
||||
|
||||
|
||||
# Dispatcher events
|
||||
@@ -79,6 +99,7 @@ class WebsocketNotification(StrEnum):
|
||||
"""Enum for WebSocket notification types."""
|
||||
|
||||
ACTIVE_LISTENING_MODE = "active_listening_mode"
|
||||
BEO_REMOTE_BUTTON = "beo_remote_button"
|
||||
BUTTON = "button"
|
||||
PLAYBACK_ERROR = "playback_error"
|
||||
PLAYBACK_METADATA = "playback_metadata"
|
||||
@@ -96,6 +117,7 @@ class WebsocketNotification(StrEnum):
|
||||
BEOLINK_AVAILABLE_LISTENERS = "beolinkAvailableListeners"
|
||||
CONFIGURATION = "configuration"
|
||||
NOTIFICATION = "notification"
|
||||
REMOTE_CONTROL_DEVICES = "remoteControlDevices"
|
||||
REMOTE_MENU_CHANGED = "remoteMenuChanged"
|
||||
|
||||
ALL = "all"
|
||||
@@ -111,7 +133,11 @@ CONF_SERIAL_NUMBER: Final = "serial_number"
|
||||
CONF_BEOLINK_JID: Final = "jid"
|
||||
|
||||
# Models to choose from in manual configuration.
|
||||
COMPATIBLE_MODELS: list[str] = [x.value for x in BangOlufsenModel]
|
||||
SELECTABLE_MODELS: list[str] = [
|
||||
model.value for model in BangOlufsenModel if model != BangOlufsenModel.BEOREMOTE_ONE
|
||||
]
|
||||
|
||||
MANUFACTURER: Final[str] = "Bang & Olufsen"
|
||||
|
||||
# Attribute names for zeroconf discovery.
|
||||
ATTR_TYPE_NUMBER: Final[str] = "tn"
|
||||
@@ -204,29 +230,16 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
),
|
||||
]
|
||||
)
|
||||
# Map for storing compatibility of devices.
|
||||
|
||||
MODEL_SUPPORT_DEVICE_BUTTONS: Final[str] = "device_buttons"
|
||||
|
||||
MODEL_SUPPORT_MAP = {
|
||||
MODEL_SUPPORT_DEVICE_BUTTONS: (
|
||||
BangOlufsenModel.BEOLAB_8,
|
||||
BangOlufsenModel.BEOLAB_28,
|
||||
BangOlufsenModel.BEOSOUND_2,
|
||||
BangOlufsenModel.BEOSOUND_A5,
|
||||
BangOlufsenModel.BEOSOUND_A9,
|
||||
BangOlufsenModel.BEOSOUND_BALANCE,
|
||||
BangOlufsenModel.BEOSOUND_EMERGE,
|
||||
BangOlufsenModel.BEOSOUND_LEVEL,
|
||||
BangOlufsenModel.BEOSOUND_THEATRE,
|
||||
)
|
||||
}
|
||||
|
||||
# Device events
|
||||
BANG_OLUFSEN_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] = {
|
||||
# Beoremote One
|
||||
"KeyPress": "key_press",
|
||||
"KeyRelease": "key_release",
|
||||
# Physical "buttons"
|
||||
"shortPress (Release)": "short_press_release",
|
||||
"longPress (Timeout)": "long_press_timeout",
|
||||
"longPress (Release)": "long_press_release",
|
||||
@@ -236,18 +249,7 @@ EVENT_TRANSLATION_MAP: dict[str, str] = {
|
||||
|
||||
CONNECTION_STATUS: Final[str] = "CONNECTION_STATUS"
|
||||
|
||||
DEVICE_BUTTONS: Final[list[str]] = [
|
||||
"Bluetooth",
|
||||
"Microphone",
|
||||
"Next",
|
||||
"PlayPause",
|
||||
"Preset1",
|
||||
"Preset2",
|
||||
"Preset3",
|
||||
"Preset4",
|
||||
"Previous",
|
||||
"Volume",
|
||||
]
|
||||
DEVICE_BUTTONS: Final[list[str]] = [x.value for x in BangOlufsenButtons]
|
||||
|
||||
|
||||
DEVICE_BUTTON_EVENTS: Final[list[str]] = [
|
||||
@@ -258,6 +260,70 @@ DEVICE_BUTTON_EVENTS: Final[list[str]] = [
|
||||
"very_long_press_release",
|
||||
]
|
||||
|
||||
BEO_REMOTE_SUBMENU_CONTROL: Final[str] = "Control"
|
||||
BEO_REMOTE_SUBMENU_LIGHT: Final[str] = "Light"
|
||||
|
||||
# Common for both submenus
|
||||
BEO_REMOTE_KEYS: Final[tuple[str, ...]] = (
|
||||
"Blue",
|
||||
"Digit0",
|
||||
"Digit1",
|
||||
"Digit2",
|
||||
"Digit3",
|
||||
"Digit4",
|
||||
"Digit5",
|
||||
"Digit6",
|
||||
"Digit7",
|
||||
"Digit8",
|
||||
"Digit9",
|
||||
"Down",
|
||||
"Green",
|
||||
"Left",
|
||||
"Play",
|
||||
"Red",
|
||||
"Rewind",
|
||||
"Right",
|
||||
"Select",
|
||||
"Stop",
|
||||
"Up",
|
||||
"Wind",
|
||||
"Yellow",
|
||||
"Func1",
|
||||
"Func2",
|
||||
"Func3",
|
||||
"Func4",
|
||||
"Func5",
|
||||
"Func6",
|
||||
"Func7",
|
||||
"Func8",
|
||||
"Func9",
|
||||
"Func10",
|
||||
"Func11",
|
||||
"Func12",
|
||||
"Func13",
|
||||
"Func14",
|
||||
"Func15",
|
||||
"Func16",
|
||||
"Func17",
|
||||
)
|
||||
|
||||
# "keys" that are unique to the Control submenu
|
||||
BEO_REMOTE_CONTROL_KEYS: Final[tuple[str, ...]] = (
|
||||
"Func18",
|
||||
"Func19",
|
||||
"Func20",
|
||||
"Func21",
|
||||
"Func22",
|
||||
"Func23",
|
||||
"Func24",
|
||||
"Func25",
|
||||
"Func26",
|
||||
"Func27",
|
||||
)
|
||||
|
||||
BEO_REMOTE_KEY_EVENTS: Final[list[str]] = ["key_press", "key_release"]
|
||||
|
||||
|
||||
# Beolink Converter NL/ML sources need to be transformed to upper case
|
||||
BEOLINK_JOIN_SOURCES_TO_UPPER = (
|
||||
"aux_a",
|
||||
|
||||
@@ -6,11 +6,13 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from .const import DEVICE_BUTTONS, DOMAIN
|
||||
from .const import DOMAIN
|
||||
from .util import get_device_buttons
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
@@ -40,7 +42,7 @@ async def async_get_config_entry_diagnostics(
|
||||
data["media_player"] = state_dict
|
||||
|
||||
# Add button Event entity states (if enabled)
|
||||
for device_button in DEVICE_BUTTONS:
|
||||
for device_button in get_device_buttons(config_entry.data[CONF_MODEL]):
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}"
|
||||
):
|
||||
|
||||
@@ -2,22 +2,34 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mozart_api.models import PairedRemote
|
||||
|
||||
from homeassistant.components.event import EventDeviceClass, EventEntity
|
||||
from homeassistant.const import CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
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 .const import (
|
||||
BEO_REMOTE_CONTROL_KEYS,
|
||||
BEO_REMOTE_KEY_EVENTS,
|
||||
BEO_REMOTE_KEYS,
|
||||
BEO_REMOTE_SUBMENU_CONTROL,
|
||||
BEO_REMOTE_SUBMENU_LIGHT,
|
||||
CONNECTION_STATUS,
|
||||
DEVICE_BUTTON_EVENTS,
|
||||
DEVICE_BUTTONS,
|
||||
MODEL_SUPPORT_DEVICE_BUTTONS,
|
||||
MODEL_SUPPORT_MAP,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
BangOlufsenModel,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenEntity
|
||||
from .util import get_device_buttons, get_remotes
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -27,25 +39,87 @@ async def async_setup_entry(
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Sensor entities from config entry."""
|
||||
"""Set up Event entities from config entry."""
|
||||
entities: list[BangOlufsenEvent] = []
|
||||
|
||||
if config_entry.data[CONF_MODEL] in MODEL_SUPPORT_MAP[MODEL_SUPPORT_DEVICE_BUTTONS]:
|
||||
async_add_entities(
|
||||
BangOlufsenButtonEvent(config_entry, button_type)
|
||||
for button_type in DEVICE_BUTTONS
|
||||
async_add_entities(
|
||||
BangOlufsenButtonEvent(config_entry, button_type)
|
||||
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
|
||||
)
|
||||
|
||||
# Check for connected Beoremote One
|
||||
remotes = await get_remotes(config_entry.runtime_data.client)
|
||||
|
||||
for remote in remotes:
|
||||
# Add Light keys
|
||||
entities.extend(
|
||||
[
|
||||
BangOlufsenRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
|
||||
)
|
||||
for key_type in BEO_REMOTE_KEYS
|
||||
]
|
||||
)
|
||||
|
||||
# Add Control keys
|
||||
entities.extend(
|
||||
[
|
||||
BangOlufsenRemoteKeyEvent(
|
||||
config_entry,
|
||||
remote,
|
||||
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
|
||||
)
|
||||
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
|
||||
]
|
||||
)
|
||||
|
||||
class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity):
|
||||
"""Event class for Button events."""
|
||||
# If the remote is no longer available, then delete the device.
|
||||
# The remote may appear as being available to the device after it has been unpaired on the remote
|
||||
# As it has to be removed from the device on the app.
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
devices = device_registry.devices.get_devices_for_config_entry_id(
|
||||
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}
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
|
||||
async_add_entities(new_entities=entities)
|
||||
|
||||
|
||||
class BangOlufsenEvent(BangOlufsenEntity, EventEntity):
|
||||
"""Base Event class."""
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def __init__(self, config_entry: BangOlufsenConfigEntry) -> None:
|
||||
"""Initialize Event."""
|
||||
super().__init__(config_entry, config_entry.runtime_data.client)
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self, event: str) -> None:
|
||||
"""Handle event."""
|
||||
self._trigger_event(event)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class BangOlufsenButtonEvent(BangOlufsenEvent):
|
||||
"""Event class for Button events."""
|
||||
|
||||
_attr_event_types = DEVICE_BUTTON_EVENTS
|
||||
|
||||
def __init__(self, config_entry: BangOlufsenConfigEntry, button_type: str) -> None:
|
||||
"""Initialize Button."""
|
||||
super().__init__(config_entry, config_entry.runtime_data.client)
|
||||
super().__init__(config_entry)
|
||||
|
||||
self._attr_unique_id = f"{self._unique_id}_{button_type}"
|
||||
|
||||
@@ -59,20 +133,65 @@ class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity):
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{CONNECTION_STATUS}",
|
||||
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
|
||||
self._async_update_connection_state,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.BUTTON}_{self._button_type}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BUTTON}_{self._button_type}",
|
||||
self._async_handle_event,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self, event: str) -> None:
|
||||
"""Handle event."""
|
||||
self._trigger_event(event)
|
||||
self.async_write_ha_state()
|
||||
|
||||
class BangOlufsenRemoteKeyEvent(BangOlufsenEvent):
|
||||
"""Event class for Beoremote One key events."""
|
||||
|
||||
_attr_event_types = BEO_REMOTE_KEY_EVENTS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: BangOlufsenConfigEntry,
|
||||
remote: PairedRemote,
|
||||
key_type: str,
|
||||
) -> None:
|
||||
"""Initialize Beoremote One key."""
|
||||
super().__init__(config_entry)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert remote.serial_number
|
||||
|
||||
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,
|
||||
serial_number=remote.serial_number,
|
||||
sw_version=remote.app_version,
|
||||
manufacturer=MANUFACTURER,
|
||||
via_device=(DOMAIN, self._unique_id),
|
||||
)
|
||||
|
||||
# Make the native key name Home Assistant compatible
|
||||
self._attr_translation_key = key_type.lower().replace("/", "_")
|
||||
|
||||
self._key_type = key_type
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Listen to WebSocket Beoremote One key events."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
|
||||
self._async_update_connection_state,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BEO_REMOTE_BUTTON}_{self._key_type}",
|
||||
self._async_handle_event,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,278 @@
|
||||
{
|
||||
"entity": {
|
||||
"event": {
|
||||
"control_blue": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit0": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit1": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit2": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit3": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit4": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit5": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit6": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit7": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit8": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_digit9": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_down": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func1": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func10": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func11": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func12": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func13": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func14": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func15": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func16": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func17": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func18": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func19": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func2": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func20": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func21": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func22": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func23": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func24": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func25": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func26": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func27": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func3": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func4": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func5": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func6": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func7": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func8": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_func9": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_green": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_left": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_play": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_red": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_rewind": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_right": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_select": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_stop": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_up": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_wind": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"control_yellow": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_blue": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit0": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit1": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit2": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit3": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit4": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit5": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit6": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit7": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit8": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_digit9": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_down": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func1": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func10": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func11": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func12": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func13": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func14": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func15": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func16": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func17": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func2": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func3": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func4": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func5": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func6": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func7": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func8": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_func9": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_green": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_left": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_play": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_red": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_rewind": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_right": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_select": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_stop": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_up": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_wind": {
|
||||
"default": "mdi:remote"
|
||||
},
|
||||
"light_yellow": {
|
||||
"default": "mdi:remote"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"beolink_allstandby": { "service": "mdi:close-circle-multiple-outline" },
|
||||
"beolink_expand": { "service": "mdi:location-enter" },
|
||||
|
||||
@@ -80,6 +80,7 @@ from .const import (
|
||||
CONNECTION_STATUS,
|
||||
DOMAIN,
|
||||
FALLBACK_SOURCES,
|
||||
MANUFACTURER,
|
||||
VALID_MEDIA_TYPES,
|
||||
BangOlufsenMediaType,
|
||||
BangOlufsenSource,
|
||||
@@ -201,7 +202,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url=f"http://{self._host}/#/",
|
||||
identifiers={(DOMAIN, self._unique_id)},
|
||||
manufacturer="Bang & Olufsen",
|
||||
manufacturer=MANUFACTURER,
|
||||
model=self._model,
|
||||
serial_number=self._unique_id,
|
||||
)
|
||||
@@ -249,7 +250,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{signal}",
|
||||
f"{DOMAIN}_{self._unique_id}_{signal}",
|
||||
signal_handler,
|
||||
)
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from mozart_api.models import PairedRemote
|
||||
from mozart_api.mozart_client import MozartClient
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DEVICE_BUTTONS, DOMAIN, BangOlufsenButtons, BangOlufsenModel
|
||||
|
||||
|
||||
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
|
||||
@@ -21,3 +26,30 @@ def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
|
||||
def get_serial_number_from_jid(jid: str) -> str:
|
||||
"""Get serial number from Beolink JID."""
|
||||
return jid.split(".")[2].split("@")[0]
|
||||
|
||||
|
||||
async def get_remotes(client: MozartClient) -> list[PairedRemote]:
|
||||
"""Get paired remotes."""
|
||||
|
||||
bluetooth_remote_list = await client.get_bluetooth_remotes()
|
||||
|
||||
return [
|
||||
remote
|
||||
for remote in cast(list[PairedRemote], bluetooth_remote_list.items)
|
||||
if remote.serial_number is not None
|
||||
]
|
||||
|
||||
|
||||
def get_device_buttons(model: BangOlufsenModel) -> 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)
|
||||
|
||||
# Beoconnect Core does not have any buttons
|
||||
elif model == BangOlufsenModel.BEOCONNECT_CORE:
|
||||
buttons = []
|
||||
|
||||
return buttons
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mozart_api.models import (
|
||||
BeoRemoteButton,
|
||||
ButtonEvent,
|
||||
ListeningModeProps,
|
||||
PlaybackContentMetadata,
|
||||
@@ -28,11 +29,13 @@ from homeassistant.util.enum import try_parse_enum
|
||||
from .const import (
|
||||
BANG_OLUFSEN_WEBSOCKET_EVENT,
|
||||
CONNECTION_STATUS,
|
||||
DOMAIN,
|
||||
EVENT_TRANSLATION_MAP,
|
||||
BangOlufsenModel,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenBase
|
||||
from .util import get_device
|
||||
from .util import get_device, get_remotes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +60,9 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
self._client.get_active_listening_mode_notifications(
|
||||
self.on_active_listening_mode
|
||||
)
|
||||
self._client.get_beo_remote_button_notifications(
|
||||
self.on_beo_remote_button_notification
|
||||
)
|
||||
self._client.get_button_notifications(self.on_button_notification)
|
||||
|
||||
self._client.get_playback_error_notifications(
|
||||
@@ -87,7 +93,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
"""Update all entities of the connection status."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{CONNECTION_STATUS}",
|
||||
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
|
||||
self._client.websocket_connected,
|
||||
)
|
||||
|
||||
@@ -105,10 +111,22 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
"""Send active_listening_mode dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.ACTIVE_LISTENING_MODE}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.ACTIVE_LISTENING_MODE}",
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None:
|
||||
"""Send beo_remote_button dispatch."""
|
||||
if TYPE_CHECKING:
|
||||
assert notification.type
|
||||
|
||||
# Send to event entity
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BEO_REMOTE_BUTTON}_{notification.key}",
|
||||
EVENT_TRANSLATION_MAP[notification.type],
|
||||
)
|
||||
|
||||
def on_button_notification(self, notification: ButtonEvent) -> None:
|
||||
"""Send button dispatch."""
|
||||
# State is expected to always be available.
|
||||
@@ -118,11 +136,11 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
# Send to event entity
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.BUTTON}_{notification.button}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BUTTON}_{notification.button}",
|
||||
EVENT_TRANSLATION_MAP[notification.state],
|
||||
)
|
||||
|
||||
def on_notification_notification(
|
||||
async def on_notification_notification(
|
||||
self, notification: WebsocketNotificationTag
|
||||
) -> None:
|
||||
"""Send notification dispatch."""
|
||||
@@ -136,24 +154,51 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
):
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.BEOLINK}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BEOLINK}",
|
||||
)
|
||||
elif notification_type is WebsocketNotification.CONFIGURATION:
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.CONFIGURATION}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.CONFIGURATION}",
|
||||
)
|
||||
elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED:
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.REMOTE_MENU_CHANGED}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.REMOTE_MENU_CHANGED}",
|
||||
)
|
||||
|
||||
# This notification is triggered by a remote pairing, unpairing and connecting to a device
|
||||
# So the current remote devices have to be compared to available remotes to determine action
|
||||
elif notification_type is WebsocketNotification.REMOTE_CONTROL_DEVICES:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
# Get remote devices connected to the device from Home Assistant
|
||||
device_serial_numbers = [
|
||||
device.serial_number
|
||||
for device in device_registry.devices.get_devices_for_config_entry_id(
|
||||
self.entry.entry_id
|
||||
)
|
||||
if device.serial_number is not None
|
||||
and device.model == BangOlufsenModel.BEOREMOTE_ONE
|
||||
]
|
||||
# Get paired remotes from device
|
||||
remote_serial_numbers = [
|
||||
remote.serial_number
|
||||
for remote in await get_remotes(self._client)
|
||||
if remote.serial_number is not None
|
||||
]
|
||||
# Check if number of remote devices correspond to number of paired remotes
|
||||
if len(remote_serial_numbers) != len(device_serial_numbers):
|
||||
_LOGGER.info(
|
||||
"A Beoremote One has been paired or unpaired to %s. Reloading config entry to add device and entities",
|
||||
self.entry.title,
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(self.entry.entry_id)
|
||||
|
||||
def on_playback_error_notification(self, notification: PlaybackError) -> None:
|
||||
"""Send playback_error dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_ERROR}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_ERROR}",
|
||||
notification,
|
||||
)
|
||||
|
||||
@@ -163,7 +208,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
"""Send playback_metadata dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_METADATA}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_METADATA}",
|
||||
notification,
|
||||
)
|
||||
|
||||
@@ -171,7 +216,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
"""Send playback_progress dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_PROGRESS}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_PROGRESS}",
|
||||
notification,
|
||||
)
|
||||
|
||||
@@ -179,7 +224,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
"""Send playback_state dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_STATE}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_STATE}",
|
||||
notification,
|
||||
)
|
||||
|
||||
@@ -187,7 +232,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
"""Send playback_source dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}",
|
||||
notification,
|
||||
)
|
||||
|
||||
@@ -195,7 +240,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
"""Send source_change dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.SOURCE_CHANGE}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.SOURCE_CHANGE}",
|
||||
notification,
|
||||
)
|
||||
|
||||
@@ -203,7 +248,7 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
"""Send volume dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.VOLUME}",
|
||||
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.VOLUME}",
|
||||
notification,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.0.0",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.7.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, cast
|
||||
from aiohttp import ClientSession
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
@@ -18,13 +19,17 @@ from homeassistant.core import (
|
||||
ServiceCall,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_CONFIG_ENTRY
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_TXT = "txt"
|
||||
@@ -32,7 +37,13 @@ ATTR_TXT = "txt"
|
||||
DOMAIN = "duckdns"
|
||||
|
||||
INTERVAL = timedelta(minutes=5)
|
||||
|
||||
BACKOFF_INTERVALS = (
|
||||
INTERVAL,
|
||||
timedelta(minutes=1),
|
||||
timedelta(minutes=5),
|
||||
timedelta(minutes=15),
|
||||
timedelta(minutes=30),
|
||||
)
|
||||
SERVICE_SET_TXT = "set_txt"
|
||||
|
||||
UPDATE_URL = "https://www.duckdns.org/update"
|
||||
@@ -49,39 +60,112 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SERVICE_TXT_SCHEMA = vol.Schema({vol.Required(ATTR_TXT): vol.Any(None, cv.string)})
|
||||
SERVICE_TXT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector(
|
||||
{
|
||||
"integration": DOMAIN,
|
||||
}
|
||||
),
|
||||
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
|
||||
}
|
||||
)
|
||||
|
||||
type DuckDnsConfigEntry = ConfigEntry
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Initialize the DuckDNS component."""
|
||||
domain: str = config[DOMAIN][CONF_DOMAIN]
|
||||
token: str = config[DOMAIN][CONF_ACCESS_TOKEN]
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_TXT,
|
||||
update_domain_service,
|
||||
schema=SERVICE_TXT_SCHEMA,
|
||||
)
|
||||
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
|
||||
"""Set up Duck DNS from a config entry."""
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
async def update_domain_interval(_now: datetime) -> bool:
|
||||
"""Update the DuckDNS entry."""
|
||||
return await _update_duckdns(session, domain, token)
|
||||
return await _update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
)
|
||||
|
||||
intervals = (
|
||||
INTERVAL,
|
||||
timedelta(minutes=1),
|
||||
timedelta(minutes=5),
|
||||
timedelta(minutes=15),
|
||||
timedelta(minutes=30),
|
||||
)
|
||||
async_track_time_interval_backoff(hass, update_domain_interval, intervals)
|
||||
|
||||
async def update_domain_service(call: ServiceCall) -> None:
|
||||
"""Update the DuckDNS entry."""
|
||||
await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT])
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval_backoff(
|
||||
hass, update_domain_interval, BACKOFF_INTERVALS
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_config_entry(
|
||||
hass: HomeAssistant, entry_id: str | None = None
|
||||
) -> DuckDnsConfigEntry:
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
|
||||
if entry_id is None:
|
||||
if not (config_entries := hass.config_entries.async_entries(DOMAIN)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
if len(config_entries) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_selected",
|
||||
)
|
||||
return config_entries[0]
|
||||
|
||||
if not (entry := hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def update_domain_service(call: ServiceCall) -> None:
|
||||
"""Update the DuckDNS entry."""
|
||||
|
||||
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
|
||||
|
||||
session = async_get_clientsession(call.hass)
|
||||
|
||||
await _update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
txt=call.data.get(ATTR_TXT),
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
|
||||
81
homeassistant/components/duckdns/config_flow.py
Normal file
81
homeassistant/components/duckdns/config_flow.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Config flow for the Duck DNS integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from . import _update_duckdns
|
||||
from .const import DOMAIN
|
||||
from .issue import deprecate_yaml_issue
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DOMAIN): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.TEXT, suffix=".duckdns.org")
|
||||
),
|
||||
vol.Required(CONF_ACCESS_TOKEN): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Duck DNS."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_DOMAIN: user_input[CONF_DOMAIN]})
|
||||
session = async_get_clientsession(self.hass)
|
||||
try:
|
||||
if not await _update_duckdns(
|
||||
session,
|
||||
user_input[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_create_entry(
|
||||
title=f"{user_input[CONF_DOMAIN]}.duckdns.org", data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"url": "https://www.duckdns.org/"},
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import config from yaml."""
|
||||
|
||||
self._async_abort_entries_match({CONF_DOMAIN: import_info[CONF_DOMAIN]})
|
||||
result = await self.async_step_user(import_info)
|
||||
if errors := result.get("errors"):
|
||||
deprecate_yaml_issue(self.hass, import_success=False)
|
||||
return self.async_abort(reason=errors["base"])
|
||||
|
||||
deprecate_yaml_issue(self.hass, import_success=True)
|
||||
return result
|
||||
7
homeassistant/components/duckdns/const.py
Normal file
7
homeassistant/components/duckdns/const.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Constants for the Duck DNS integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN = "duckdns"
|
||||
|
||||
ATTR_CONFIG_ENTRY: Final = "config_entry_id"
|
||||
40
homeassistant/components/duckdns/issue.py
Normal file
40
homeassistant/components/duckdns/issue.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Issues for Duck DNS integration."""
|
||||
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
|
||||
"""Deprecate yaml issue."""
|
||||
if import_success:
|
||||
async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Duck DNS",
|
||||
},
|
||||
)
|
||||
else:
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml_import_issue_error",
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml_import_issue_error",
|
||||
translation_placeholders={
|
||||
"url": "/config/integrations/dashboard/add?domain=duckdns"
|
||||
},
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"domain": "duckdns",
|
||||
"name": "Duck DNS",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@tr4nt0r"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/duckdns",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "legacy"
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
set_txt:
|
||||
fields:
|
||||
config_entry_id:
|
||||
selector:
|
||||
config_entry:
|
||||
integration: duckdns
|
||||
txt:
|
||||
required: true
|
||||
example: "This domain name is reserved for use in documentation"
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -1,8 +1,48 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"update_failed": "Updating Duck DNS failed"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"access_token": "Token",
|
||||
"domain": "Subdomain"
|
||||
},
|
||||
"data_description": {
|
||||
"access_token": "Your Duck DNS account token",
|
||||
"domain": "The Duck DNS subdomain to update"
|
||||
},
|
||||
"description": "Enter your Duck DNS subdomain and token below to configure dynamic DNS updates. You can find your token on the [Duck DNS]({url}) homepage after logging into your account."
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"entry_not_found": {
|
||||
"message": "Duck DNS integration entry not found"
|
||||
},
|
||||
"entry_not_selected": {
|
||||
"message": "Duck DNS integration entry not selected"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_error": {
|
||||
"description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
|
||||
"title": "The Duck DNS YAML configuration import failed"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_txt": {
|
||||
"description": "Sets the TXT record of your DuckDNS subdomain.",
|
||||
"description": "Sets the TXT record of your Duck DNS subdomain.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"description": "The Duck DNS integration ID.",
|
||||
"name": "Integration ID"
|
||||
},
|
||||
"txt": {
|
||||
"description": "Payload for the TXT record.",
|
||||
"name": "TXT"
|
||||
|
||||
@@ -20,7 +20,7 @@ from .coordinator import (
|
||||
GoogleWeatherSubEntryRuntimeData,
|
||||
)
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.WEATHER]
|
||||
_PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -16,10 +16,15 @@ class GoogleWeatherBaseEntity(Entity):
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
|
||||
self,
|
||||
config_entry: GoogleWeatherConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
unique_id_suffix: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize base entity."""
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
if unique_id_suffix is not None:
|
||||
self._attr_unique_id += f"_{unique_id_suffix.lower()}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
|
||||
27
homeassistant/components/google_weather/icons.json
Normal file
27
homeassistant/components/google_weather/icons.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"cloud_coverage": {
|
||||
"default": "mdi:weather-cloudy"
|
||||
},
|
||||
"precipitation_probability": {
|
||||
"default": "mdi:weather-rainy"
|
||||
},
|
||||
"precipitation_qpf": {
|
||||
"default": "mdi:cup-water"
|
||||
},
|
||||
"thunderstorm_probability": {
|
||||
"default": "mdi:weather-lightning"
|
||||
},
|
||||
"uv_index": {
|
||||
"default": "mdi:weather-sunny-alert"
|
||||
},
|
||||
"visibility": {
|
||||
"default": "mdi:eye"
|
||||
},
|
||||
"weather_condition": {
|
||||
"default": "mdi:card-text-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
233
homeassistant/components/google_weather/sensor.py
Normal file
233
homeassistant/components/google_weather/sensor.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Support for Google Weather sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from google_weather_api import CurrentConditionsResponse
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
UV_INDEX,
|
||||
UnitOfLength,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolumetricFlux,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import (
|
||||
GoogleWeatherConfigEntry,
|
||||
GoogleWeatherCurrentConditionsCoordinator,
|
||||
)
|
||||
from .entity import GoogleWeatherBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GoogleWeatherSensorDescription(SensorEntityDescription):
|
||||
"""Class describing Google Weather sensor entities."""
|
||||
|
||||
value_fn: Callable[[CurrentConditionsResponse], str | int | float | None]
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[GoogleWeatherSensorDescription, ...] = (
|
||||
GoogleWeatherSensorDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: data.temperature.degrees,
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="feelsLikeTemperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: data.feels_like_temperature.degrees,
|
||||
translation_key="apparent_temperature",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="dewPoint",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: data.dew_point.degrees,
|
||||
translation_key="dew_point",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="heatIndex",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: data.heat_index.degrees,
|
||||
translation_key="heat_index",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="windChill",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data: data.wind_chill.degrees,
|
||||
translation_key="wind_chill",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="relativeHumidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data: data.relative_humidity,
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="uvIndex",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UV_INDEX,
|
||||
value_fn=lambda data: data.uv_index,
|
||||
translation_key="uv_index",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="precipitation_probability",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data: data.precipitation.probability.percent,
|
||||
translation_key="precipitation_probability",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="precipitation_qpf",
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
value_fn=lambda data: data.precipitation.qpf.quantity,
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="thunderstormProbability",
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data: data.thunderstorm_probability,
|
||||
translation_key="thunderstorm_probability",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="airPressure",
|
||||
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement=UnitOfPressure.HPA,
|
||||
value_fn=lambda data: data.air_pressure.mean_sea_level_millibars,
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="wind_direction",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
native_unit_of_measurement=DEGREE,
|
||||
value_fn=lambda data: data.wind.direction.degrees,
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="wind_speed",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
value_fn=lambda data: data.wind.speed.value,
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="wind_gust",
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||
value_fn=lambda data: data.wind.gust.value,
|
||||
translation_key="wind_gust_speed",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="visibility",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
value_fn=lambda data: data.visibility.distance,
|
||||
translation_key="visibility",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="cloudCover",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda data: data.cloud_cover,
|
||||
translation_key="cloud_coverage",
|
||||
),
|
||||
GoogleWeatherSensorDescription(
|
||||
key="weatherCondition",
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.weather_condition.description.text,
|
||||
translation_key="weather_condition",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GoogleWeatherConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add Google Weather entities from a config_entry."""
|
||||
for subentry in entry.subentries.values():
|
||||
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
|
||||
subentry.subentry_id
|
||||
]
|
||||
coordinator = subentry_runtime_data.coordinator_observation
|
||||
async_add_entities(
|
||||
(
|
||||
GoogleWeatherSensor(coordinator, subentry, description)
|
||||
for description in SENSOR_TYPES
|
||||
if description.value_fn(coordinator.data) is not None
|
||||
),
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWeatherSensor(
|
||||
CoordinatorEntity[GoogleWeatherCurrentConditionsCoordinator],
|
||||
GoogleWeatherBaseEntity,
|
||||
SensorEntity,
|
||||
):
|
||||
"""Define a Google Weather entity."""
|
||||
|
||||
entity_description: GoogleWeatherSensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GoogleWeatherCurrentConditionsCoordinator,
|
||||
subentry: ConfigSubentry,
|
||||
description: GoogleWeatherSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
GoogleWeatherBaseEntity.__init__(
|
||||
self, coordinator.config_entry, subentry, description.key
|
||||
)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | float | None:
|
||||
"""Return the state."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -61,5 +61,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"apparent_temperature": {
|
||||
"name": "Apparent temperature"
|
||||
},
|
||||
"cloud_coverage": {
|
||||
"name": "Cloud coverage"
|
||||
},
|
||||
"dew_point": {
|
||||
"name": "Dew point"
|
||||
},
|
||||
"heat_index": {
|
||||
"name": "Heat index temperature"
|
||||
},
|
||||
"precipitation_probability": {
|
||||
"name": "Precipitation probability"
|
||||
},
|
||||
"thunderstorm_probability": {
|
||||
"name": "Thunderstorm probability"
|
||||
},
|
||||
"uv_index": {
|
||||
"name": "UV index"
|
||||
},
|
||||
"visibility": {
|
||||
"name": "Visibility"
|
||||
},
|
||||
"weather_condition": {
|
||||
"name": "Weather condition"
|
||||
},
|
||||
"wind_chill": {
|
||||
"name": "Wind chill temperature"
|
||||
},
|
||||
"wind_gust_speed": {
|
||||
"name": "Wind gust speed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,8 @@ ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
|
||||
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
|
||||
|
||||
ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed"
|
||||
|
||||
CORE_CONTAINER = "homeassistant"
|
||||
SUPERVISOR_CONTAINER = "hassio_supervisor"
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from homeassistant.helpers.issue_registry import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_DATA,
|
||||
ATTR_HEALTHY,
|
||||
ATTR_STARTUP,
|
||||
@@ -49,6 +50,7 @@ from .const import (
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||
ISSUE_MOUNT_MOUNT_FAILED,
|
||||
PLACEHOLDER_KEY_ADDON,
|
||||
PLACEHOLDER_KEY_ADDON_URL,
|
||||
PLACEHOLDER_KEY_FREE_SPACE,
|
||||
@@ -57,7 +59,7 @@ from .const import (
|
||||
STARTUP_COMPLETE,
|
||||
UPDATE_KEY_SUPERVISOR,
|
||||
)
|
||||
from .coordinator import get_addons_info, get_host_info
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_info, get_host_info
|
||||
from .handler import HassIO, get_supervisor_client
|
||||
|
||||
ISSUE_KEY_UNHEALTHY = "unhealthy"
|
||||
@@ -77,7 +79,7 @@ UNSUPPORTED_SKIP_REPAIR = {"privileged"}
|
||||
# Keys (type + context) of issues that when found should be made into a repair
|
||||
ISSUE_KEYS_FOR_REPAIRS = {
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
"issue_mount_mount_failed",
|
||||
ISSUE_MOUNT_MOUNT_FAILED,
|
||||
"issue_system_multiple_data_disks",
|
||||
"issue_system_reboot_required",
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
@@ -284,6 +286,9 @@ class SupervisorIssues:
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
|
||||
|
||||
if issue.key == ISSUE_MOUNT_MOUNT_FAILED:
|
||||
self._async_coordinator_refresh()
|
||||
|
||||
async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
@@ -336,6 +341,9 @@ class SupervisorIssues:
|
||||
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
|
||||
async_delete_issue(self._hass, DOMAIN, issue.uuid.hex)
|
||||
|
||||
if issue.key == ISSUE_MOUNT_MOUNT_FAILED:
|
||||
self._async_coordinator_refresh()
|
||||
|
||||
del self._issues[issue.uuid]
|
||||
|
||||
def get_issue(self, issue_id: str) -> Issue | None:
|
||||
@@ -406,3 +414,11 @@ class SupervisorIssues:
|
||||
|
||||
elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED:
|
||||
self.remove_issue(Issue.from_dict(event[ATTR_DATA]))
|
||||
|
||||
def _async_coordinator_refresh(self) -> None:
|
||||
"""Refresh coordinator to update latest data in entities."""
|
||||
coordinator: HassioDataUpdateCoordinator | None
|
||||
if coordinator := self._hass.data.get(ADDONS_COORDINATOR):
|
||||
coordinator.config_entry.async_create_task(
|
||||
self._hass, coordinator.async_refresh()
|
||||
)
|
||||
|
||||
@@ -13,11 +13,13 @@ DOMAIN = "home_connect"
|
||||
API_DEFAULT_RETRY_AFTER = 60
|
||||
|
||||
APPLIANCES_WITH_PROGRAMS = (
|
||||
"AirConditioner",
|
||||
"CleaningRobot",
|
||||
"CoffeeMaker",
|
||||
"Dishwasher",
|
||||
"Dryer",
|
||||
"Hood",
|
||||
"Microwave",
|
||||
"Oven",
|
||||
"WarmingDrawer",
|
||||
"Washer",
|
||||
@@ -83,6 +85,14 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
|
||||
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
|
||||
}
|
||||
|
||||
FAN_SPEED_MODE_OPTIONS = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in (
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
)
|
||||
}
|
||||
|
||||
AVAILABLE_MAPS_ENUM = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in (
|
||||
@@ -315,6 +325,10 @@ PROGRAM_ENUM_OPTIONS = {
|
||||
options,
|
||||
)
|
||||
for option_key, options in (
|
||||
(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_SPEED_MODE_OPTIONS,
|
||||
),
|
||||
(
|
||||
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
|
||||
AVAILABLE_MAPS_ENUM,
|
||||
|
||||
@@ -82,6 +82,12 @@ set_program_and_options:
|
||||
- dishcare_dishwasher_program_maximum_cleaning
|
||||
- dishcare_dishwasher_program_mixed_load
|
||||
- dishcare_dishwasher_program_learning_dishwasher
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_active_clean
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_auto
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_cool
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_dry
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_fan
|
||||
- heating_ventilation_air_conditioning_air_conditioner_program_heat
|
||||
- laundry_care_dryer_program_cotton
|
||||
- laundry_care_dryer_program_synthetic
|
||||
- laundry_care_dryer_program_mix
|
||||
@@ -136,6 +142,7 @@ set_program_and_options:
|
||||
- cooking_oven_program_microwave_90_watt
|
||||
- cooking_oven_program_microwave_180_watt
|
||||
- cooking_oven_program_microwave_360_watt
|
||||
- cooking_oven_program_microwave_450_watt
|
||||
- cooking_oven_program_microwave_600_watt
|
||||
- cooking_oven_program_microwave_900_watt
|
||||
- cooking_oven_program_microwave_1000_watt
|
||||
@@ -177,6 +184,28 @@ set_program_and_options:
|
||||
- laundry_care_washer_dryer_program_easy_care
|
||||
- laundry_care_washer_dryer_program_wash_and_dry_60
|
||||
- laundry_care_washer_dryer_program_wash_and_dry_90
|
||||
air_conditioner_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_percentage:
|
||||
example: 50
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 100
|
||||
step: 1
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
mode: dropdown
|
||||
translation_key: fan_speed_mode
|
||||
options:
|
||||
- heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic
|
||||
- heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual
|
||||
cleaning_robot_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
"cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
|
||||
"cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
|
||||
"cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
|
||||
"cooking_oven_program_microwave_450_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_450_watt%]",
|
||||
"cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
|
||||
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
|
||||
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
|
||||
@@ -281,6 +282,12 @@
|
||||
"dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
|
||||
"dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
|
||||
"dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_active_clean": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_active_clean%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_auto": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_auto%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_cool": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_cool%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_dry": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_dry%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_fan": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_fan%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_heat": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_heat%]",
|
||||
"laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
|
||||
"laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
|
||||
"laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
|
||||
@@ -443,6 +450,13 @@
|
||||
"laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]"
|
||||
}
|
||||
},
|
||||
"fan_speed_mode": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode::name%]",
|
||||
"state": {
|
||||
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic": "[%key:component::home_connect::selector::fan_speed_mode::options::heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual": "[%key:component::home_connect::selector::fan_speed_mode::options::heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual%]"
|
||||
}
|
||||
},
|
||||
"flow_rate": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]",
|
||||
"state": {
|
||||
@@ -575,6 +589,7 @@
|
||||
"cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
|
||||
"cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
|
||||
"cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
|
||||
"cooking_oven_program_microwave_450_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_450_watt%]",
|
||||
"cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
|
||||
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
|
||||
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
|
||||
@@ -604,6 +619,12 @@
|
||||
"dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
|
||||
"dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
|
||||
"dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_active_clean": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_active_clean%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_auto": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_auto%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_cool": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_cool%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_dry": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_dry%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_fan": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_fan%]",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_heat": "[%key:component::home_connect::selector::programs::options::heating_ventilation_air_conditioning_air_conditioner_program_heat%]",
|
||||
"laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
|
||||
"laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
|
||||
"laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
|
||||
@@ -1418,6 +1439,12 @@
|
||||
"laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry"
|
||||
}
|
||||
},
|
||||
"fan_speed_mode": {
|
||||
"options": {
|
||||
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic": "Auto",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_manual": "Manual"
|
||||
}
|
||||
},
|
||||
"flow_rate": {
|
||||
"options": {
|
||||
"consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense",
|
||||
@@ -1526,6 +1553,7 @@
|
||||
"cooking_oven_program_microwave_1000_watt": "1000 Watt",
|
||||
"cooking_oven_program_microwave_180_watt": "180 Watt",
|
||||
"cooking_oven_program_microwave_360_watt": "360 Watt",
|
||||
"cooking_oven_program_microwave_450_watt": "450 Watt",
|
||||
"cooking_oven_program_microwave_600_watt": "600 Watt",
|
||||
"cooking_oven_program_microwave_900_watt": "900 Watt",
|
||||
"cooking_oven_program_microwave_90_watt": "90 Watt",
|
||||
@@ -1555,6 +1583,12 @@
|
||||
"dishcare_dishwasher_program_quick_65": "Quick 65ºC",
|
||||
"dishcare_dishwasher_program_steam_fresh": "Steam fresh",
|
||||
"dishcare_dishwasher_program_super_60": "Super 60ºC",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_active_clean": "Active clean",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_auto": "Auto",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_cool": "Cool",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_dry": "Dry",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_fan": "Fan",
|
||||
"heating_ventilation_air_conditioning_air_conditioner_program_heat": "Heat",
|
||||
"laundry_care_dryer_program_anti_shrink": "Anti shrink",
|
||||
"laundry_care_dryer_program_blankets": "Blankets",
|
||||
"laundry_care_dryer_program_business_shirts": "Business shirts",
|
||||
@@ -1823,6 +1857,14 @@
|
||||
"description": "Defines if the program sequence is optimized with special drying cycle ensures improved drying for glasses, plates and plasticware.",
|
||||
"name": "Zeolite dry"
|
||||
},
|
||||
"heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode": {
|
||||
"description": "Setting to adjust the fan speed mode to Manual or Auto.",
|
||||
"name": "Fan speed mode"
|
||||
},
|
||||
"heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_percentage": {
|
||||
"description": "Setting to adjust the venting level of the air conditioner as a percentage.",
|
||||
"name": "Fan speed percentage"
|
||||
},
|
||||
"laundry_care_dryer_option_drying_target": {
|
||||
"description": "Describes the drying target for a dryer program.",
|
||||
"name": "Drying target"
|
||||
@@ -1854,6 +1896,10 @@
|
||||
},
|
||||
"name": "Set program and options",
|
||||
"sections": {
|
||||
"air_conditioner_options": {
|
||||
"description": "Specific settings for air conditioners.",
|
||||
"name": "Air conditioner options"
|
||||
},
|
||||
"cleaning_robot_options": {
|
||||
"description": "Options for cleaning robots.",
|
||||
"name": "Cleaning robot options"
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
},
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]"
|
||||
},
|
||||
"show_z2m_docs_url": {
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
@@ -204,6 +208,10 @@
|
||||
"reconfigure_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
|
||||
},
|
||||
"show_z2m_docs_url": {
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
|
||||
},
|
||||
"start_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
|
||||
},
|
||||
|
||||
@@ -35,3 +35,5 @@ ZIGBEE_FLASHER_ADDON_SLUG = "core_silabs_flasher"
|
||||
|
||||
SILABS_MULTIPROTOCOL_ADDON_SLUG = "core_silabs_multiprotocol"
|
||||
SILABS_FLASHER_ADDON_SLUG = "core_silabs_flasher"
|
||||
|
||||
Z2M_EMBER_DOCS_URL = "https://www.zigbee2mqtt.io/guide/adapters/emberznet.html"
|
||||
|
||||
@@ -33,7 +33,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .const import OTBR_DOMAIN, ZHA_DOMAIN
|
||||
from .const import OTBR_DOMAIN, Z2M_EMBER_DOCS_URL, ZHA_DOMAIN
|
||||
from .util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
@@ -456,7 +456,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
assert self._hardware_name is not None
|
||||
|
||||
if self._zigbee_integration == ZigbeeIntegration.OTHER:
|
||||
return self._async_flow_finished()
|
||||
return await self.async_step_show_z2m_docs_url()
|
||||
|
||||
result = await self.hass.config_entries.flow.async_init(
|
||||
ZHA_DOMAIN,
|
||||
@@ -475,6 +475,21 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
)
|
||||
return self._continue_zha_flow(result)
|
||||
|
||||
async def async_step_show_z2m_docs_url(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show Zigbee2MQTT documentation link."""
|
||||
if user_input is not None:
|
||||
return self._async_flow_finished()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="show_z2m_docs_url",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"z2m_docs_url": Z2M_EMBER_DOCS_URL,
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
|
||||
"""Continue the ZHA flow."""
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
},
|
||||
"title": "Pick your protocol"
|
||||
},
|
||||
"show_z2m_docs_url": {
|
||||
"description": "Your {model} is now running the latest Zigbee firmware.\nPlease read the Zigbee2MQTT documentation for EmberZNet adapters and copy the config for your {model}: {z2m_docs_url}",
|
||||
"title": "Set up Zigbee2MQTT"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "Configuring Thread"
|
||||
},
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
},
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]"
|
||||
},
|
||||
"show_z2m_docs_url": {
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
|
||||
},
|
||||
"start_otbr_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::start_otbr_addon::title%]"
|
||||
},
|
||||
@@ -204,6 +208,10 @@
|
||||
"reconfigure_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
|
||||
},
|
||||
"show_z2m_docs_url": {
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
|
||||
},
|
||||
"start_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
|
||||
},
|
||||
|
||||
@@ -138,6 +138,10 @@
|
||||
"reconfigure_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
|
||||
},
|
||||
"show_z2m_docs_url": {
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::description%]",
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::show_z2m_docs_url::title%]"
|
||||
},
|
||||
"start_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
|
||||
from homematicip.base.functionalChannels import MultiModeInputChannel
|
||||
from homematicip.device import (
|
||||
AccelerationSensor,
|
||||
ContactInterface,
|
||||
@@ -87,8 +88,11 @@ async def async_setup_entry(
|
||||
entities.append(HomematicipTiltVibrationSensor(hap, device))
|
||||
if isinstance(device, WiredInput32):
|
||||
entities.extend(
|
||||
HomematicipMultiContactInterface(hap, device, channel=channel)
|
||||
for channel in range(1, 33)
|
||||
HomematicipMultiContactInterface(
|
||||
hap, device, channel_real_index=channel.index
|
||||
)
|
||||
for channel in device.functionalChannels
|
||||
if isinstance(channel, MultiModeInputChannel)
|
||||
)
|
||||
elif isinstance(device, FullFlushContactInterface6):
|
||||
entities.extend(
|
||||
@@ -227,21 +231,24 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt
|
||||
device,
|
||||
channel=1,
|
||||
is_multi_channel=True,
|
||||
channel_real_index=None,
|
||||
) -> None:
|
||||
"""Initialize the multi contact entity."""
|
||||
super().__init__(
|
||||
hap, device, channel=channel, is_multi_channel=is_multi_channel
|
||||
hap,
|
||||
device,
|
||||
channel=channel,
|
||||
is_multi_channel=is_multi_channel,
|
||||
channel_real_index=channel_real_index,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the contact interface is on/open."""
|
||||
if self._device.functionalChannels[self._channel].windowState is None:
|
||||
channel = self.get_channel_or_raise()
|
||||
if channel.windowState is None:
|
||||
return None
|
||||
return (
|
||||
self._device.functionalChannels[self._channel].windowState
|
||||
!= WindowState.CLOSED
|
||||
)
|
||||
return channel.windowState != WindowState.CLOSED
|
||||
|
||||
|
||||
class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensorEntity):
|
||||
|
||||
@@ -283,19 +283,23 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
return self.functional_channel.doorState == DoorState.CLOSED
|
||||
channel = self.get_channel_or_raise()
|
||||
return channel.doorState == DoorState.CLOSED
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
await self.functional_channel.async_send_door_command(DoorCommand.OPEN)
|
||||
channel = self.get_channel_or_raise()
|
||||
await channel.async_send_door_command(DoorCommand.OPEN)
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
await self.functional_channel.async_send_door_command(DoorCommand.CLOSE)
|
||||
channel = self.get_channel_or_raise()
|
||||
await channel.async_send_door_command(DoorCommand.CLOSE)
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
await self.functional_channel.async_send_door_command(DoorCommand.STOP)
|
||||
channel = self.get_channel_or_raise()
|
||||
await channel.async_send_door_command(DoorCommand.STOP)
|
||||
|
||||
|
||||
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -84,6 +85,7 @@ class HomematicipGenericEntity(Entity):
|
||||
post: str | None = None,
|
||||
channel: int | None = None,
|
||||
is_multi_channel: bool | None = False,
|
||||
channel_real_index: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the generic entity."""
|
||||
self._hap = hap
|
||||
@@ -91,8 +93,19 @@ class HomematicipGenericEntity(Entity):
|
||||
self._device = device
|
||||
self._post = post
|
||||
self._channel = channel
|
||||
|
||||
# channel_real_index represents the actual index of the devices channel.
|
||||
# Accessing a functionalChannel by the channel parameter or array index is unreliable,
|
||||
# because the functionalChannels array is sorted as strings, not numbers.
|
||||
# For example, channels are ordered as: 1, 10, 11, 12, 2, 3, ...
|
||||
# Using channel_real_index ensures you reference the correct channel.
|
||||
self._channel_real_index: int | None = channel_real_index
|
||||
|
||||
self._is_multi_channel = is_multi_channel
|
||||
self.functional_channel = self.get_current_channel()
|
||||
self.functional_channel = None
|
||||
with contextlib.suppress(ValueError):
|
||||
self.functional_channel = self.get_current_channel()
|
||||
|
||||
# Marker showing that the HmIP device hase been removed.
|
||||
self.hmip_device_removed = False
|
||||
|
||||
@@ -101,17 +114,20 @@ class HomematicipGenericEntity(Entity):
|
||||
"""Return device specific attributes."""
|
||||
# Only physical devices should be HA devices.
|
||||
if isinstance(self._device, Device):
|
||||
device_id = str(self._device.id)
|
||||
home_id = str(self._device.homeId)
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers={
|
||||
# Serial numbers of Homematic IP device
|
||||
(DOMAIN, self._device.id)
|
||||
(DOMAIN, device_id)
|
||||
},
|
||||
manufacturer=self._device.oem,
|
||||
model=self._device.modelType,
|
||||
name=self._device.label,
|
||||
sw_version=self._device.firmwareVersion,
|
||||
# Link to the homematic ip access point.
|
||||
via_device=(DOMAIN, self._device.homeId),
|
||||
via_device=(DOMAIN, home_id),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -185,25 +201,31 @@ class HomematicipGenericEntity(Entity):
|
||||
def name(self) -> str:
|
||||
"""Return the name of the generic entity."""
|
||||
|
||||
name = None
|
||||
name = ""
|
||||
# Try to get a label from a channel.
|
||||
if hasattr(self._device, "functionalChannels"):
|
||||
functional_channels = getattr(self._device, "functionalChannels", None)
|
||||
if functional_channels and self.functional_channel:
|
||||
if self._is_multi_channel:
|
||||
name = self._device.functionalChannels[self._channel].label
|
||||
elif len(self._device.functionalChannels) > 1:
|
||||
name = self._device.functionalChannels[1].label
|
||||
label = getattr(self.functional_channel, "label", None)
|
||||
if label:
|
||||
name = str(label)
|
||||
elif len(functional_channels) > 1:
|
||||
label = getattr(functional_channels[1], "label", None)
|
||||
if label:
|
||||
name = str(label)
|
||||
|
||||
# Use device label, if name is not defined by channel label.
|
||||
if not name:
|
||||
name = self._device.label
|
||||
name = self._device.label or ""
|
||||
if self._post:
|
||||
name = f"{name} {self._post}"
|
||||
elif self._is_multi_channel:
|
||||
name = f"{name} Channel{self._channel}"
|
||||
name = f"{name} Channel{self.get_channel_index()}"
|
||||
|
||||
# Add a prefix to the name if the homematic ip home has a name.
|
||||
if name and self._home.name:
|
||||
name = f"{self._home.name} {name}"
|
||||
home_name = getattr(self._home, "name", None)
|
||||
if name and home_name:
|
||||
name = f"{home_name} {name}"
|
||||
|
||||
return name
|
||||
|
||||
@@ -217,9 +239,7 @@ class HomematicipGenericEntity(Entity):
|
||||
"""Return a unique ID."""
|
||||
unique_id = f"{self.__class__.__name__}_{self._device.id}"
|
||||
if self._is_multi_channel:
|
||||
unique_id = (
|
||||
f"{self.__class__.__name__}_Channel{self._channel}_{self._device.id}"
|
||||
)
|
||||
unique_id = f"{self.__class__.__name__}_Channel{self.get_channel_index()}_{self._device.id}"
|
||||
|
||||
return unique_id
|
||||
|
||||
@@ -254,12 +274,65 @@ class HomematicipGenericEntity(Entity):
|
||||
return state_attr
|
||||
|
||||
def get_current_channel(self) -> FunctionalChannel:
|
||||
"""Return the FunctionalChannel for device."""
|
||||
if hasattr(self._device, "functionalChannels"):
|
||||
if self._is_multi_channel:
|
||||
return self._device.functionalChannels[self._channel]
|
||||
"""Return the FunctionalChannel for the device.
|
||||
|
||||
if len(self._device.functionalChannels) > 1:
|
||||
return self._device.functionalChannels[1]
|
||||
Resolution priority:
|
||||
1. For multi-channel entities with a real index, find channel by index match.
|
||||
2. For multi-channel entities without a real index, use the provided channel position.
|
||||
3. For non multi-channel entities with >1 channels, use channel at position 1
|
||||
(index 0 is often a meta/service channel in HmIP).
|
||||
Raises ValueError if no suitable channel can be resolved.
|
||||
"""
|
||||
functional_channels = getattr(self._device, "functionalChannels", None)
|
||||
if not functional_channels:
|
||||
raise ValueError(
|
||||
f"Device {getattr(self._device, 'id', 'unknown')} has no functionalChannels"
|
||||
)
|
||||
|
||||
return None
|
||||
# Multi-channel handling
|
||||
if self._is_multi_channel:
|
||||
# Prefer real index mapping when provided to avoid ordering issues.
|
||||
if self._channel_real_index is not None:
|
||||
for channel in functional_channels:
|
||||
if channel.index == self._channel_real_index:
|
||||
return channel
|
||||
raise ValueError(
|
||||
f"Real channel index {self._channel_real_index} not found for device "
|
||||
f"{getattr(self._device, 'id', 'unknown')}"
|
||||
)
|
||||
# Fallback: positional channel (already sorted as strings upstream).
|
||||
if self._channel is not None and 0 <= self._channel < len(
|
||||
functional_channels
|
||||
):
|
||||
return functional_channels[self._channel]
|
||||
raise ValueError(
|
||||
f"Channel position {self._channel} invalid for device "
|
||||
f"{getattr(self._device, 'id', 'unknown')} (len={len(functional_channels)})"
|
||||
)
|
||||
|
||||
# Single-channel / non multi-channel entity: choose second element if available
|
||||
if len(functional_channels) > 1:
|
||||
return functional_channels[1]
|
||||
return functional_channels[0]
|
||||
|
||||
def get_channel_index(self) -> int:
|
||||
"""Return the correct channel index for this entity.
|
||||
|
||||
Prefers channel_real_index if set, otherwise returns channel.
|
||||
This ensures the correct channel is used even if the functionalChannels list is not numerically ordered.
|
||||
"""
|
||||
if self._channel_real_index is not None:
|
||||
return self._channel_real_index
|
||||
|
||||
if self._channel is not None:
|
||||
return self._channel
|
||||
|
||||
return 1
|
||||
|
||||
def get_channel_or_raise(self) -> FunctionalChannel:
|
||||
"""Return the FunctionalChannel or raise an error if not found."""
|
||||
if not self.functional_channel:
|
||||
raise ValueError(
|
||||
f"No functional channel found for device {getattr(self._device, 'id', 'unknown')}"
|
||||
)
|
||||
return self.functional_channel
|
||||
|
||||
@@ -92,7 +92,9 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
await super().async_added_to_hass()
|
||||
self.functional_channel.add_on_channel_event_handler(self._async_handle_event)
|
||||
|
||||
channel = self.get_channel_or_raise()
|
||||
channel.add_on_channel_event_handler(self._async_handle_event)
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self, *args, **kwargs) -> None:
|
||||
|
||||
@@ -134,49 +134,49 @@ class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if light is on."""
|
||||
return self.functional_channel.on
|
||||
channel = self.get_channel_or_raise()
|
||||
return channel.on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the current brightness."""
|
||||
return int(self.functional_channel.dimLevel * 255.0)
|
||||
channel = self.get_channel_or_raise()
|
||||
return int(channel.dimLevel * 255.0)
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
"""Return the hue and saturation color value [float, float]."""
|
||||
if (
|
||||
self.functional_channel.hue is None
|
||||
or self.functional_channel.saturationLevel is None
|
||||
):
|
||||
channel = self.get_channel_or_raise()
|
||||
if channel.hue is None or channel.saturationLevel is None:
|
||||
return None
|
||||
return (
|
||||
self.functional_channel.hue,
|
||||
self.functional_channel.saturationLevel * 100.0,
|
||||
channel.hue,
|
||||
channel.saturationLevel * 100.0,
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
|
||||
channel = self.get_channel_or_raise()
|
||||
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
|
||||
hue = hs_color[0] % 360.0
|
||||
saturation = hs_color[1] / 100.0
|
||||
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
|
||||
|
||||
if ATTR_HS_COLOR not in kwargs:
|
||||
hue = self.functional_channel.hue
|
||||
saturation = self.functional_channel.saturationLevel
|
||||
hue = channel.hue
|
||||
saturation = channel.saturationLevel
|
||||
|
||||
if ATTR_BRIGHTNESS not in kwargs:
|
||||
# If no brightness is set, use the current brightness
|
||||
dim_level = self.functional_channel.dimLevel or 1.0
|
||||
|
||||
await self.functional_channel.set_hue_saturation_dim_level_async(
|
||||
dim_level = channel.dimLevel or 1.0
|
||||
await channel.set_hue_saturation_dim_level_async(
|
||||
hue=hue, saturation_level=saturation, dim_level=dim_level
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self.functional_channel.set_switch_state_async(on=False)
|
||||
channel = self.get_channel_or_raise()
|
||||
await channel.set_switch_state_async(on=False)
|
||||
|
||||
|
||||
class HomematicipLightMeasuring(HomematicipLight):
|
||||
|
||||
@@ -307,7 +307,8 @@ class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state."""
|
||||
return self.functional_channel.waterFlow
|
||||
channel = self.get_channel_or_raise()
|
||||
return channel.waterFlow
|
||||
|
||||
|
||||
class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity):
|
||||
|
||||
@@ -113,15 +113,18 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if switch is on."""
|
||||
return self.functional_channel.on
|
||||
channel = self.get_channel_or_raise()
|
||||
return channel.on
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self.functional_channel.async_turn_on()
|
||||
channel = self.get_channel_or_raise()
|
||||
await channel.async_turn_on()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self.functional_channel.async_turn_off()
|
||||
channel = self.get_channel_or_raise()
|
||||
await channel.async_turn_off()
|
||||
|
||||
|
||||
class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity):
|
||||
|
||||
@@ -47,13 +47,16 @@ class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity):
|
||||
|
||||
async def async_open_valve(self) -> None:
|
||||
"""Open the valve."""
|
||||
await self.functional_channel.set_watering_switch_state_async(True)
|
||||
channel = self.get_channel_or_raise()
|
||||
await channel.set_watering_switch_state_async(True)
|
||||
|
||||
async def async_close_valve(self) -> None:
|
||||
"""Close valve."""
|
||||
await self.functional_channel.set_watering_switch_state_async(False)
|
||||
channel = self.get_channel_or_raise()
|
||||
await channel.set_watering_switch_state_async(False)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Return if the valve is closed."""
|
||||
return self.functional_channel.wateringActive is False
|
||||
channel = self.get_channel_or_raise()
|
||||
return channel.wateringActive is False
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["incomfortclient"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["incomfort-client==0.6.9"]
|
||||
"requirements": ["incomfort-client==0.6.10"]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,7 @@ from random import random
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.labs import (
|
||||
EVENT_LABS_UPDATED,
|
||||
EventLabsUpdatedData,
|
||||
async_is_preview_feature_enabled,
|
||||
)
|
||||
from homeassistant.components.labs import async_is_preview_feature_enabled, async_listen
|
||||
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
|
||||
from homeassistant.components.recorder.models import (
|
||||
StatisticData,
|
||||
@@ -35,7 +31,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
@@ -120,17 +116,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
|
||||
|
||||
# Subscribe to labs feature updates for kitchen_sink preview repair
|
||||
@callback
|
||||
def _async_labs_updated(event: Event[EventLabsUpdatedData]) -> None:
|
||||
"""Handle labs feature update event."""
|
||||
if (
|
||||
event.data["domain"] == "kitchen_sink"
|
||||
and event.data["preview_feature"] == "special_repair"
|
||||
):
|
||||
_async_update_special_repair(hass)
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen(EVENT_LABS_UPDATED, _async_labs_updated)
|
||||
async_listen(
|
||||
hass,
|
||||
domain=DOMAIN,
|
||||
preview_feature="special_repair",
|
||||
listener=lambda: _async_update_special_repair(hass),
|
||||
)
|
||||
)
|
||||
|
||||
# Check if lab feature is currently enabled and create repair if so
|
||||
|
||||
@@ -7,6 +7,7 @@ in the Home Assistant Labs UI for users to enable or disable.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -14,7 +15,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.backup import async_get_manager
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.generated.labs import LABS_PREVIEW_FEATURES
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.storage import Store
|
||||
@@ -41,6 +42,7 @@ __all__ = [
|
||||
"EVENT_LABS_UPDATED",
|
||||
"EventLabsUpdatedData",
|
||||
"async_is_preview_feature_enabled",
|
||||
"async_listen",
|
||||
]
|
||||
|
||||
|
||||
@@ -217,6 +219,37 @@ def async_is_preview_feature_enabled(
|
||||
return (domain, preview_feature) in labs_data.data["preview_feature_status"]
|
||||
|
||||
|
||||
@callback
|
||||
def async_listen(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
preview_feature: str,
|
||||
listener: Callable[[], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for changes to a specific preview feature.
|
||||
|
||||
Args:
|
||||
hass: HomeAssistant instance
|
||||
domain: Integration domain
|
||||
preview_feature: Preview feature name
|
||||
listener: Callback to invoke when the preview feature is toggled
|
||||
|
||||
Returns:
|
||||
Callable to unsubscribe from the listener
|
||||
"""
|
||||
|
||||
@callback
|
||||
def _async_feature_updated(event: Event[EventLabsUpdatedData]) -> None:
|
||||
"""Handle labs feature update event."""
|
||||
if (
|
||||
event.data["domain"] == domain
|
||||
and event.data["preview_feature"] == preview_feature
|
||||
):
|
||||
listener()
|
||||
|
||||
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "labs/list"})
|
||||
@@ -234,7 +267,7 @@ def websocket_list_preview_features(
|
||||
(preview_feature.domain, preview_feature.preview_feature)
|
||||
in labs_data.data["preview_feature_status"]
|
||||
)
|
||||
for preview_feature_key, preview_feature in labs_data.preview_features.items()
|
||||
for preview_feature in labs_data.preview_features.values()
|
||||
if preview_feature.domain in loaded_components
|
||||
]
|
||||
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.2.0"]
|
||||
"requirements": ["pylamarzocco==2.2.2"]
|
||||
}
|
||||
|
||||
@@ -287,7 +287,11 @@ class DashboardsCollection(collection.DictStorageCollection):
|
||||
raise vol.Invalid("Url path needs to contain a hyphen (-)")
|
||||
|
||||
if url_path in self.hass.data[DATA_PANELS]:
|
||||
raise vol.Invalid("Panel url path needs to be unique")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="url_already_exists",
|
||||
translation_placeholders={"url": url_path},
|
||||
)
|
||||
|
||||
return self.CREATE_SCHEMA(data) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"url_already_exists": {
|
||||
"message": "The URL \"{url}\" is already in use. Please choose a different one."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload_resources": {
|
||||
"description": "Reloads dashboard resources from the YAML-configuration.",
|
||||
|
||||
@@ -25,6 +25,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"scenes": bridge.scenes,
|
||||
"occupancy_groups": bridge.occupancy_groups,
|
||||
"areas": bridge.areas,
|
||||
"smart_away_state": bridge.smart_away_state,
|
||||
},
|
||||
"integration_data": {
|
||||
"keypad_button_names_to_leap": data.keypad_data.button_names_to_leap,
|
||||
|
||||
@@ -5,9 +5,12 @@ from typing import Any
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .entity import LutronCasetaUpdatableEntity
|
||||
from .const import DOMAIN
|
||||
from .entity import LutronCasetaEntity, LutronCasetaUpdatableEntity
|
||||
from .models import LutronCasetaData
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -23,9 +26,14 @@ async def async_setup_entry(
|
||||
data = config_entry.runtime_data
|
||||
bridge = data.bridge
|
||||
switch_devices = bridge.get_devices_by_domain(SWITCH_DOMAIN)
|
||||
async_add_entities(
|
||||
entities: list[LutronCasetaLight | LutronCasetaSmartAwaySwitch] = [
|
||||
LutronCasetaLight(switch_device, data) for switch_device in switch_devices
|
||||
)
|
||||
]
|
||||
|
||||
if bridge.smart_away_state != "":
|
||||
entities.append(LutronCasetaSmartAwaySwitch(data))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity):
|
||||
@@ -61,3 +69,46 @@ class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity):
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if device is on."""
|
||||
return self._device["current_state"] > 0
|
||||
|
||||
|
||||
class LutronCasetaSmartAwaySwitch(LutronCasetaEntity, SwitchEntity):
|
||||
"""Representation of Lutron Caseta Smart Away."""
|
||||
|
||||
def __init__(self, data: LutronCasetaData) -> None:
|
||||
"""Init a switch entity."""
|
||||
device = {
|
||||
"device_id": "smart_away",
|
||||
"name": "Smart Away",
|
||||
"type": "SmartAway",
|
||||
"model": "Smart Away",
|
||||
"area": data.bridge_device["area"],
|
||||
"serial": data.bridge_device["serial"],
|
||||
}
|
||||
super().__init__(device, data)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, data.bridge_device["serial"])},
|
||||
)
|
||||
self._smart_away_unique_id = f"{self._bridge_unique_id}_smart_away"
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID of the smart away switch."""
|
||||
return self._smart_away_unique_id
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
await super().async_added_to_hass()
|
||||
self._smartbridge.add_smart_away_subscriber(self._handle_bridge_update)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn Smart Away on."""
|
||||
await self._smartbridge.activate_smart_away()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn Smart Away off."""
|
||||
await self._smartbridge.deactivate_smart_away()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if Smart Away is on."""
|
||||
return self._smartbridge.smart_away_state == "Enabled"
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["matrix_client"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0"]
|
||||
"requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0", "aiofiles==24.1.0"]
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ from homeassistant.util.async_ import create_eager_task
|
||||
from . import debug_info, discovery
|
||||
from .client import (
|
||||
MQTT,
|
||||
async_on_subscribe_done,
|
||||
async_publish,
|
||||
async_subscribe,
|
||||
async_subscribe_internal,
|
||||
@@ -163,6 +164,7 @@ __all__ = [
|
||||
"async_create_certificate_temp_files",
|
||||
"async_forward_entry_setup_and_setup_discovery",
|
||||
"async_migrate_entry",
|
||||
"async_on_subscribe_done",
|
||||
"async_prepare_subscribe_topics",
|
||||
"async_publish",
|
||||
"async_remove_config_entry_device",
|
||||
|
||||
@@ -38,7 +38,10 @@ from homeassistant.core import (
|
||||
get_hassjob_callable_job_type,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.importlib import async_import_module
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -71,6 +74,7 @@ from .const import (
|
||||
DEFAULT_WS_PATH,
|
||||
DOMAIN,
|
||||
MQTT_CONNECTION_STATE,
|
||||
MQTT_PROCESSED_SUBSCRIPTIONS,
|
||||
PROTOCOL_5,
|
||||
PROTOCOL_31,
|
||||
TRANSPORT_WEBSOCKETS,
|
||||
@@ -109,6 +113,7 @@ INITIAL_SUBSCRIBE_COOLDOWN = 0.5
|
||||
SUBSCRIBE_COOLDOWN = 0.1
|
||||
UNSUBSCRIBE_COOLDOWN = 0.1
|
||||
TIMEOUT_ACK = 10
|
||||
SUBSCRIBE_TIMEOUT = 10
|
||||
RECONNECT_INTERVAL_SECONDS = 10
|
||||
|
||||
MAX_WILDCARD_SUBSCRIBES_PER_CALL = 1
|
||||
@@ -184,6 +189,38 @@ async def async_publish(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_on_subscribe_done(
|
||||
hass: HomeAssistant,
|
||||
topic: str,
|
||||
qos: int,
|
||||
on_subscribe_status: CALLBACK_TYPE,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Call on_subscribe_done when the matched subscription was completed.
|
||||
|
||||
If a subscription is already present the callback will call
|
||||
on_subscribe_status directly.
|
||||
Call the returned callback to stop and cleanup status monitoring.
|
||||
"""
|
||||
|
||||
async def _sync_mqtt_subscribe(subscriptions: list[tuple[str, int]]) -> None:
|
||||
if (topic, qos) not in subscriptions:
|
||||
return
|
||||
hass.loop.call_soon(on_subscribe_status)
|
||||
|
||||
mqtt_data = hass.data[DATA_MQTT]
|
||||
if (
|
||||
mqtt_data.client.connected
|
||||
and mqtt_data.client.is_active_subscription(topic)
|
||||
and not mqtt_data.client.is_pending_subscription(topic)
|
||||
):
|
||||
hass.loop.call_soon(on_subscribe_status)
|
||||
|
||||
return async_dispatcher_connect(
|
||||
hass, MQTT_PROCESSED_SUBSCRIPTIONS, _sync_mqtt_subscribe
|
||||
)
|
||||
|
||||
|
||||
@bind_hass
|
||||
async def async_subscribe(
|
||||
hass: HomeAssistant,
|
||||
@@ -191,12 +228,32 @@ async def async_subscribe(
|
||||
msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None],
|
||||
qos: int = DEFAULT_QOS,
|
||||
encoding: str | None = DEFAULT_ENCODING,
|
||||
on_subscribe: CALLBACK_TYPE | None = None,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Subscribe to an MQTT topic.
|
||||
|
||||
If the on_subcribe callback hook is set, it will be called once
|
||||
when the subscription has been completed.
|
||||
|
||||
Call the return value to unsubscribe.
|
||||
"""
|
||||
return async_subscribe_internal(hass, topic, msg_callback, qos, encoding)
|
||||
handler: CALLBACK_TYPE | None = None
|
||||
|
||||
def _on_subscribe_done() -> None:
|
||||
"""Call once when the subscription was completed."""
|
||||
if TYPE_CHECKING:
|
||||
assert on_subscribe is not None and handler is not None
|
||||
|
||||
handler()
|
||||
on_subscribe()
|
||||
|
||||
subscription_handler = async_subscribe_internal(
|
||||
hass, topic, msg_callback, qos, encoding
|
||||
)
|
||||
if on_subscribe is not None:
|
||||
handler = async_on_subscribe_done(hass, topic, qos, _on_subscribe_done)
|
||||
|
||||
return subscription_handler
|
||||
|
||||
|
||||
@callback
|
||||
@@ -640,12 +697,16 @@ class MQTT:
|
||||
if fileno > -1:
|
||||
self.loop.remove_writer(sock)
|
||||
|
||||
def _is_active_subscription(self, topic: str) -> bool:
|
||||
def is_active_subscription(self, topic: str) -> bool:
|
||||
"""Check if a topic has an active subscription."""
|
||||
return topic in self._simple_subscriptions or any(
|
||||
other.topic == topic for other in self._wildcard_subscriptions
|
||||
)
|
||||
|
||||
def is_pending_subscription(self, topic: str) -> bool:
|
||||
"""Check if a topic has a pending subscription."""
|
||||
return topic in self._pending_subscriptions
|
||||
|
||||
async def async_publish(
|
||||
self, topic: str, payload: PublishPayloadType, qos: int, retain: bool
|
||||
) -> None:
|
||||
@@ -899,7 +960,7 @@ class MQTT:
|
||||
@callback
|
||||
def _async_unsubscribe(self, topic: str) -> None:
|
||||
"""Unsubscribe from a topic."""
|
||||
if self._is_active_subscription(topic):
|
||||
if self.is_active_subscription(topic):
|
||||
if self._max_qos[topic] == 0:
|
||||
return
|
||||
subs = self._matching_subscriptions(topic)
|
||||
@@ -963,6 +1024,7 @@ class MQTT:
|
||||
self._last_subscribe = time.monotonic()
|
||||
|
||||
await self._async_wait_for_mid_or_raise(mid, result)
|
||||
async_dispatcher_send(self.hass, MQTT_PROCESSED_SUBSCRIPTIONS, chunk_list)
|
||||
|
||||
async def _async_perform_unsubscribes(self) -> None:
|
||||
"""Perform pending MQTT client unsubscribes."""
|
||||
|
||||
@@ -62,6 +62,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.switch import SwitchDeviceClass
|
||||
from homeassistant.components.valve import ValveDeviceClass, ValveState
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigEntry,
|
||||
@@ -276,6 +277,7 @@ from .const import (
|
||||
CONF_PRESET_MODES_LIST,
|
||||
CONF_QOS,
|
||||
CONF_RED_TEMPLATE,
|
||||
CONF_REPORTS_POSITION,
|
||||
CONF_RETAIN,
|
||||
CONF_RGB_COMMAND_TEMPLATE,
|
||||
CONF_RGB_COMMAND_TOPIC,
|
||||
@@ -467,6 +469,7 @@ SUBENTRY_PLATFORMS = [
|
||||
Platform.SIREN,
|
||||
Platform.SWITCH,
|
||||
Platform.TEXT,
|
||||
Platform.VALVE,
|
||||
]
|
||||
|
||||
_CODE_VALIDATION_MODE = {
|
||||
@@ -831,6 +834,16 @@ TEXT_MODE_SELECTOR = SelectSelector(
|
||||
TEXT_SIZE_SELECTOR = NumberSelector(
|
||||
NumberSelectorConfig(min=0, max=255, step=1, mode=NumberSelectorMode.BOX)
|
||||
)
|
||||
VALVE_DEVICE_CLASS_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[device_class.value for device_class in ValveDeviceClass],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="device_class_valve",
|
||||
)
|
||||
)
|
||||
VALVE_POSITION_SELECTOR = NumberSelector(
|
||||
NumberSelectorConfig(mode=NumberSelectorMode.BOX, step=1)
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -1199,6 +1212,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
|
||||
Platform.SIREN: None,
|
||||
Platform.SWITCH: None,
|
||||
Platform.TEXT: validate_text_platform_config,
|
||||
Platform.VALVE: None,
|
||||
}
|
||||
|
||||
|
||||
@@ -1460,6 +1474,16 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
),
|
||||
},
|
||||
Platform.TEXT: {},
|
||||
Platform.VALVE: {
|
||||
CONF_DEVICE_CLASS: PlatformField(
|
||||
selector=VALVE_DEVICE_CLASS_SELECTOR, required=False, default=None
|
||||
),
|
||||
CONF_REPORTS_POSITION: PlatformField(
|
||||
selector=BOOLEAN_SELECTOR,
|
||||
required=True,
|
||||
default=False,
|
||||
),
|
||||
},
|
||||
}
|
||||
PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
Platform.ALARM_CONTROL_PANEL: {
|
||||
@@ -3380,6 +3404,91 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
section="text_advanced_settings",
|
||||
),
|
||||
},
|
||||
Platform.VALVE: {
|
||||
CONF_COMMAND_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
validator=valid_publish_topic,
|
||||
error="invalid_publish_topic",
|
||||
),
|
||||
CONF_COMMAND_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_STATE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
),
|
||||
CONF_VALUE_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_POSITION_CLOSED: PlatformField(
|
||||
selector=VALVE_POSITION_SELECTOR,
|
||||
required=True,
|
||||
default=DEFAULT_POSITION_CLOSED,
|
||||
conditions=({CONF_REPORTS_POSITION: True},),
|
||||
),
|
||||
CONF_POSITION_OPEN: PlatformField(
|
||||
selector=VALVE_POSITION_SELECTOR,
|
||||
required=True,
|
||||
default=DEFAULT_POSITION_OPEN,
|
||||
conditions=({CONF_REPORTS_POSITION: True},),
|
||||
),
|
||||
CONF_PAYLOAD_OPEN: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
default=DEFAULT_PAYLOAD_OPEN,
|
||||
conditions=({CONF_REPORTS_POSITION: False},),
|
||||
section="valve_payload_settings",
|
||||
),
|
||||
CONF_PAYLOAD_CLOSE: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
default=DEFAULT_PAYLOAD_CLOSE,
|
||||
conditions=({CONF_REPORTS_POSITION: False},),
|
||||
section="valve_payload_settings",
|
||||
),
|
||||
CONF_PAYLOAD_STOP: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
section="valve_payload_settings",
|
||||
),
|
||||
CONF_STATE_OPEN: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
default=ValveState.OPEN.value,
|
||||
conditions=({CONF_REPORTS_POSITION: False},),
|
||||
section="valve_payload_settings",
|
||||
),
|
||||
CONF_STATE_CLOSED: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
default=ValveState.CLOSED.value,
|
||||
conditions=({CONF_REPORTS_POSITION: False},),
|
||||
section="valve_payload_settings",
|
||||
),
|
||||
CONF_STATE_OPENING: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
default=ValveState.OPENING.value,
|
||||
section="valve_payload_settings",
|
||||
),
|
||||
CONF_STATE_CLOSING: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
default=ValveState.CLOSING.value,
|
||||
section="valve_payload_settings",
|
||||
),
|
||||
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
CONF_OPTIMISTIC: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
|
||||
},
|
||||
}
|
||||
MQTT_DEVICE_PLATFORM_FIELDS = {
|
||||
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
|
||||
|
||||
@@ -172,6 +172,7 @@ CONF_PRESET_MODES_LIST = "preset_modes"
|
||||
CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic"
|
||||
CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template"
|
||||
CONF_RED_TEMPLATE = "red_template"
|
||||
CONF_REPORTS_POSITION = "reports_position"
|
||||
CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template"
|
||||
CONF_RGB_COMMAND_TOPIC = "rgb_command_topic"
|
||||
CONF_RGB_STATE_TOPIC = "rgb_state_topic"
|
||||
@@ -375,6 +376,7 @@ DOMAIN = "mqtt"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
MQTT_CONNECTION_STATE = "mqtt_connection_state"
|
||||
MQTT_PROCESSED_SUBSCRIPTIONS = "mqtt_processed_subscriptions"
|
||||
|
||||
PAYLOAD_EMPTY_JSON = "{}"
|
||||
PAYLOAD_NONE = "None"
|
||||
|
||||
@@ -242,6 +242,7 @@
|
||||
"fan_feature_speed": "Speed support",
|
||||
"image_processing_mode": "Image processing mode",
|
||||
"options": "Add option",
|
||||
"reports_position": "Reports position",
|
||||
"schema": "Schema",
|
||||
"state_class": "State class",
|
||||
"suggested_display_precision": "Suggested display precision",
|
||||
@@ -269,6 +270,7 @@
|
||||
"fan_feature_speed": "The fan supports multiple speeds.",
|
||||
"image_processing_mode": "Select how the image data is received.",
|
||||
"options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement.",
|
||||
"reports_position": "Set this option if the valve reports the position or supports setting the position. Enabling this option will cause the position to be published instead of a payload defined by payload \"open\", payload \"close\" or payload \"stop\". When receiving messages, state topic will accept numeric payloads or one of the configured state messages.",
|
||||
"schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)",
|
||||
"state_class": "The [State class]({available_state_classes_url}) of the sensor. [Learn more.]({url}#state_class)",
|
||||
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)",
|
||||
@@ -356,6 +358,8 @@
|
||||
"payload_on": "Payload \"on\"",
|
||||
"payload_press": "Payload \"press\"",
|
||||
"payload_reset": "Payload \"reset\"",
|
||||
"position_closed": "Position \"closed\"",
|
||||
"position_open": "Position \"open\"",
|
||||
"qos": "QoS",
|
||||
"red_template": "Red template",
|
||||
"retain": "Retain",
|
||||
@@ -407,6 +411,8 @@
|
||||
"payload_on": "The payload that represents the \"on\" state.",
|
||||
"payload_press": "The payload to send when the button is triggered.",
|
||||
"payload_reset": "The payload received at the state topic that resets the entity to an unknown state.",
|
||||
"position_closed": "Number which represents closed position. The valve’s position will be scaled to the (position \"closed\"…position \"open\") range when an action is performed and scaled back when a value is received.",
|
||||
"position_open": "Number which represents open position. The valve’s position will be scaled to the (position \"closed\"…position \"open\") range when an action is performed and scaled back when a value is received.",
|
||||
"qos": "The QoS value a {platform} entity should use.",
|
||||
"red_template": "[Template]({value_templating_url}) to extract red color from the state payload value. Expected result of the template is an integer from 0-255 range.",
|
||||
"retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.",
|
||||
@@ -985,6 +991,27 @@
|
||||
"pattern": "A valid regex pattern"
|
||||
},
|
||||
"name": "Advanced text entity settings"
|
||||
},
|
||||
"valve_payload_settings": {
|
||||
"data": {
|
||||
"payload_close": "Payload \"close\"",
|
||||
"payload_open": "Payload \"open\"",
|
||||
"payload_stop": "Payload \"stop\"",
|
||||
"state_closed": "State \"closed\"",
|
||||
"state_closing": "State \"closing\"",
|
||||
"state_open": "State \"open\"",
|
||||
"state_opening": "State \"opening\""
|
||||
},
|
||||
"data_description": {
|
||||
"payload_close": "The payload sent when a \"close\" command is issued.",
|
||||
"payload_open": "The payload sent when an \"open\" command is issued.",
|
||||
"payload_stop": "The payload sent when a \"stop\" command is issued. Set this payload only if your valve supports the \"stop\" action.",
|
||||
"state_closed": "The payload received at the state topic that represents the \"closed\" state.",
|
||||
"state_closing": "The payload received at the state topic that represents the \"closing\" state.",
|
||||
"state_open": "The payload received at the state topic that represents the \"open\" state.",
|
||||
"state_opening": "The payload received at the state topic that represents the \"opening\" state."
|
||||
},
|
||||
"name": "Valve payload settings"
|
||||
}
|
||||
},
|
||||
"title": "Configure MQTT device \"{mqtt_device}\""
|
||||
@@ -1347,6 +1374,12 @@
|
||||
"switch": "[%key:component::switch::title%]"
|
||||
}
|
||||
},
|
||||
"device_class_valve": {
|
||||
"options": {
|
||||
"gas": "[%key:component::valve::entity_component::gas::name%]",
|
||||
"water": "[%key:component::valve::entity_component::water::name%]"
|
||||
}
|
||||
},
|
||||
"entity_category": {
|
||||
"options": {
|
||||
"config": "Config",
|
||||
@@ -1403,7 +1436,8 @@
|
||||
"sensor": "[%key:component::sensor::title%]",
|
||||
"siren": "[%key:component::siren::title%]",
|
||||
"switch": "[%key:component::switch::title%]",
|
||||
"text": "[%key:component::text::title%]"
|
||||
"text": "[%key:component::text::title%]",
|
||||
"valve": "[%key:component::valve::title%]"
|
||||
}
|
||||
},
|
||||
"set_ca_cert": {
|
||||
|
||||
@@ -42,6 +42,7 @@ from .const import (
|
||||
CONF_PAYLOAD_STOP,
|
||||
CONF_POSITION_CLOSED,
|
||||
CONF_POSITION_OPEN,
|
||||
CONF_REPORTS_POSITION,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_CLOSED,
|
||||
CONF_STATE_CLOSING,
|
||||
@@ -65,8 +66,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
CONF_REPORTS_POSITION = "reports_position"
|
||||
|
||||
DEFAULT_NAME = "MQTT Valve"
|
||||
|
||||
MQTT_VALVE_ATTRIBUTES_BLOCKED = frozenset(
|
||||
@@ -112,8 +111,12 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
|
||||
vol.Optional(CONF_PAYLOAD_CLOSE): vol.Any(cv.string, None),
|
||||
vol.Optional(CONF_PAYLOAD_OPEN): vol.Any(cv.string, None),
|
||||
vol.Optional(CONF_PAYLOAD_STOP): vol.Any(cv.string, None),
|
||||
vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): int,
|
||||
vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): int,
|
||||
vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): vol.Coerce(
|
||||
int
|
||||
),
|
||||
vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): vol.Coerce(
|
||||
int
|
||||
),
|
||||
vol.Optional(CONF_REPORTS_POSITION, default=False): cv.boolean,
|
||||
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
|
||||
vol.Optional(CONF_STATE_CLOSED): cv.string,
|
||||
|
||||
@@ -40,7 +40,7 @@ async def async_setup_entry(
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the available OctoPrint binary sensors."""
|
||||
"""Set up the available OctoPrint sensors."""
|
||||
coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
]["coordinator"]
|
||||
@@ -111,7 +111,7 @@ class OctoPrintSensorBase(
|
||||
|
||||
|
||||
class OctoPrintStatusSensor(OctoPrintSensorBase):
|
||||
"""Representation of an OctoPrint sensor."""
|
||||
"""Representation of an OctoPrint status sensor."""
|
||||
|
||||
_attr_icon = "mdi:printer-3d"
|
||||
|
||||
@@ -137,7 +137,7 @@ class OctoPrintStatusSensor(OctoPrintSensorBase):
|
||||
|
||||
|
||||
class OctoPrintJobPercentageSensor(OctoPrintSensorBase):
|
||||
"""Representation of an OctoPrint sensor."""
|
||||
"""Representation of an OctoPrint job percentage sensor."""
|
||||
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_icon = "mdi:file-percent"
|
||||
@@ -162,9 +162,10 @@ class OctoPrintJobPercentageSensor(OctoPrintSensorBase):
|
||||
|
||||
|
||||
class OctoPrintEstimatedFinishTimeSensor(OctoPrintSensorBase):
|
||||
"""Representation of an OctoPrint sensor."""
|
||||
"""Representation of an OctoPrint estimated finish time sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_icon = "mdi:clock-end"
|
||||
|
||||
def __init__(
|
||||
self, coordinator: OctoprintDataUpdateCoordinator, device_id: str
|
||||
@@ -191,9 +192,10 @@ class OctoPrintEstimatedFinishTimeSensor(OctoPrintSensorBase):
|
||||
|
||||
|
||||
class OctoPrintStartTimeSensor(OctoPrintSensorBase):
|
||||
"""Representation of an OctoPrint sensor."""
|
||||
"""Representation of an OctoPrint start time sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_icon = "mdi:clock-start"
|
||||
|
||||
def __init__(
|
||||
self, coordinator: OctoprintDataUpdateCoordinator, device_id: str
|
||||
@@ -221,11 +223,12 @@ class OctoPrintStartTimeSensor(OctoPrintSensorBase):
|
||||
|
||||
|
||||
class OctoPrintTemperatureSensor(OctoPrintSensorBase):
|
||||
"""Representation of an OctoPrint sensor."""
|
||||
"""Representation of an OctoPrint temperature sensor."""
|
||||
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_icon = "mdi:printer-3d-nozzle-heat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -267,7 +270,9 @@ class OctoPrintTemperatureSensor(OctoPrintSensorBase):
|
||||
|
||||
|
||||
class OctoPrintFileNameSensor(OctoPrintSensorBase):
|
||||
"""Representation of an OctoPrint sensor."""
|
||||
"""Representation of an OctoPrint file name sensor."""
|
||||
|
||||
_attr_icon = "mdi:printer-3d-nozzle"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -294,7 +299,7 @@ class OctoPrintFileNameSensor(OctoPrintSensorBase):
|
||||
|
||||
|
||||
class OctoPrintFileSizeSensor(OctoPrintSensorBase):
|
||||
"""Representation of an OctoPrint sensor."""
|
||||
"""Representation of an OctoPrint file size sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.DATA_SIZE
|
||||
_attr_native_unit_of_measurement = UnitOfInformation.BYTES
|
||||
|
||||
@@ -306,6 +306,14 @@ class OllamaSubentryFlowHandler(ConfigSubentryFlow):
|
||||
async_step_reconfigure = async_step_set_options
|
||||
|
||||
|
||||
def filter_invalid_llm_apis(hass: HomeAssistant, selected_apis: list[str]) -> list[str]:
|
||||
"""Accepts a list of LLM API IDs and filters this against those currently available."""
|
||||
|
||||
valid_llm_apis = [api.id for api in llm.async_get_apis(hass)]
|
||||
|
||||
return [api for api in selected_apis if api in valid_llm_apis]
|
||||
|
||||
|
||||
def ollama_config_option_schema(
|
||||
hass: HomeAssistant,
|
||||
is_new: bool,
|
||||
@@ -326,6 +334,10 @@ def ollama_config_option_schema(
|
||||
else:
|
||||
schema = {}
|
||||
|
||||
selected_llm_apis = filter_invalid_llm_apis(
|
||||
hass, options.get(CONF_LLM_HASS_API, [])
|
||||
)
|
||||
|
||||
schema.update(
|
||||
{
|
||||
vol.Required(
|
||||
@@ -349,7 +361,7 @@ def ollama_config_option_schema(
|
||||
): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
|
||||
description={"suggested_value": selected_llm_apis},
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pysaunum import SaunumClient, SaunumConnectionError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import LeilSaunaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
]
|
||||
|
||||
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
|
||||
@@ -27,8 +26,6 @@ from . import LeilSaunaConfigEntry
|
||||
from .const import DELAYED_REFRESH_SECONDS, DOMAIN
|
||||
from .entity import LeilSaunaEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
# Map Saunum fan speed (0-3) to Home Assistant fan modes
|
||||
|
||||
@@ -3,14 +3,7 @@
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN: Final = "saunum"
|
||||
|
||||
# Platforms
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
]
|
||||
|
||||
DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60)
|
||||
DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3)
|
||||
|
||||
71
homeassistant/components/saunum/light.py
Normal file
71
homeassistant/components/saunum/light.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Light platform for Saunum Leil Sauna Control Unit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pysaunum import SaunumException
|
||||
|
||||
from homeassistant.components.light import ColorMode, LightEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import LeilSaunaConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import LeilSaunaEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LeilSaunaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Saunum Leil Sauna light entity."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities([LeilSaunaLight(coordinator)])
|
||||
|
||||
|
||||
class LeilSaunaLight(LeilSaunaEntity, LightEntity):
|
||||
"""Representation of a Saunum Leil Sauna light entity."""
|
||||
|
||||
_attr_translation_key = "light"
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
def __init__(self, coordinator) -> None:
|
||||
"""Initialize the light entity."""
|
||||
super().__init__(coordinator)
|
||||
# Override unique_id to differentiate from climate entity
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_light"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if light is on."""
|
||||
return self.coordinator.data.light_on
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
try:
|
||||
await self.coordinator.client.async_set_light_control(True)
|
||||
except SaunumException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_light_on_failed",
|
||||
) from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
try:
|
||||
await self.coordinator.client.async_set_light_control(False)
|
||||
except SaunumException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_light_off_failed",
|
||||
) from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -59,7 +59,7 @@ rules:
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"light": {
|
||||
"light": {
|
||||
"name": "[%key:component::light::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"communication_error": {
|
||||
"message": "Communication error: {error}"
|
||||
@@ -29,6 +36,12 @@
|
||||
"set_hvac_mode_failed": {
|
||||
"message": "Failed to set HVAC mode to {hvac_mode}"
|
||||
},
|
||||
"set_light_off_failed": {
|
||||
"message": "Failed to turn off light"
|
||||
},
|
||||
"set_light_on_failed": {
|
||||
"message": "Failed to turn on light"
|
||||
},
|
||||
"set_temperature_failed": {
|
||||
"message": "Failed to set temperature to {temperature}"
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ from .coordinator import (
|
||||
from .repairs import (
|
||||
async_manage_ble_scanner_firmware_unsupported_issue,
|
||||
async_manage_deprecated_firmware_issue,
|
||||
async_manage_open_wifi_ap_issue,
|
||||
async_manage_outbound_websocket_incorrectly_enabled_issue,
|
||||
)
|
||||
from .utils import (
|
||||
@@ -347,6 +348,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
|
||||
hass,
|
||||
entry,
|
||||
)
|
||||
async_manage_open_wifi_ap_issue(hass, entry)
|
||||
remove_empty_sub_devices(hass, entry)
|
||||
elif (
|
||||
sleep_period is None
|
||||
|
||||
@@ -33,6 +33,7 @@ from homeassistant.components import zeroconf
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_ble_device_from_address,
|
||||
async_clear_address_from_match_history,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.const import (
|
||||
@@ -395,6 +396,14 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not mac:
|
||||
return self.async_abort(reason="invalid_discovery_info")
|
||||
|
||||
# Clear match history at the start of discovery flow.
|
||||
# This ensures that if the user never provisions the device and it
|
||||
# disappears (powers down), the discovery flow gets cleaned up,
|
||||
# and then the device comes back later, it can be rediscovered.
|
||||
# Also handles factory reset scenarios where the device may reappear
|
||||
# with different advertisement content (RPC-over-BLE re-enabled).
|
||||
async_clear_address_from_match_history(self.hass, discovery_info.address)
|
||||
|
||||
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
|
||||
if not has_rpc_over_ble(discovery_info.manufacturer_data):
|
||||
LOGGER.debug(
|
||||
@@ -685,6 +694,13 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
# Secure device after provisioning if requested (disable AP/BLE)
|
||||
await self._async_secure_device_after_provision(self.host, self.port)
|
||||
|
||||
# Clear match history so device can be rediscovered if factory reset
|
||||
# This ensures that if the device is factory reset in the future
|
||||
# (re-enabling BLE provisioning), it will trigger a new discovery flow
|
||||
if TYPE_CHECKING:
|
||||
assert self.ble_device is not None
|
||||
async_clear_address_from_match_history(self.hass, self.ble_device.address)
|
||||
|
||||
# User just provisioned this device - create entry directly without confirmation
|
||||
return self.async_create_entry(
|
||||
title=device_info["title"],
|
||||
|
||||
@@ -254,6 +254,7 @@ OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = (
|
||||
"outbound_websocket_incorrectly_enabled_{unique}"
|
||||
)
|
||||
DEPRECATED_FIRMWARE_ISSUE_ID = "deprecated_firmware_{unique}"
|
||||
OPEN_WIFI_AP_ISSUE_ID = "open_wifi_ap_{unique}"
|
||||
|
||||
|
||||
class DeprecatedFirmwareInfo(TypedDict):
|
||||
|
||||
@@ -20,14 +20,14 @@ from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER
|
||||
from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, ROLE_GENERIC
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .utils import (
|
||||
async_remove_shelly_entity,
|
||||
get_block_device_info,
|
||||
get_block_entity_name,
|
||||
get_rpc_channel_name,
|
||||
get_rpc_device_info,
|
||||
get_rpc_entity_name,
|
||||
get_rpc_key,
|
||||
get_rpc_key_instances,
|
||||
get_rpc_role_by_key,
|
||||
)
|
||||
@@ -371,7 +371,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]):
|
||||
"""Initialize Shelly entity."""
|
||||
super().__init__(coordinator)
|
||||
self.block = block
|
||||
self._attr_name = get_block_entity_name(coordinator.device, block)
|
||||
|
||||
self._attr_device_info = get_entity_block_device_info(coordinator, block)
|
||||
self._attr_unique_id = f"{coordinator.mac}-{block.description}"
|
||||
|
||||
@@ -413,9 +413,9 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]):
|
||||
"""Initialize Shelly entity."""
|
||||
super().__init__(coordinator)
|
||||
self.key = key
|
||||
|
||||
self._attr_device_info = get_entity_rpc_device_info(coordinator, key)
|
||||
self._attr_unique_id = f"{coordinator.mac}-{key}"
|
||||
self._attr_name = get_rpc_entity_name(coordinator.device, key)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -467,9 +467,6 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity):
|
||||
self.entity_description = description
|
||||
|
||||
self._attr_unique_id: str = f"{super().unique_id}-{self.attribute}"
|
||||
self._attr_name = get_block_entity_name(
|
||||
coordinator.device, block, description.name
|
||||
)
|
||||
|
||||
@property
|
||||
def attribute_value(self) -> StateType:
|
||||
@@ -507,9 +504,7 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]):
|
||||
self.block_coordinator = coordinator
|
||||
self.attribute = attribute
|
||||
self.entity_description = description
|
||||
self._attr_name = get_block_entity_name(
|
||||
coordinator.device, None, description.name
|
||||
)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.mac}-{attribute}"
|
||||
self._attr_device_info = get_entity_block_device_info(coordinator)
|
||||
self._last_value = None
|
||||
@@ -546,13 +541,13 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, Entity):
|
||||
self.attribute = attribute
|
||||
self.entity_description = description
|
||||
|
||||
if description.role == ROLE_GENERIC:
|
||||
self._attr_name = get_rpc_channel_name(coordinator.device, key)
|
||||
|
||||
self._attr_unique_id = f"{super().unique_id}-{attribute}"
|
||||
self._attr_name = get_rpc_entity_name(
|
||||
coordinator.device, key, description.name, description.role
|
||||
)
|
||||
self._last_value = None
|
||||
id_key = key.split(":")[-1]
|
||||
self._id = int(id_key) if id_key.isnumeric() else None
|
||||
has_id, _, component_id = get_rpc_key(key)
|
||||
self._id = int(component_id) if has_id and component_id.isnumeric() else None
|
||||
|
||||
if description.unit is not None:
|
||||
self._attr_native_unit_of_measurement = description.unit(
|
||||
@@ -626,9 +621,6 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
|
||||
self._attr_unique_id = (
|
||||
f"{self.coordinator.mac}-{block.description}-{attribute}"
|
||||
)
|
||||
self._attr_name = get_block_entity_name(
|
||||
coordinator.device, block, description.name
|
||||
)
|
||||
elif entry is not None:
|
||||
self._attr_unique_id = entry.unique_id
|
||||
|
||||
@@ -689,11 +681,7 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity):
|
||||
self._attr_unique_id = f"{coordinator.mac}-{key}-{attribute}"
|
||||
self._last_value = None
|
||||
|
||||
if coordinator.device.initialized:
|
||||
self._attr_name = get_rpc_entity_name(
|
||||
coordinator.device, key, description.name
|
||||
)
|
||||
elif entry is not None:
|
||||
if not coordinator.device.initialized and entry is not None:
|
||||
self._attr_name = cast(str, entry.original_name)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioshelly==13.20.0"],
|
||||
"requirements": ["aioshelly==13.21.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "shelly*",
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import (
|
||||
DEPRECATED_FIRMWARE_ISSUE_ID,
|
||||
DEPRECATED_FIRMWARES,
|
||||
DOMAIN,
|
||||
OPEN_WIFI_AP_ISSUE_ID,
|
||||
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
|
||||
BLEScannerMode,
|
||||
)
|
||||
@@ -149,6 +150,45 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
|
||||
@callback
|
||||
def async_manage_open_wifi_ap_issue(
|
||||
hass: HomeAssistant,
|
||||
entry: ShellyConfigEntry,
|
||||
) -> None:
|
||||
"""Manage the open WiFi AP issue."""
|
||||
issue_id = OPEN_WIFI_AP_ISSUE_ID.format(unique=entry.unique_id)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert entry.runtime_data.rpc is not None
|
||||
|
||||
device = entry.runtime_data.rpc.device
|
||||
|
||||
# Check if WiFi AP is enabled and is open (no password)
|
||||
if (
|
||||
(wifi_config := device.config.get("wifi"))
|
||||
and (ap_config := wifi_config.get("ap"))
|
||||
and ap_config.get("enable")
|
||||
and ap_config.get("is_open")
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="open_wifi_ap",
|
||||
translation_placeholders={
|
||||
"device_name": device.name,
|
||||
"ip_address": device.ip_address,
|
||||
},
|
||||
data={"entry_id": entry.entry_id},
|
||||
)
|
||||
return
|
||||
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
|
||||
class ShellyRpcRepairsFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
@@ -229,6 +269,49 @@ class DisableOutboundWebSocketFlow(ShellyRpcRepairsFlow):
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
|
||||
class DisableOpenWiFiApFlow(RepairsFlow):
|
||||
"""Handler for Disable Open WiFi AP flow."""
|
||||
|
||||
def __init__(self, device: RpcDevice, issue_id: str) -> None:
|
||||
"""Initialize."""
|
||||
self._device = device
|
||||
self.issue_id = issue_id
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
issue_registry = ir.async_get(self.hass)
|
||||
description_placeholders = None
|
||||
if issue := issue_registry.async_get_issue(DOMAIN, self.issue_id):
|
||||
description_placeholders = issue.translation_placeholders
|
||||
|
||||
return self.async_show_menu(
|
||||
menu_options=["confirm", "ignore"],
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
try:
|
||||
result = await self._device.wifi_setconfig(ap_enable=False)
|
||||
if result.get("restart_required"):
|
||||
await self._device.trigger_reboot()
|
||||
except (DeviceConnectionError, RpcCallError):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
async def async_step_ignore(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the ignore step of a fix flow."""
|
||||
ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True)
|
||||
return self.async_abort(reason="issue_ignored")
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant, issue_id: str, data: dict[str, str] | None
|
||||
) -> RepairsFlow:
|
||||
@@ -253,4 +336,7 @@ async def async_create_fix_flow(
|
||||
if "outbound_websocket_incorrectly_enabled" in issue_id:
|
||||
return DisableOutboundWebSocketFlow(device)
|
||||
|
||||
if "open_wifi_ap" in issue_id:
|
||||
return DisableOpenWiFiApFlow(device, issue_id)
|
||||
|
||||
return ConfirmRepairFlow()
|
||||
|
||||
@@ -59,9 +59,6 @@ class RpcSelect(ShellyRpcAttributeEntity, SelectEntity):
|
||||
if self.option_map:
|
||||
self._attr_options = list(self.option_map.values())
|
||||
|
||||
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
|
||||
@@ -664,6 +664,25 @@
|
||||
"description": "Shelly device {device_name} with IP address {ip_address} requires calibration. To calibrate the device, it must be rebooted after proper installation on the valve. You can reboot the device in its web panel, go to 'Settings' > 'Device Reboot'.",
|
||||
"title": "Shelly device {device_name} is not calibrated"
|
||||
},
|
||||
"open_wifi_ap": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"issue_ignored": "Issue ignored"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "Your Shelly device {device_name} with IP address {ip_address} has an open WiFi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.",
|
||||
"menu_options": {
|
||||
"confirm": "Disable WiFi access point",
|
||||
"ignore": "Ignore"
|
||||
},
|
||||
"title": "[%key:component::shelly::issues::open_wifi_ap::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Open WiFi access point on {device_name}"
|
||||
},
|
||||
"outbound_websocket_incorrectly_enabled": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
|
||||
@@ -49,7 +49,6 @@ from homeassistant.helpers.device_registry import (
|
||||
DeviceInfo,
|
||||
)
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import (
|
||||
@@ -117,20 +116,6 @@ def get_block_number_of_channels(device: BlockDevice, block: Block) -> int:
|
||||
return channels or 1
|
||||
|
||||
|
||||
def get_block_entity_name(
|
||||
device: BlockDevice,
|
||||
block: Block | None,
|
||||
name: str | UndefinedType | None = None,
|
||||
) -> str | None:
|
||||
"""Naming for block based switch and sensors."""
|
||||
channel_name = get_block_channel_name(device, block)
|
||||
|
||||
if name is not UNDEFINED and name:
|
||||
return f"{channel_name} {name.lower()}" if channel_name else name
|
||||
|
||||
return channel_name
|
||||
|
||||
|
||||
def get_block_custom_name(device: BlockDevice, block: Block | None) -> str | None:
|
||||
"""Get custom name from device settings."""
|
||||
if block and (key := cast(str, block.type) + "s") and key in device.settings:
|
||||
@@ -474,23 +459,6 @@ def get_rpc_sub_device_name(
|
||||
return f"{device.name} {component.title()} {component_id}"
|
||||
|
||||
|
||||
def get_rpc_entity_name(
|
||||
device: RpcDevice,
|
||||
key: str,
|
||||
name: str | UndefinedType | None = None,
|
||||
role: str | None = None,
|
||||
) -> str | None:
|
||||
"""Naming for RPC based switch and sensors."""
|
||||
channel_name = get_rpc_channel_name(device, key)
|
||||
|
||||
if name is not UNDEFINED and name:
|
||||
if role and role != ROLE_GENERIC:
|
||||
return name
|
||||
return f"{channel_name} {name.lower()}" if channel_name else name
|
||||
|
||||
return channel_name
|
||||
|
||||
|
||||
def get_entity_translation_attributes(
|
||||
channel_name: str | None,
|
||||
translation_key: str | None,
|
||||
@@ -826,11 +794,9 @@ async def get_rpc_scripts_event_types(
|
||||
device: RpcDevice, ignore_scripts: list[str]
|
||||
) -> dict[int, list[str]]:
|
||||
"""Return a dict of all scripts and their event types."""
|
||||
script_instances = get_rpc_key_instances(device.status, "script")
|
||||
script_events = {}
|
||||
for script in script_instances:
|
||||
script_name = get_rpc_entity_name(device, script)
|
||||
if script_name in ignore_scripts:
|
||||
for script in get_rpc_key_instances(device.status, "script"):
|
||||
if get_rpc_channel_name(device, script) in ignore_scripts:
|
||||
continue
|
||||
|
||||
script_id = get_rpc_key_id(script)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["slack"],
|
||||
"requirements": ["slack_sdk==3.33.4"]
|
||||
"requirements": ["slack_sdk==3.33.4", "aiofiles==24.1.0"]
|
||||
}
|
||||
|
||||
@@ -166,7 +166,9 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]):
|
||||
zb_firmware: list[FirmwareList] = []
|
||||
|
||||
try:
|
||||
esp_firmware = await self.client.get_firmware_version(info.fw_channel)
|
||||
esp_firmware = await self.client.get_firmware_version(
|
||||
info.fw_channel, device=info.model
|
||||
)
|
||||
zb_firmware.extend(
|
||||
[
|
||||
await self.client.get_firmware_version(
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pysmlight==0.2.8"],
|
||||
"requirements": ["pysmlight==0.2.11"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_slzb-06._tcp.local."
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.const import (
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -164,6 +165,15 @@ WALL_CONNECTOR_SENSORS = [
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
WallConnectorSensorDescription(
|
||||
key="total_power_w",
|
||||
translation_key="total_power_w",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].total_power_w,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
WallConnectorSensorDescription(
|
||||
key="session_energy_wh",
|
||||
translation_key="session_energy_wh",
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
"status_code": {
|
||||
"name": "Status code"
|
||||
},
|
||||
"total_power_w": {
|
||||
"name": "Total power"
|
||||
},
|
||||
"voltage_a_v": {
|
||||
"name": "Phase A voltage"
|
||||
},
|
||||
|
||||
@@ -123,7 +123,9 @@ async def async_setup_entry(
|
||||
ThermoProBluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
entry.async_on_unload(
|
||||
coordinator.async_register_processor(processor, SensorEntityDescription)
|
||||
)
|
||||
|
||||
|
||||
class ThermoProBluetoothSensorEntity(
|
||||
|
||||
@@ -1,33 +1,83 @@
|
||||
"""Support for Tibber."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.client_exceptions import ClientError, ClientResponseError
|
||||
import tibber
|
||||
from tibber import data_api as tibber_data_api
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util, ssl as ssl_util
|
||||
|
||||
from .const import DATA_HASS_CONFIG, DOMAIN
|
||||
from .const import (
|
||||
API_TYPE_DATA_API,
|
||||
API_TYPE_GRAPHQL,
|
||||
CONF_API_TYPE,
|
||||
DATA_HASS_CONFIG,
|
||||
DOMAIN,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||
GRAPHQL_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||
DATA_API_PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TibberGraphQLRuntimeData:
|
||||
"""Runtime data for GraphQL-based Tibber entries."""
|
||||
|
||||
tibber: tibber.Tibber
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TibberDataAPIRuntimeData:
|
||||
"""Runtime data for Tibber Data API entries."""
|
||||
|
||||
session: OAuth2Session
|
||||
_client: tibber_data_api.TibberDataAPI | None = None
|
||||
|
||||
async def async_get_client(
|
||||
self, hass: HomeAssistant
|
||||
) -> tibber_data_api.TibberDataAPI:
|
||||
"""Return an authenticated Tibber Data API client."""
|
||||
await self.session.async_ensure_token_valid()
|
||||
token = self.session.token
|
||||
access_token = token.get(CONF_ACCESS_TOKEN)
|
||||
if not access_token:
|
||||
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
|
||||
if self._client is None:
|
||||
self._client = tibber_data_api.TibberDataAPI(
|
||||
access_token,
|
||||
websession=async_get_clientsession(hass),
|
||||
)
|
||||
self._client.set_access_token(access_token)
|
||||
return self._client
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Tibber component."""
|
||||
|
||||
hass.data[DATA_HASS_CONFIG] = config
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
async_setup_services(hass)
|
||||
|
||||
@@ -37,45 +87,100 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
api_type = entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
|
||||
|
||||
if api_type == API_TYPE_DATA_API:
|
||||
return await _async_setup_data_api_entry(hass, entry)
|
||||
|
||||
return await _async_setup_graphql_entry(hass, entry)
|
||||
|
||||
|
||||
async def _async_setup_graphql_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the legacy GraphQL Tibber entry."""
|
||||
|
||||
tibber_connection = tibber.Tibber(
|
||||
access_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
websession=async_get_clientsession(hass),
|
||||
time_zone=dt_util.get_default_time_zone(),
|
||||
ssl=ssl_util.get_default_context(),
|
||||
)
|
||||
hass.data[DOMAIN] = tibber_connection
|
||||
|
||||
async def _close(event: Event) -> None:
|
||||
runtime = TibberGraphQLRuntimeData(tibber_connection)
|
||||
entry.runtime_data = runtime
|
||||
hass.data[DOMAIN][API_TYPE_GRAPHQL] = runtime
|
||||
|
||||
async def _close(_event: Event) -> None:
|
||||
await tibber_connection.rt_disconnect()
|
||||
|
||||
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
|
||||
|
||||
try:
|
||||
await tibber_connection.update_info()
|
||||
|
||||
except (
|
||||
TimeoutError,
|
||||
aiohttp.ClientError,
|
||||
tibber.RetryableHttpExceptionError,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady("Unable to connect") from err
|
||||
except tibber.InvalidLoginError as exp:
|
||||
_LOGGER.error("Failed to login. %s", exp)
|
||||
except tibber.InvalidLoginError as err:
|
||||
_LOGGER.error("Failed to login to Tibber GraphQL API: %s", err)
|
||||
return False
|
||||
except tibber.FatalHttpExceptionError:
|
||||
except tibber.FatalHttpExceptionError as err:
|
||||
_LOGGER.error("Fatal error communicating with Tibber GraphQL API: %s", err)
|
||||
return False
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, GRAPHQL_PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_setup_data_api_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a Tibber Data API entry."""
|
||||
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauthentication required"
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
runtime = TibberDataAPIRuntimeData(session=session)
|
||||
entry.runtime_data = runtime
|
||||
hass.data[DOMAIN][API_TYPE_DATA_API] = runtime
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, DATA_API_PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
api_type = config_entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
config_entry,
|
||||
GRAPHQL_PLATFORMS if api_type == API_TYPE_GRAPHQL else DATA_API_PLATFORMS,
|
||||
)
|
||||
|
||||
if unload_ok:
|
||||
tibber_connection = hass.data[DOMAIN]
|
||||
await tibber_connection.rt_disconnect()
|
||||
if api_type == API_TYPE_GRAPHQL:
|
||||
runtime = hass.data[DOMAIN].get(api_type)
|
||||
if runtime:
|
||||
tibber_connection = runtime.tibber
|
||||
await tibber_connection.rt_disconnect()
|
||||
|
||||
hass.data[DOMAIN].pop(api_type, None)
|
||||
return unload_ok
|
||||
|
||||
15
homeassistant/components/tibber/application_credentials.py
Normal file
15
homeassistant/components/tibber/application_credentials.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Application credentials platform for Tibber."""
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
AUTHORIZE_URL = "https://thewall.tibber.com/connect/authorize"
|
||||
TOKEN_URL = "https://thewall.tibber.com/connect/token"
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server for Tibber Data API."""
|
||||
return AuthorizationServer(
|
||||
authorize_url=AUTHORIZE_URL,
|
||||
token_url=TOKEN_URL,
|
||||
)
|
||||
@@ -2,36 +2,118 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import tibber
|
||||
from tibber.data_api import TibberDataAPI
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
AbstractOAuth2FlowHandler,
|
||||
async_get_config_entry_implementation,
|
||||
async_get_implementations,
|
||||
)
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
API_TYPE_DATA_API,
|
||||
API_TYPE_GRAPHQL,
|
||||
CONF_API_TYPE,
|
||||
DATA_API_DEFAULT_SCOPES,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
TYPE_SELECTOR = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_TYPE, default=API_TYPE_GRAPHQL): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[API_TYPE_GRAPHQL, API_TYPE_DATA_API],
|
||||
translation_key="api_type",
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
GRAPHQL_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
|
||||
|
||||
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
|
||||
ERR_TIMEOUT = "timeout"
|
||||
ERR_CLIENT = "cannot_connect"
|
||||
ERR_TOKEN = "invalid_access_token"
|
||||
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
|
||||
DATA_API_DOC_URL = "https://data-api.tibber.com/docs/auth/"
|
||||
APPLICATION_CREDENTIALS_DOC_URL = (
|
||||
"https://www.home-assistant.io/integrations/application_credentials/"
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Handle a config flow for Tibber integration."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self._api_type: str | None = None
|
||||
self._data_api_home_ids: list[str] = []
|
||||
self._data_api_user_sub: str | None = None
|
||||
self._reauth_confirmed: bool = False
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return the logger."""
|
||||
return _LOGGER
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data appended to the authorize URL."""
|
||||
if self._api_type != API_TYPE_DATA_API:
|
||||
return super().extra_authorize_data
|
||||
return {
|
||||
**super().extra_authorize_data,
|
||||
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
|
||||
}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
|
||||
self._async_abort_entries_match()
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=TYPE_SELECTOR,
|
||||
description_placeholders={"url": DATA_API_DOC_URL},
|
||||
)
|
||||
|
||||
self._api_type = user_input[CONF_API_TYPE]
|
||||
|
||||
if self._api_type == API_TYPE_GRAPHQL:
|
||||
return await self.async_step_graphql()
|
||||
|
||||
return await self.async_step_data_api()
|
||||
|
||||
async def async_step_graphql(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle GraphQL token based configuration."""
|
||||
|
||||
if self.source != SOURCE_REAUTH:
|
||||
for entry in self._async_current_entries(include_ignore=False):
|
||||
if entry.entry_id == self.context.get("entry_id"):
|
||||
continue
|
||||
if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_GRAPHQL:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
if user_input is not None:
|
||||
access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "")
|
||||
@@ -58,24 +140,146 @@ class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
step_id="graphql",
|
||||
data_schema=GRAPHQL_SCHEMA,
|
||||
description_placeholders={"url": TOKEN_URL},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
unique_id = tibber_connection.user_id
|
||||
await self.async_set_unique_id(unique_id)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={
|
||||
CONF_API_TYPE: API_TYPE_GRAPHQL,
|
||||
CONF_ACCESS_TOKEN: access_token,
|
||||
},
|
||||
title=tibber_connection.name,
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
data = {
|
||||
CONF_API_TYPE: API_TYPE_GRAPHQL,
|
||||
CONF_ACCESS_TOKEN: access_token,
|
||||
}
|
||||
|
||||
return self.async_create_entry(
|
||||
title=tibber_connection.name,
|
||||
data={CONF_ACCESS_TOKEN: access_token},
|
||||
data=data,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
step_id="graphql",
|
||||
data_schema=GRAPHQL_SCHEMA,
|
||||
description_placeholders={"url": TOKEN_URL},
|
||||
errors={},
|
||||
)
|
||||
|
||||
async def async_step_data_api(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the Data API OAuth configuration."""
|
||||
|
||||
implementations = await async_get_implementations(self.hass, self.DOMAIN)
|
||||
if not implementations:
|
||||
return self.async_abort(
|
||||
reason="missing_credentials",
|
||||
description_placeholders={
|
||||
"application_credentials_url": APPLICATION_CREDENTIALS_DOC_URL,
|
||||
"data_api_url": DATA_API_DOC_URL,
|
||||
},
|
||||
)
|
||||
|
||||
if self.source != SOURCE_REAUTH:
|
||||
for entry in self._async_current_entries(include_ignore=False):
|
||||
if entry.entry_id == self.context.get("entry_id"):
|
||||
continue
|
||||
if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_DATA_API:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
return await self.async_step_pick_implementation(user_input)
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Finalize the OAuth flow and create the config entry."""
|
||||
|
||||
assert self._api_type == API_TYPE_DATA_API
|
||||
|
||||
token: dict[str, Any] = data["token"]
|
||||
|
||||
client = TibberDataAPI(
|
||||
token[CONF_ACCESS_TOKEN],
|
||||
websession=async_get_clientsession(self.hass),
|
||||
)
|
||||
|
||||
try:
|
||||
userinfo = await client.get_userinfo()
|
||||
except (
|
||||
tibber.InvalidLoginError,
|
||||
tibber.FatalHttpExceptionError,
|
||||
) as err:
|
||||
self.logger.error("Authentication failed against Data API: %s", err)
|
||||
return self.async_abort(reason="oauth_invalid_token")
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
self.logger.error("Error retrieving homes via Data API: %s", err)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
unique_id = userinfo["email"]
|
||||
title = userinfo["email"]
|
||||
await self.async_set_unique_id(unique_id)
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._abort_if_unique_id_mismatch(
|
||||
reason="wrong_account",
|
||||
description_placeholders={"email": reauth_entry.unique_id or ""},
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_API_TYPE: API_TYPE_DATA_API,
|
||||
"auth_implementation": data["auth_implementation"],
|
||||
CONF_TOKEN: token,
|
||||
},
|
||||
title=title,
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
entry_data: dict[str, Any] = {
|
||||
CONF_API_TYPE: API_TYPE_DATA_API,
|
||||
"auth_implementation": data["auth_implementation"],
|
||||
CONF_TOKEN: token,
|
||||
}
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data=entry_data,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
|
||||
api_type = entry_data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
|
||||
self._api_type = api_type
|
||||
|
||||
if api_type == API_TYPE_DATA_API:
|
||||
self.flow_impl = await async_get_config_entry_implementation(
|
||||
self.hass, self._get_reauth_entry()
|
||||
)
|
||||
return await self.async_step_auth()
|
||||
|
||||
self.context["title_placeholders"] = {"name": self._get_reauth_entry().title}
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the reauth dialog for GraphQL entries."""
|
||||
if user_input is None and not self._reauth_confirmed:
|
||||
self._reauth_confirmed = True
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
|
||||
return await self.async_step_graphql()
|
||||
|
||||
@@ -3,3 +3,19 @@
|
||||
DATA_HASS_CONFIG = "tibber_hass_config"
|
||||
DOMAIN = "tibber"
|
||||
MANUFACTURER = "Tibber"
|
||||
CONF_API_TYPE = "api_type"
|
||||
API_TYPE_GRAPHQL = "graphql"
|
||||
API_TYPE_DATA_API = "data_api"
|
||||
DATA_API_DEFAULT_SCOPES = [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access",
|
||||
"data-api-user-read",
|
||||
"data-api-chargers-read",
|
||||
"data-api-energy-systems-read",
|
||||
"data-api-homes-read",
|
||||
"data-api-thermostats-read",
|
||||
"data-api-vehicles-read",
|
||||
"data-api-inverters-read",
|
||||
]
|
||||
|
||||
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
import tibber
|
||||
from tibber.data_api import TibberDataAPI, TibberDevice
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.models import (
|
||||
@@ -22,6 +24,7 @@ from homeassistant.components.recorder.statistics import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfEnergy
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import EnergyConverter
|
||||
@@ -187,3 +190,48 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
|
||||
unit_of_measurement=unit,
|
||||
)
|
||||
async_add_external_statistics(self.hass, metadata, statistics)
|
||||
|
||||
|
||||
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
|
||||
"""Fetch and cache Tibber Data API device capabilities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
runtime_data: Any,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN} Data API",
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=entry,
|
||||
)
|
||||
self._runtime_data = runtime_data
|
||||
|
||||
async def _async_get_client(self) -> TibberDataAPI:
|
||||
"""Get the Tibber Data API client with error handling."""
|
||||
try:
|
||||
return cast(
|
||||
TibberDataAPI,
|
||||
await self._runtime_data.async_get_client(self.hass),
|
||||
)
|
||||
except ConfigEntryAuthFailed:
|
||||
raise
|
||||
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
|
||||
raise UpdateFailed(
|
||||
f"Unable to create Tibber Data API client: {err}"
|
||||
) from err
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Initial load of Tibber Data API devices."""
|
||||
client = await self._async_get_client()
|
||||
self.data = await client.get_all_devices()
|
||||
|
||||
async def _async_update_data(self) -> dict[str, TibberDevice]:
|
||||
"""Fetch the latest device capabilities from the Tibber Data API."""
|
||||
client = await self._async_get_client()
|
||||
devices: dict[str, TibberDevice] = await client.update_devices()
|
||||
return devices
|
||||
|
||||
@@ -4,29 +4,80 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import tibber
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import API_TYPE_DATA_API, API_TYPE_GRAPHQL, CONF_API_TYPE, DOMAIN
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
tibber_connection: tibber.Tibber = hass.data[DOMAIN]
|
||||
|
||||
api_type = config_entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL)
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
|
||||
if api_type == API_TYPE_GRAPHQL:
|
||||
tibber_connection: tibber.Tibber = domain_data[API_TYPE_GRAPHQL].tibber
|
||||
return {
|
||||
"api_type": API_TYPE_GRAPHQL,
|
||||
"homes": [
|
||||
{
|
||||
"last_data_timestamp": home.last_data_timestamp,
|
||||
"has_active_subscription": home.has_active_subscription,
|
||||
"has_real_time_consumption": home.has_real_time_consumption,
|
||||
"last_cons_data_timestamp": home.last_cons_data_timestamp,
|
||||
"country": home.country,
|
||||
}
|
||||
for home in tibber_connection.get_homes(only_active=False)
|
||||
],
|
||||
}
|
||||
|
||||
runtime = domain_data.get(API_TYPE_DATA_API)
|
||||
if runtime is None:
|
||||
return {
|
||||
"api_type": API_TYPE_DATA_API,
|
||||
"devices": [],
|
||||
}
|
||||
|
||||
devices: dict[str, Any] = {}
|
||||
error: str | None = None
|
||||
try:
|
||||
devices = await (await runtime.async_get_client(hass)).get_all_devices()
|
||||
except ConfigEntryAuthFailed:
|
||||
devices = {}
|
||||
error = "Authentication failed"
|
||||
except TimeoutError:
|
||||
devices = {}
|
||||
error = "Timeout error"
|
||||
except aiohttp.ClientError:
|
||||
devices = {}
|
||||
error = "Client error"
|
||||
except tibber.InvalidLoginError:
|
||||
devices = {}
|
||||
error = "Invalid login"
|
||||
except tibber.RetryableHttpExceptionError as err:
|
||||
devices = {}
|
||||
error = f"Retryable HTTP error ({err.status})"
|
||||
except tibber.FatalHttpExceptionError as err:
|
||||
devices = {}
|
||||
error = f"Fatal HTTP error ({err.status})"
|
||||
|
||||
return {
|
||||
"homes": [
|
||||
"api_type": API_TYPE_DATA_API,
|
||||
"error": error,
|
||||
"devices": [
|
||||
{
|
||||
"last_data_timestamp": home.last_data_timestamp,
|
||||
"has_active_subscription": home.has_active_subscription,
|
||||
"has_real_time_consumption": home.has_real_time_consumption,
|
||||
"last_cons_data_timestamp": home.last_cons_data_timestamp,
|
||||
"country": home.country,
|
||||
"id": device.id,
|
||||
"name": device.name,
|
||||
"brand": device.brand,
|
||||
"model": device.model,
|
||||
}
|
||||
for home in tibber_connection.get_homes(only_active=False)
|
||||
]
|
||||
for device in devices.values()
|
||||
],
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"name": "Tibber",
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["recorder"],
|
||||
"dependencies": ["application_credentials", "recorder"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/tibber",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"requirements": ["pyTibber==0.32.2"]
|
||||
"requirements": ["pyTibber==0.33.1"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN
|
||||
from .const import API_TYPE_GRAPHQL, DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -39,7 +39,7 @@ class TibberNotificationEntity(NotifyEntity):
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message to Tibber devices."""
|
||||
tibber_connection: Tibber = self.hass.data[DOMAIN]
|
||||
tibber_connection: Tibber = self.hass.data[DOMAIN][API_TYPE_GRAPHQL].tibber
|
||||
try:
|
||||
await tibber_connection.send_notification(
|
||||
title or ATTR_TITLE_DEFAULT, message
|
||||
|
||||
@@ -10,7 +10,8 @@ from random import randrange
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import tibber
|
||||
from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome
|
||||
from tibber.data_api import TibberDevice
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -27,6 +28,7 @@ from homeassistant.const import (
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfLength,
|
||||
UnitOfPower,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
@@ -41,8 +43,14 @@ from homeassistant.helpers.update_coordinator import (
|
||||
)
|
||||
from homeassistant.util import Throttle, dt as dt_util
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import TibberDataCoordinator
|
||||
from .const import (
|
||||
API_TYPE_DATA_API,
|
||||
API_TYPE_GRAPHQL,
|
||||
CONF_API_TYPE,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
)
|
||||
from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -260,6 +268,58 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="storage.stateOfCharge",
|
||||
translation_key="storage_state_of_charge",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="storage.targetStateOfCharge",
|
||||
translation_key="storage_target_state_of_charge",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="connector.status",
|
||||
translation_key="connector_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["connected", "disconnected", "unknown"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="charging.status",
|
||||
translation_key="charging_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["charging", "idle", "unknown"],
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="range.remaining",
|
||||
translation_key="range_remaining",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="charging.current.max",
|
||||
translation_key="charging_current_max",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="charging.current.offlineFallback",
|
||||
translation_key="charging_current_offline_fallback",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
@@ -267,7 +327,11 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Tibber sensor."""
|
||||
|
||||
tibber_connection = hass.data[DOMAIN]
|
||||
if entry.data.get(CONF_API_TYPE, API_TYPE_GRAPHQL) == API_TYPE_DATA_API:
|
||||
await _async_setup_data_api_sensors(hass, entry, async_add_entities)
|
||||
return
|
||||
|
||||
tibber_connection = hass.data[DOMAIN][API_TYPE_GRAPHQL].tibber
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
@@ -280,7 +344,11 @@ async def async_setup_entry(
|
||||
except TimeoutError as err:
|
||||
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
|
||||
raise PlatformNotReady from err
|
||||
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err:
|
||||
except (
|
||||
RetryableHttpExceptionError,
|
||||
FatalHttpExceptionError,
|
||||
aiohttp.ClientError,
|
||||
) as err:
|
||||
_LOGGER.error("Error connecting to Tibber home: %s ", err)
|
||||
raise PlatformNotReady from err
|
||||
|
||||
@@ -328,14 +396,94 @@ async def async_setup_entry(
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
async def _async_setup_data_api_sensors(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensors backed by the Tibber Data API."""
|
||||
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
runtime = domain_data[API_TYPE_DATA_API]
|
||||
|
||||
coordinator = TibberDataAPICoordinator(hass, entry, runtime)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entities: list[TibberDataAPISensor] = []
|
||||
api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS}
|
||||
|
||||
for device in coordinator.data.values():
|
||||
for sensor in device.sensors:
|
||||
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
|
||||
if description is None:
|
||||
_LOGGER.error("Sensor %s not found", sensor)
|
||||
continue
|
||||
entities.append(
|
||||
TibberDataAPISensor(
|
||||
coordinator, device, description, sensor.description
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity):
|
||||
"""Representation of a Tibber Data API capability sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TibberDataAPICoordinator,
|
||||
device: TibberDevice,
|
||||
entity_description: SensorEntityDescription,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._device_id: str = device.id
|
||||
self.entity_description = entity_description
|
||||
self._attr_name = name
|
||||
|
||||
self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.external_id)},
|
||||
name=device.name,
|
||||
manufacturer=device.brand,
|
||||
model=device.model,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(
|
||||
self,
|
||||
) -> StateType:
|
||||
"""Return the value reported by the device."""
|
||||
device = self.coordinator.data.get(self._device_id)
|
||||
if device is None:
|
||||
return None
|
||||
|
||||
for sensor in device.sensors:
|
||||
if sensor.id == self.entity_description.key:
|
||||
return sensor.value
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether the sensor is available."""
|
||||
device = self.coordinator.data.get(self._device_id)
|
||||
if device is None:
|
||||
return False
|
||||
return self.native_value is not None
|
||||
|
||||
|
||||
class TibberSensor(SensorEntity):
|
||||
"""Representation of a generic Tibber sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any
|
||||
) -> None:
|
||||
def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._tibber_home = tibber_home
|
||||
@@ -366,7 +514,7 @@ class TibberSensorElPrice(TibberSensor):
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_translation_key = "electricity_price"
|
||||
|
||||
def __init__(self, tibber_home: tibber.TibberHome) -> None:
|
||||
def __init__(self, tibber_home: TibberHome) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(tibber_home=tibber_home)
|
||||
self._last_updated: datetime.datetime | None = None
|
||||
@@ -443,7 +591,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tibber_home: tibber.TibberHome,
|
||||
tibber_home: TibberHome,
|
||||
coordinator: TibberDataCoordinator,
|
||||
entity_description: SensorEntityDescription,
|
||||
) -> None:
|
||||
@@ -470,7 +618,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"])
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tibber_home: tibber.TibberHome,
|
||||
tibber_home: TibberHome,
|
||||
description: SensorEntityDescription,
|
||||
initial_state: float,
|
||||
coordinator: TibberRtDataCoordinator,
|
||||
@@ -532,7 +680,7 @@ class TibberRtEntityCreator:
|
||||
def __init__(
|
||||
self,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
tibber_home: tibber.TibberHome,
|
||||
tibber_home: TibberHome,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Initialize the data handler."""
|
||||
@@ -618,7 +766,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
|
||||
tibber_home: tibber.TibberHome,
|
||||
tibber_home: TibberHome,
|
||||
) -> None:
|
||||
"""Initialize the data handler."""
|
||||
self._add_sensor_callback = add_sensor_callback
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import (
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import API_TYPE_GRAPHQL, DOMAIN
|
||||
|
||||
PRICE_SERVICE_NAME = "get_prices"
|
||||
ATTR_START: Final = "start"
|
||||
@@ -33,7 +33,15 @@ SERVICE_SCHEMA: Final = vol.Schema(
|
||||
|
||||
|
||||
async def __get_prices(call: ServiceCall) -> ServiceResponse:
|
||||
tibber_connection = call.hass.data[DOMAIN]
|
||||
domain_data = call.hass.data.get(DOMAIN, {})
|
||||
runtime = domain_data.get(API_TYPE_GRAPHQL)
|
||||
if runtime is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="graphql_required",
|
||||
)
|
||||
|
||||
tibber_connection = runtime.tibber
|
||||
|
||||
start = __get_date(call.data.get(ATTR_START), "start")
|
||||
end = __get_date(call.data.get(ATTR_END), "end")
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"missing_credentials": "Add Tibber Data API application credentials under application credentials before continuing. See {application_credentials_url} for guidance and {data_api_url} for API documentation.",
|
||||
"oauth_invalid_token": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"wrong_account": "The connected account does not match {email}. Sign in with the same Tibber account and try again."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,11 +15,21 @@
|
||||
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"graphql": {
|
||||
"data": {
|
||||
"access_token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"description": "Enter your access token from {url}"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Reconnect your Tibber account to refresh access.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_type": "API type"
|
||||
},
|
||||
"description": "Select which Tibber API you want to configure. See {url} for documentation."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -40,6 +56,37 @@
|
||||
"average_power": {
|
||||
"name": "Average power"
|
||||
},
|
||||
"battery_battery_power": {
|
||||
"name": "Battery power"
|
||||
},
|
||||
"battery_battery_state_of_charge": {
|
||||
"name": "Battery state of charge"
|
||||
},
|
||||
"battery_stored_energy": {
|
||||
"name": "Stored energy"
|
||||
},
|
||||
"charging_current_max": {
|
||||
"name": "Maximum charging current"
|
||||
},
|
||||
"charging_current_offline_fallback": {
|
||||
"name": "Offline fallback charging current"
|
||||
},
|
||||
"charging_status": {
|
||||
"name": "Charging status",
|
||||
"state": {
|
||||
"charging": "Charging",
|
||||
"idle": "Idle",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"connector_status": {
|
||||
"name": "Connector status",
|
||||
"state": {
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"current_l1": {
|
||||
"name": "Current L1"
|
||||
},
|
||||
@@ -55,6 +102,30 @@
|
||||
"estimated_hour_consumption": {
|
||||
"name": "Estimated consumption current hour"
|
||||
},
|
||||
"ev_charger_charge_current": {
|
||||
"name": "Charge current"
|
||||
},
|
||||
"ev_charger_charging_state": {
|
||||
"name": "Charging state"
|
||||
},
|
||||
"ev_charger_power": {
|
||||
"name": "Charging power"
|
||||
},
|
||||
"ev_charger_session_energy": {
|
||||
"name": "Session energy"
|
||||
},
|
||||
"ev_charger_total_energy": {
|
||||
"name": "Total energy"
|
||||
},
|
||||
"heat_pump_measured_temperature": {
|
||||
"name": "Measured temperature"
|
||||
},
|
||||
"heat_pump_operation_mode": {
|
||||
"name": "Operation mode"
|
||||
},
|
||||
"heat_pump_target_temperature": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
"last_meter_consumption": {
|
||||
"name": "Last meter consumption"
|
||||
},
|
||||
@@ -88,9 +159,33 @@
|
||||
"power_production": {
|
||||
"name": "Power production"
|
||||
},
|
||||
"range_remaining": {
|
||||
"name": "Remaining range"
|
||||
},
|
||||
"signal_strength": {
|
||||
"name": "Signal strength"
|
||||
},
|
||||
"solar_power": {
|
||||
"name": "Solar power"
|
||||
},
|
||||
"solar_power_production": {
|
||||
"name": "Power production"
|
||||
},
|
||||
"storage_state_of_charge": {
|
||||
"name": "Storage state of charge"
|
||||
},
|
||||
"storage_target_state_of_charge": {
|
||||
"name": "Storage target state of charge"
|
||||
},
|
||||
"thermostat_measured_temperature": {
|
||||
"name": "Measured temperature"
|
||||
},
|
||||
"thermostat_operation_mode": {
|
||||
"name": "Operation mode"
|
||||
},
|
||||
"thermostat_target_temperature": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
"voltage_phase1": {
|
||||
"name": "Voltage phase1"
|
||||
},
|
||||
@@ -103,13 +198,27 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"graphql_required": {
|
||||
"message": "Configure the Tibber GraphQL API before calling this service."
|
||||
},
|
||||
"invalid_date": {
|
||||
"message": "Invalid datetime provided {date}"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"send_message_timeout": {
|
||||
"message": "Timeout sending message with Tibber"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"api_type": {
|
||||
"options": {
|
||||
"data_api": "Data API (OAuth2)",
|
||||
"graphql": "GraphQL API (access token)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_prices": {
|
||||
"description": "Fetches hourly energy prices including price level.",
|
||||
|
||||
@@ -22,6 +22,7 @@ ATTR_FOLLOW_SINCE = "following_since"
|
||||
ATTR_FOLLOWING = "followers"
|
||||
ATTR_VIEWERS = "viewers"
|
||||
ATTR_STARTED_AT = "started_at"
|
||||
ATTR_CHANNEL_PICTURE = "channel_picture"
|
||||
|
||||
STATE_OFFLINE = "offline"
|
||||
STATE_STREAMING = "streaming"
|
||||
@@ -82,6 +83,7 @@ class TwitchSensor(CoordinatorEntity[TwitchCoordinator], SensorEntity):
|
||||
ATTR_STARTED_AT: channel.started_at,
|
||||
ATTR_VIEWERS: channel.viewers,
|
||||
ATTR_SUBSCRIPTION: False,
|
||||
ATTR_CHANNEL_PICTURE: channel.picture,
|
||||
}
|
||||
if channel.subscribed is not None:
|
||||
resp[ATTR_SUBSCRIPTION] = channel.subscribed
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.core import (
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.location import find_coordinates
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
@@ -47,6 +46,7 @@ from .const import (
|
||||
VEHICLE_TYPES,
|
||||
)
|
||||
from .coordinator import WazeTravelTimeCoordinator, async_get_travel_times
|
||||
from .httpx_client import create_httpx_client
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
@@ -106,7 +106,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}):
|
||||
hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1)
|
||||
|
||||
httpx_client = get_async_client(hass)
|
||||
httpx_client = await create_httpx_client(hass)
|
||||
|
||||
client = WazeRouteCalculator(
|
||||
region=config_entry.data[CONF_REGION].upper(), client=httpx_client
|
||||
)
|
||||
@@ -119,7 +120,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
async def async_get_travel_times_service(service: ServiceCall) -> ServiceResponse:
|
||||
httpx_client = get_async_client(hass)
|
||||
httpx_client = await create_httpx_client(hass)
|
||||
client = WazeRouteCalculator(
|
||||
region=service.data[CONF_REGION].upper(), client=httpx_client
|
||||
)
|
||||
|
||||
26
homeassistant/components/waze_travel_time/httpx_client.py
Normal file
26
homeassistant/components/waze_travel_time/httpx_client.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Special httpx client for Waze Travel Time integration."""
|
||||
|
||||
import httpx
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import create_async_httpx_client
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
DATA_HTTPX_ASYNC_CLIENT: HassKey[httpx.AsyncClient] = HassKey("httpx_async_client")
|
||||
|
||||
|
||||
def create_transport() -> httpx.AsyncHTTPTransport:
|
||||
"""Create a httpx transport which enforces the use of IPv4."""
|
||||
return httpx.AsyncHTTPTransport(local_address="0.0.0.0")
|
||||
|
||||
|
||||
async def create_httpx_client(hass: HomeAssistant) -> httpx.AsyncClient:
|
||||
"""Create a httpx client which enforces the use of IPv4."""
|
||||
if (client := hass.data[DOMAIN].get(DATA_HTTPX_ASYNC_CLIENT)) is None:
|
||||
transport = await hass.async_add_executor_job(create_transport)
|
||||
client = hass.data[DOMAIN][DATA_HTTPX_ASYNC_CLIENT] = create_async_httpx_client(
|
||||
hass, transport=transport
|
||||
)
|
||||
return client
|
||||
@@ -99,10 +99,13 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Handle updating _attr values."""
|
||||
state = self._device.state
|
||||
color_modes = self.supported_color_modes
|
||||
assert color_modes is not None
|
||||
|
||||
if (brightness := state.get_brightness()) is not None:
|
||||
self._attr_brightness = max(0, min(255, brightness))
|
||||
|
||||
color_modes = self.supported_color_modes
|
||||
assert color_modes is not None
|
||||
|
||||
if ColorMode.COLOR_TEMP in color_modes and (
|
||||
color_temp := state.get_colortemp()
|
||||
):
|
||||
@@ -111,12 +114,19 @@ class WizBulbEntity(WizToggleEntity, LightEntity):
|
||||
elif (
|
||||
ColorMode.RGBWW in color_modes and (rgbww := state.get_rgbww()) is not None
|
||||
):
|
||||
self._attr_rgbww_color = rgbww
|
||||
self._attr_color_mode = ColorMode.RGBWW
|
||||
self._attr_rgbww_color = rgbww
|
||||
elif ColorMode.RGBW in color_modes and (rgbw := state.get_rgbw()) is not None:
|
||||
self._attr_rgbw_color = rgbw
|
||||
self._attr_color_mode = ColorMode.RGBW
|
||||
self._attr_effect = state.get_scene()
|
||||
self._attr_rgbw_color = rgbw
|
||||
|
||||
self._attr_effect = effect = state.get_scene()
|
||||
if effect is not None:
|
||||
if brightness is not None:
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
else:
|
||||
self._attr_color_mode = ColorMode.ONOFF
|
||||
|
||||
super()._async_update_attrs()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -28,6 +28,7 @@ from homeassistant.components.hassio import (
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_ESPHOME,
|
||||
SOURCE_USB,
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
@@ -1516,6 +1517,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="not_hassio")
|
||||
|
||||
if discovery_info.zwave_home_id:
|
||||
existing_entry: ConfigEntry | None = None
|
||||
if (
|
||||
(
|
||||
current_config_entries := self._async_current_entries(
|
||||
@@ -1533,26 +1535,30 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
None,
|
||||
)
|
||||
)
|
||||
# Only update existing entries that are configured via sockets
|
||||
and existing_entry.data.get(CONF_SOCKET_PATH)
|
||||
# And use the add-on
|
||||
and existing_entry.data.get(CONF_USE_ADDON)
|
||||
):
|
||||
manager = get_addon_manager(self.hass)
|
||||
await self._async_set_addon_config(
|
||||
{CONF_ADDON_SOCKET: discovery_info.socket_path}
|
||||
)
|
||||
if self.restart_addon:
|
||||
await manager.async_stop_addon()
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry,
|
||||
data={
|
||||
**existing_entry.data,
|
||||
CONF_SOCKET_PATH: discovery_info.socket_path,
|
||||
},
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
|
||||
return self.async_abort(reason="already_configured")
|
||||
# We can't migrate entries that are not using the add-on
|
||||
if not existing_entry.data.get(CONF_USE_ADDON):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Only update config automatically if using socket
|
||||
if existing_entry.data.get(CONF_SOCKET_PATH):
|
||||
manager = get_addon_manager(self.hass)
|
||||
await self._async_set_addon_config(
|
||||
{CONF_ADDON_SOCKET: discovery_info.socket_path}
|
||||
)
|
||||
if self.restart_addon:
|
||||
await manager.async_stop_addon()
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry,
|
||||
data={
|
||||
**existing_entry.data,
|
||||
CONF_SOCKET_PATH: discovery_info.socket_path,
|
||||
},
|
||||
)
|
||||
self.hass.config_entries.async_schedule_reload(
|
||||
existing_entry.entry_id
|
||||
)
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# We are not aborting if home ID configured here, we just want to make sure that it's set
|
||||
# We will update a USB based config entry automatically in `async_step_finish_addon_setup_user`
|
||||
|
||||
@@ -37,6 +37,7 @@ APPLICATION_CREDENTIALS = [
|
||||
"smartthings",
|
||||
"spotify",
|
||||
"tesla_fleet",
|
||||
"tibber",
|
||||
"twitch",
|
||||
"volvo",
|
||||
"weheat",
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -157,6 +157,7 @@ FLOWS = {
|
||||
"droplet",
|
||||
"dsmr",
|
||||
"dsmr_reader",
|
||||
"duckdns",
|
||||
"duke_energy",
|
||||
"dunehd",
|
||||
"duotecno",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user