mirror of
https://github.com/home-assistant/core.git
synced 2025-11-25 10:37:59 +00:00
Compare commits
55 Commits
ble_provis
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15328a4aff | ||
|
|
083cfb89af | ||
|
|
bd129c2085 | ||
|
|
f73bc9242b | ||
|
|
4506be5065 | ||
|
|
80c611e562 | ||
|
|
b44aafc294 | ||
|
|
af1e3205b8 | ||
|
|
1360fe7f23 | ||
|
|
b5bb8583f8 | ||
|
|
9b62c212ce | ||
|
|
8fa56ad92e | ||
|
|
f82f0a1862 | ||
|
|
878881b100 | ||
|
|
743583d9bd | ||
|
|
f537204d22 | ||
|
|
ec74be7922 | ||
|
|
3574f647d0 | ||
|
|
6c4296a0de | ||
|
|
e780e3db8c | ||
|
|
4ed2efa4e8 | ||
|
|
abef6f7b3e | ||
|
|
5556fb99e6 | ||
|
|
16669e39bd | ||
|
|
ca088d81c3 | ||
|
|
12847fb0a4 | ||
|
|
8b758c46f4 | ||
|
|
f439471dc1 | ||
|
|
5ff3233b09 | ||
|
|
22daed083f | ||
|
|
13384de464 | ||
|
|
f5e5183190 | ||
|
|
e18668b8f9 | ||
|
|
15647f2720 | ||
|
|
c961126ee5 | ||
|
|
5142c5f418 | ||
|
|
3d459704e1 | ||
|
|
5a8ddcd0b3 | ||
|
|
2667a40b92 | ||
|
|
08baa99691 | ||
|
|
d84cf26f40 | ||
|
|
ba5472da90 | ||
|
|
e20b88a54f | ||
|
|
ac69712a51 | ||
|
|
f0e75ba0ed | ||
|
|
e64598e7f5 | ||
|
|
e6f9a8e7d6 | ||
|
|
1e8b42f843 | ||
|
|
430eee0b28 | ||
|
|
b4799aa7ea | ||
|
|
ab45460069 | ||
|
|
c8fd6db3ff | ||
|
|
0a9f200ca4 | ||
|
|
8591335660 | ||
|
|
c01089e994 |
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
@@ -417,7 +417,7 @@ jobs:
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -257,7 +257,7 @@ jobs:
|
||||
- &setup-python-default
|
||||
name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: &actions-setup-python actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -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
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
|
||||
2
.github/workflows/wheels.yml
vendored
2
.github/workflows/wheels.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
check-latest: true
|
||||
|
||||
@@ -94,7 +94,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$
|
||||
files: ^(script/hassfest/(metadata|docker)\.py|homeassistant/const\.py$|pyproject\.toml)$
|
||||
- id: hassfest-mypy-config
|
||||
name: hassfest-mypy-config
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config
|
||||
|
||||
21
Dockerfile
generated
21
Dockerfile
generated
@@ -15,21 +15,14 @@ ARG QEMU_CPU
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
# Needs to be redefined inside the FROM statement to be set for RUN commands
|
||||
ARG BUILD_ARCH
|
||||
# Get go2rtc binary
|
||||
RUN \
|
||||
case "${BUILD_ARCH}" in \
|
||||
"aarch64") go2rtc_suffix='arm64' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
# Add go2rtc binary
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.9.6
|
||||
RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
# Install uv
|
||||
&& pip3 install uv==0.9.6
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==9.0.2"]
|
||||
"requirements": ["aioamazondevices==9.0.3"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -73,6 +74,8 @@ class BangOlufsenModel(StrEnum):
|
||||
BEOSOUND_LEVEL = "Beosound Level"
|
||||
BEOSOUND_PREMIERE = "Beosound Premiere"
|
||||
BEOSOUND_THEATRE = "Beosound Theatre"
|
||||
# Remote devices
|
||||
BEOREMOTE_ONE = "Beoremote One"
|
||||
|
||||
|
||||
# Physical "buttons" on devices
|
||||
@@ -96,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"
|
||||
@@ -113,6 +117,7 @@ class WebsocketNotification(StrEnum):
|
||||
BEOLINK_AVAILABLE_LISTENERS = "beolinkAvailableListeners"
|
||||
CONFIGURATION = "configuration"
|
||||
NOTIFICATION = "notification"
|
||||
REMOTE_CONTROL_DEVICES = "remoteControlDevices"
|
||||
REMOTE_MENU_CHANGED = "remoteMenuChanged"
|
||||
|
||||
ALL = "all"
|
||||
@@ -128,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"
|
||||
@@ -227,6 +236,10 @@ 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",
|
||||
@@ -247,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",
|
||||
|
||||
@@ -2,16 +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 CONNECTION_STATUS, DEVICE_BUTTON_EVENTS, WebsocketNotification
|
||||
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,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
BangOlufsenModel,
|
||||
WebsocketNotification,
|
||||
)
|
||||
from .entity import BangOlufsenEntity
|
||||
from .util import get_device_buttons
|
||||
from .util import get_device_buttons, get_remotes
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -21,24 +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] = []
|
||||
|
||||
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)
|
||||
|
||||
class BangOlufsenButtonEvent(BangOlufsenEntity, EventEntity):
|
||||
"""Event class for Button events."""
|
||||
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)
|
||||
]
|
||||
)
|
||||
|
||||
# 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}"
|
||||
|
||||
@@ -52,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,6 +2,11 @@
|
||||
|
||||
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
|
||||
@@ -23,6 +28,18 @@ def get_serial_number_from_jid(jid: str) -> str:
|
||||
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()
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.5.1"],
|
||||
"requirements": ["hass-nabucasa==1.6.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator, AsyncIterable, Callable, Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
@@ -16,14 +17,18 @@ import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import chat_session, frame, intent, llm, template
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import trace
|
||||
from .const import ChatLogEventType
|
||||
from .models import ConversationInput, ConversationResult
|
||||
|
||||
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
|
||||
|
||||
DATA_SUBSCRIPTIONS: HassKey[
|
||||
list[Callable[[str, ChatLogEventType, dict[str, Any]], None]]
|
||||
] = HassKey("conversation_chat_log_subscriptions")
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
@@ -31,6 +36,40 @@ current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_subscribe_chat_logs(
|
||||
hass: HomeAssistant,
|
||||
callback_func: Callable[[str, ChatLogEventType, dict[str, Any]], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to all chat logs."""
|
||||
subscriptions = hass.data.get(DATA_SUBSCRIPTIONS)
|
||||
if subscriptions is None:
|
||||
subscriptions = []
|
||||
hass.data[DATA_SUBSCRIPTIONS] = subscriptions
|
||||
|
||||
subscriptions.append(callback_func)
|
||||
|
||||
@callback
|
||||
def unsubscribe() -> None:
|
||||
"""Unsubscribe from chat logs."""
|
||||
subscriptions.remove(callback_func)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_subscribers(
|
||||
hass: HomeAssistant,
|
||||
conversation_id: str,
|
||||
event_type: ChatLogEventType,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Notify subscribers of a chat log event."""
|
||||
if subscriptions := hass.data.get(DATA_SUBSCRIPTIONS):
|
||||
for callback_func in subscriptions:
|
||||
callback_func(conversation_id, event_type, data)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def async_get_chat_log(
|
||||
hass: HomeAssistant,
|
||||
@@ -63,6 +102,8 @@ def async_get_chat_log(
|
||||
all_chat_logs = {}
|
||||
hass.data[DATA_CHAT_LOGS] = all_chat_logs
|
||||
|
||||
is_new_log = session.conversation_id not in all_chat_logs
|
||||
|
||||
if chat_log := all_chat_logs.get(session.conversation_id):
|
||||
chat_log = replace(chat_log, content=chat_log.content.copy())
|
||||
else:
|
||||
@@ -71,6 +112,15 @@ def async_get_chat_log(
|
||||
if chat_log_delta_listener:
|
||||
chat_log.delta_listener = chat_log_delta_listener
|
||||
|
||||
# Fire CREATED event for new chat logs before any content is added
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.CREATED,
|
||||
{"chat_log": chat_log.as_dict()},
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
chat_log.async_add_user_content(UserContent(content=user_input.text))
|
||||
|
||||
@@ -84,14 +134,28 @@ def async_get_chat_log(
|
||||
LOGGER.debug(
|
||||
"Chat Log opened but no assistant message was added, ignoring update"
|
||||
)
|
||||
# If this was a new log but nothing was added, fire DELETED to clean up
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.DELETED,
|
||||
{},
|
||||
)
|
||||
return
|
||||
|
||||
if session.conversation_id not in all_chat_logs:
|
||||
if is_new_log:
|
||||
|
||||
@callback
|
||||
def do_cleanup() -> None:
|
||||
"""Handle cleanup."""
|
||||
all_chat_logs.pop(session.conversation_id)
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.DELETED,
|
||||
{},
|
||||
)
|
||||
|
||||
session.async_on_cleanup(do_cleanup)
|
||||
|
||||
@@ -100,6 +164,16 @@ def async_get_chat_log(
|
||||
|
||||
all_chat_logs[session.conversation_id] = chat_log
|
||||
|
||||
# For new logs, CREATED was already fired before content was added
|
||||
# For existing logs, fire UPDATED
|
||||
if not is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
session.conversation_id,
|
||||
ChatLogEventType.UPDATED,
|
||||
{"chat_log": chat_log.as_dict()},
|
||||
)
|
||||
|
||||
|
||||
class ConverseError(HomeAssistantError):
|
||||
"""Error during initialization of conversation.
|
||||
@@ -129,6 +203,15 @@ class SystemContent:
|
||||
|
||||
role: Literal["system"] = field(init=False, default="system")
|
||||
content: str
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"created": self.created,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -138,6 +221,20 @@ class UserContent:
|
||||
role: Literal["user"] = field(init=False, default="user")
|
||||
content: str
|
||||
attachments: list[Attachment] | None = field(default=None)
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"created": self.created,
|
||||
}
|
||||
if self.attachments:
|
||||
result["attachments"] = [
|
||||
attachment.as_dict() for attachment in self.attachments
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -153,6 +250,14 @@ class Attachment:
|
||||
path: Path
|
||||
"""Path to the attachment on disk."""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the attachment."""
|
||||
return {
|
||||
"media_content_id": self.media_content_id,
|
||||
"mime_type": self.mime_type,
|
||||
"path": str(self.path),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantContent:
|
||||
@@ -164,6 +269,22 @@ class AssistantContent:
|
||||
thinking_content: str | None = None
|
||||
tool_calls: list[llm.ToolInput] | None = None
|
||||
native: Any = None
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {
|
||||
"role": self.role,
|
||||
"agent_id": self.agent_id,
|
||||
"created": self.created,
|
||||
}
|
||||
if self.content:
|
||||
result["content"] = self.content
|
||||
if self.thinking_content:
|
||||
result["thinking_content"] = self.thinking_content
|
||||
if self.tool_calls:
|
||||
result["tool_calls"] = self.tool_calls
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -175,6 +296,18 @@ class ToolResultContent:
|
||||
tool_call_id: str
|
||||
tool_name: str
|
||||
tool_result: JsonObjectType
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {
|
||||
"role": self.role,
|
||||
"agent_id": self.agent_id,
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"tool_name": self.tool_name,
|
||||
"tool_result": self.tool_result,
|
||||
"created": self.created,
|
||||
}
|
||||
|
||||
|
||||
type Content = SystemContent | UserContent | AssistantContent | ToolResultContent
|
||||
@@ -210,6 +343,16 @@ class ChatLog:
|
||||
llm_api: llm.APIInstance | None = None
|
||||
delta_listener: Callable[[ChatLog, dict], None] | None = None
|
||||
llm_input_provided_index = 0
|
||||
created: datetime = field(init=False, default_factory=utcnow)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the chat log."""
|
||||
return {
|
||||
"conversation_id": self.conversation_id,
|
||||
"continue_conversation": self.continue_conversation,
|
||||
"content": [c.as_dict() for c in self.content],
|
||||
"created": self.created,
|
||||
}
|
||||
|
||||
@property
|
||||
def continue_conversation(self) -> bool:
|
||||
@@ -241,6 +384,12 @@ class ChatLog:
|
||||
"""Add user content to the log."""
|
||||
LOGGER.debug("Adding user content: %s", content)
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"content": content.as_dict()},
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_add_assistant_content_without_tools(
|
||||
@@ -259,6 +408,12 @@ class ChatLog:
|
||||
):
|
||||
raise ValueError("Non-external tool calls not allowed")
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"content": content.as_dict()},
|
||||
)
|
||||
|
||||
async def async_add_assistant_content(
|
||||
self,
|
||||
@@ -317,6 +472,14 @@ class ChatLog:
|
||||
tool_result=tool_result,
|
||||
)
|
||||
self.content.append(response_content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{
|
||||
"content": response_content.as_dict(),
|
||||
},
|
||||
)
|
||||
yield response_content
|
||||
|
||||
async def async_add_delta_content_stream(
|
||||
@@ -593,6 +756,12 @@ class ChatLog:
|
||||
self.llm_api = llm_api
|
||||
self.extra_system_prompt = extra_system_prompt
|
||||
self.content[0] = SystemContent(content=prompt)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
self.conversation_id,
|
||||
ChatLogEventType.UPDATED,
|
||||
{"chat_log": self.as_dict()},
|
||||
)
|
||||
|
||||
LOGGER.debug("Prompt: %s", self.content)
|
||||
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntFlag
|
||||
from enum import IntFlag, StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
@@ -34,3 +34,13 @@ class ConversationEntityFeature(IntFlag):
|
||||
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
|
||||
|
||||
class ChatLogEventType(StrEnum):
|
||||
"""Chat log event type."""
|
||||
|
||||
INITIAL_STATE = "initial_state"
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
DELETED = "deleted"
|
||||
CONTENT_ADDED = "content_added"
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components import http, websocket_api
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||
from homeassistant.util import language as language_util
|
||||
|
||||
from .agent_manager import (
|
||||
@@ -20,7 +21,8 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .const import DATA_COMPONENT
|
||||
from .chat_log import DATA_CHAT_LOGS, async_get_chat_log, async_subscribe_chat_logs
|
||||
from .const import DATA_COMPONENT, ChatLogEventType
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput
|
||||
|
||||
@@ -35,6 +37,8 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_list_sentences)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_chat_log)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_chat_log_index)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -265,3 +269,114 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
)
|
||||
|
||||
return self.json(result.as_dict())
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/chat_log/subscribe",
|
||||
vol.Required("conversation_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
def websocket_subscribe_chat_log(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to a chat log."""
|
||||
msg_id = msg["id"]
|
||||
subscribed_conversation = msg["conversation_id"]
|
||||
|
||||
chat_logs = hass.data.get(DATA_CHAT_LOGS)
|
||||
|
||||
if not chat_logs or subscribed_conversation not in chat_logs:
|
||||
connection.send_error(
|
||||
msg_id,
|
||||
websocket_api.ERR_NOT_FOUND,
|
||||
"Conversation chat log not found",
|
||||
)
|
||||
return
|
||||
|
||||
@callback
|
||||
def forward_events(conversation_id: str, event_type: str, data: dict) -> None:
|
||||
"""Forward chat log events to websocket connection."""
|
||||
if conversation_id != subscribed_conversation:
|
||||
return
|
||||
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"conversation_id": conversation_id,
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
|
||||
if event_type == ChatLogEventType.DELETED:
|
||||
unsubscribe()
|
||||
del connection.subscriptions[msg_id]
|
||||
|
||||
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
|
||||
connection.subscriptions[msg_id] = unsubscribe
|
||||
connection.send_result(msg_id)
|
||||
|
||||
with (
|
||||
async_get_chat_session(hass, subscribed_conversation) as session,
|
||||
async_get_chat_log(hass, session) as chat_log,
|
||||
):
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"event_type": ChatLogEventType.INITIAL_STATE,
|
||||
"data": chat_log.as_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/chat_log/subscribe_index",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
def websocket_subscribe_chat_log_index(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to a chat log."""
|
||||
msg_id = msg["id"]
|
||||
|
||||
@callback
|
||||
def forward_events(
|
||||
conversation_id: str, event_type: ChatLogEventType, data: dict
|
||||
) -> None:
|
||||
"""Forward chat log events to websocket connection."""
|
||||
if event_type not in (ChatLogEventType.CREATED, ChatLogEventType.DELETED):
|
||||
return
|
||||
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"conversation_id": conversation_id,
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
|
||||
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
|
||||
connection.subscriptions[msg["id"]] = unsubscribe
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
chat_logs = hass.data.get(DATA_CHAT_LOGS)
|
||||
|
||||
if not chat_logs:
|
||||
return
|
||||
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"event_type": ChatLogEventType.INITIAL_STATE,
|
||||
"data": [c.as_dict() for c in chat_logs.values()],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.7"]
|
||||
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.24"]
|
||||
}
|
||||
|
||||
@@ -278,11 +278,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> boo
|
||||
for keypad in elk.keypads:
|
||||
keypad.add_callback(_keypad_changed)
|
||||
|
||||
sync_success = False
|
||||
try:
|
||||
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT):
|
||||
return False
|
||||
await ElkSyncWaiter(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT).async_wait()
|
||||
sync_success = True
|
||||
except LoginFailed:
|
||||
_LOGGER.error("ElkM1 login failed for %s", conf[CONF_HOST])
|
||||
return False
|
||||
except TimeoutError as exc:
|
||||
raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc
|
||||
finally:
|
||||
if not sync_success:
|
||||
elk.disconnect()
|
||||
|
||||
elk_temp_unit = elk.panel.temperature_units
|
||||
if elk_temp_unit == "C":
|
||||
@@ -321,48 +328,75 @@ async def async_unload_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bo
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_wait_for_elk_to_sync(
|
||||
elk: Elk,
|
||||
login_timeout: int,
|
||||
sync_timeout: int,
|
||||
) -> bool:
|
||||
"""Wait until the elk has finished sync. Can fail login or timeout."""
|
||||
class LoginFailed(Exception):
|
||||
"""Raised when login to ElkM1 fails."""
|
||||
|
||||
sync_event = asyncio.Event()
|
||||
login_event = asyncio.Event()
|
||||
|
||||
success = True
|
||||
class ElkSyncWaiter:
|
||||
"""Wait for ElkM1 to sync."""
|
||||
|
||||
def login_status(succeeded: bool) -> None:
|
||||
nonlocal success
|
||||
def __init__(self, elk: Elk, login_timeout: int, sync_timeout: int) -> None:
|
||||
"""Initialize the sync waiter."""
|
||||
self._elk = elk
|
||||
self._login_timeout = login_timeout
|
||||
self._sync_timeout = sync_timeout
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._sync_future: asyncio.Future[None] = self._loop.create_future()
|
||||
self._login_future: asyncio.Future[None] = self._loop.create_future()
|
||||
|
||||
success = succeeded
|
||||
@callback
|
||||
def _async_set_future_if_not_done(self, future: asyncio.Future[None]) -> None:
|
||||
"""Set the future result if not already done."""
|
||||
if not future.done():
|
||||
future.set_result(None)
|
||||
|
||||
@callback
|
||||
def _async_login_status(self, succeeded: bool) -> None:
|
||||
"""Handle login status callback."""
|
||||
if succeeded:
|
||||
_LOGGER.debug("ElkM1 login succeeded")
|
||||
login_event.set()
|
||||
self._async_set_future_if_not_done(self._login_future)
|
||||
else:
|
||||
elk.disconnect()
|
||||
_LOGGER.error("ElkM1 login failed; invalid username or password")
|
||||
login_event.set()
|
||||
sync_event.set()
|
||||
self._async_set_exception_if_not_done(self._login_future, LoginFailed)
|
||||
|
||||
def sync_complete() -> None:
|
||||
sync_event.set()
|
||||
@callback
|
||||
def _async_set_exception_if_not_done(
|
||||
self, future: asyncio.Future[None], exception: type[Exception]
|
||||
) -> None:
|
||||
"""Set an exception on the future if not already done."""
|
||||
if not future.done():
|
||||
future.set_exception(exception())
|
||||
|
||||
@callback
|
||||
def _async_sync_complete(self) -> None:
|
||||
"""Handle sync complete callback."""
|
||||
self._async_set_future_if_not_done(self._sync_future)
|
||||
|
||||
async def async_wait(self) -> None:
|
||||
"""Wait for login and sync to complete.
|
||||
|
||||
Raises LoginFailed if login fails.
|
||||
Raises TimeoutError if login or sync times out.
|
||||
"""
|
||||
self._elk.add_handler("login", self._async_login_status)
|
||||
self._elk.add_handler("sync_complete", self._async_sync_complete)
|
||||
|
||||
elk.add_handler("login", login_status)
|
||||
elk.add_handler("sync_complete", sync_complete)
|
||||
for name, event, timeout in (
|
||||
("login", login_event, login_timeout),
|
||||
("sync_complete", sync_event, sync_timeout),
|
||||
):
|
||||
_LOGGER.debug("Waiting for %s event for %s seconds", name, timeout)
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
await event.wait()
|
||||
except TimeoutError:
|
||||
_LOGGER.debug("Timed out waiting for %s event", name)
|
||||
elk.disconnect()
|
||||
raise
|
||||
_LOGGER.debug("Received %s event", name)
|
||||
for name, future, timeout in (
|
||||
("login", self._login_future, self._login_timeout),
|
||||
("sync_complete", self._sync_future, self._sync_timeout),
|
||||
):
|
||||
_LOGGER.debug("Waiting for %s event for %s seconds", name, timeout)
|
||||
handle = self._loop.call_later(
|
||||
timeout, self._async_set_exception_if_not_done, future, TimeoutError
|
||||
)
|
||||
try:
|
||||
await future
|
||||
finally:
|
||||
handle.cancel()
|
||||
|
||||
return success
|
||||
_LOGGER.debug("Received %s event", name)
|
||||
finally:
|
||||
self._elk.remove_handler("login", self._async_login_status)
|
||||
self._elk.remove_handler("sync_complete", self._async_sync_complete)
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.network import is_ip_address
|
||||
|
||||
from . import async_wait_for_elk_to_sync, hostname_from_url
|
||||
from . import ElkSyncWaiter, LoginFailed, hostname_from_url
|
||||
from .const import CONF_AUTO_CONFIGURE, DISCOVER_SCAN_TIMEOUT, DOMAIN, LOGIN_TIMEOUT
|
||||
from .discovery import (
|
||||
_short_mac,
|
||||
@@ -89,8 +89,9 @@ async def validate_input(data: dict[str, str], mac: str | None) -> dict[str, str
|
||||
elk.connect()
|
||||
|
||||
try:
|
||||
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT):
|
||||
raise InvalidAuth
|
||||
await ElkSyncWaiter(elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT).async_wait()
|
||||
except LoginFailed as exc:
|
||||
raise InvalidAuth from exc
|
||||
finally:
|
||||
elk.disconnect()
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/elkm1",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["elkm1_lib"],
|
||||
"requirements": ["elkm1-lib==2.2.12"]
|
||||
"requirements": ["elkm1-lib==2.2.13"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==42.7.0",
|
||||
"aioesphomeapi==42.8.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"preview_features": {
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251105.1"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"winter_mode": {
|
||||
"description": "Adds falling snowflakes on your screen. Get your home ready for winter! ❄️",
|
||||
"disable_confirmation": "Snowflakes will no longer fall on your screen. You can re-enable this at any time in labs settings.",
|
||||
"enable_confirmation": "Snowflakes will start falling on your screen. You can turn this off at any time in labs settings.",
|
||||
"name": "Winter mode"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"mode": {
|
||||
"options": {
|
||||
|
||||
@@ -7,4 +7,6 @@ DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
||||
HA_MANAGED_API_PORT = 11984
|
||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||
HA_MANAGED_UNIX_SOCKET = "/run/go2rtc.sock"
|
||||
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
|
||||
# in script/hassfest/docker.py.
|
||||
RECOMMENDED_VERSION = "1.9.12"
|
||||
|
||||
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
|
||||
DOMAIN = ha.DOMAIN
|
||||
|
||||
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entites")
|
||||
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entities")
|
||||
DATA_STOP_HANDLER = f"{DOMAIN}.stop_handler"
|
||||
|
||||
SERVICE_HOMEASSISTANT_STOP: Final = "stop"
|
||||
|
||||
@@ -868,7 +868,7 @@ def _get_target_temperature(state: State, unit: str) -> float | None:
|
||||
|
||||
def _get_current_temperature(state: State, unit: str) -> float | None:
|
||||
"""Calculate the current temperature from a state."""
|
||||
target_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE)
|
||||
if isinstance(target_temp, (int, float)):
|
||||
return temperature_to_homekit(target_temp, unit)
|
||||
current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE)
|
||||
if isinstance(current_temp, (int, float)):
|
||||
return temperature_to_homekit(current_temp, unit)
|
||||
return None
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/lcn",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypck==0.9.5", "lcn-frontend==0.2.7"]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,17 @@ from .entity import MatterEntity, MatterEntityDescription
|
||||
from .helpers import get_matter
|
||||
from .models import MatterDiscoverySchema
|
||||
|
||||
DOOR_LOCK_OPERATING_MODE_MAP = {
|
||||
clusters.DoorLock.Enums.OperatingModeEnum.kNormal: "normal",
|
||||
clusters.DoorLock.Enums.OperatingModeEnum.kVacation: "vacation",
|
||||
clusters.DoorLock.Enums.OperatingModeEnum.kPrivacy: "privacy",
|
||||
clusters.DoorLock.Enums.OperatingModeEnum.kNoRemoteLockUnlock: "no_remote_lock_unlock",
|
||||
clusters.DoorLock.Enums.OperatingModeEnum.kPassage: "passage",
|
||||
}
|
||||
DOOR_LOCK_OPERATING_MODE_MAP_REVERSE = {
|
||||
v: k for k, v in DOOR_LOCK_OPERATING_MODE_MAP.items()
|
||||
}
|
||||
|
||||
NUMBER_OF_RINSES_STATE_MAP = {
|
||||
clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNone: "off",
|
||||
clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNormal: "normal",
|
||||
@@ -30,6 +41,7 @@ NUMBER_OF_RINSES_STATE_MAP = {
|
||||
NUMBER_OF_RINSES_STATE_MAP_REVERSE = {
|
||||
v: k for k, v in NUMBER_OF_RINSES_STATE_MAP.items()
|
||||
}
|
||||
|
||||
PUMP_OPERATION_MODE_MAP = {
|
||||
clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kNormal: "normal",
|
||||
clusters.PumpConfigurationAndControl.Enums.OperationModeEnum.kMinimum: "minimum",
|
||||
@@ -580,4 +592,17 @@ DISCOVERY_SCHEMAS = [
|
||||
vendor_id=(4619,),
|
||||
product_id=(4097,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SELECT,
|
||||
entity_description=MatterSelectEntityDescription(
|
||||
key="DoorLockOperatingMode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="door_lock_operating_mode",
|
||||
options=list(DOOR_LOCK_OPERATING_MODE_MAP.values()),
|
||||
device_to_ha=DOOR_LOCK_OPERATING_MODE_MAP.get,
|
||||
ha_to_device=DOOR_LOCK_OPERATING_MODE_MAP_REVERSE.get,
|
||||
),
|
||||
entity_class=MatterAttributeSelectEntity,
|
||||
required_attributes=(clusters.DoorLock.Attributes.OperatingMode,),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -246,6 +246,16 @@
|
||||
"device_energy_management_mode": {
|
||||
"name": "Energy management mode"
|
||||
},
|
||||
"door_lock_operating_mode": {
|
||||
"name": "Operating mode",
|
||||
"state": {
|
||||
"no_remote_lock_unlock": "No remote lock/unlock",
|
||||
"normal": "Normal",
|
||||
"passage": "Passage",
|
||||
"privacy": "Privacy",
|
||||
"vacation": "Vacation"
|
||||
}
|
||||
},
|
||||
"door_lock_sound_volume": {
|
||||
"name": "Sound volume",
|
||||
"state": {
|
||||
|
||||
@@ -28,6 +28,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VACUUM,
|
||||
|
||||
156
homeassistant/components/miele/select.py
Normal file
156
homeassistant/components/miele/select.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Platform for Miele select entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import DOMAIN, MieleAppliance
|
||||
from .coordinator import MieleConfigEntry
|
||||
from .entity import MieleDevice, MieleEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MieleModes(IntEnum):
|
||||
"""Modes for fridge/freezer."""
|
||||
|
||||
NORMAL = 0
|
||||
SABBATH = 1
|
||||
PARTY = 2
|
||||
HOLIDAY = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MieleSelectDescription(SelectEntityDescription):
|
||||
"""Class describing Miele select entities."""
|
||||
|
||||
value_fn: Callable[[MieleDevice], StateType]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MieleSelectDefinition:
|
||||
"""Class for defining select entities."""
|
||||
|
||||
types: tuple[MieleAppliance, ...]
|
||||
description: MieleSelectDescription
|
||||
|
||||
|
||||
SELECT_TYPES: Final[tuple[MieleSelectDefinition, ...]] = (
|
||||
MieleSelectDefinition(
|
||||
types=(
|
||||
MieleAppliance.FREEZER,
|
||||
MieleAppliance.FRIDGE,
|
||||
MieleAppliance.FRIDGE_FREEZER,
|
||||
),
|
||||
description=MieleSelectDescription(
|
||||
key="fridge_freezer_modes",
|
||||
value_fn=lambda value: 1,
|
||||
translation_key="fridge_freezer_mode",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MieleConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the select platform."""
|
||||
coordinator = config_entry.runtime_data
|
||||
added_devices: set[str] = set()
|
||||
|
||||
def _async_add_new_devices() -> None:
|
||||
nonlocal added_devices
|
||||
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
|
||||
added_devices = current_devices
|
||||
|
||||
async_add_entities(
|
||||
MieleSelectMode(coordinator, device_id, definition.description)
|
||||
for device_id, device in coordinator.data.devices.items()
|
||||
for definition in SELECT_TYPES
|
||||
if device_id in new_devices_set and device.device_type in definition.types
|
||||
)
|
||||
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
_async_add_new_devices()
|
||||
|
||||
|
||||
class MieleSelectMode(MieleEntity, SelectEntity):
|
||||
"""Representation of a Select mode entity."""
|
||||
|
||||
entity_description: MieleSelectDescription
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return the list of available options."""
|
||||
return sorted(
|
||||
{MieleModes(x).name.lower() for x in self.action.modes}
|
||||
| {self.current_option}
|
||||
)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str:
|
||||
"""Retrieve currently selected option."""
|
||||
# There is no direct mapping from Miele 3rd party API, so we infer the
|
||||
# current mode based on available modes in action.modes
|
||||
|
||||
action_modes = set(self.action.modes)
|
||||
if action_modes in ({1}, {1, 2}, {1, 3}, {1, 2, 3}):
|
||||
return MieleModes.NORMAL.name.lower()
|
||||
|
||||
if action_modes in ({0}, {0, 2}, {0, 3}, {0, 2, 3}):
|
||||
return MieleModes.SABBATH.name.lower()
|
||||
|
||||
if action_modes in ({0, 1}, {0, 1, 3}):
|
||||
return MieleModes.PARTY.name.lower()
|
||||
|
||||
if action_modes == {0, 1, 2}:
|
||||
return MieleModes.HOLIDAY.name.lower()
|
||||
|
||||
return MieleModes.NORMAL.name.lower()
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the selected option."""
|
||||
new_mode = MieleModes[option.upper()].value
|
||||
if new_mode not in self.action.modes:
|
||||
_LOGGER.debug("Option '%s' is not available for %s", option, self.entity_id)
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_option",
|
||||
translation_placeholders={
|
||||
"option": option,
|
||||
"entity": self.entity_id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
await self.api.send_action(
|
||||
self._device_id,
|
||||
{"modes": new_mode},
|
||||
)
|
||||
except ClientResponseError as err:
|
||||
_LOGGER.debug("Error setting select state for %s: %s", self.entity_id, err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
translation_placeholders={
|
||||
"entity": self.entity_id,
|
||||
},
|
||||
) from err
|
||||
|
||||
# Refresh data as API does not push changes for modes updates
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -187,6 +187,17 @@
|
||||
"name": "[%key:component::light::title%]"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"fridge_freezer_mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"holiday": "Holiday",
|
||||
"normal": "Normal",
|
||||
"party": "Party",
|
||||
"sabbath": "Sabbath"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"core_target_temperature": {
|
||||
"name": "Core target temperature"
|
||||
@@ -1085,6 +1096,9 @@
|
||||
"get_programs_error": {
|
||||
"message": "'Get programs' action failed: {status} / {message}"
|
||||
},
|
||||
"invalid_option": {
|
||||
"message": "Invalid option: \"{option}\" on {entity}."
|
||||
},
|
||||
"invalid_target": {
|
||||
"message": "Invalid device targeted."
|
||||
},
|
||||
|
||||
@@ -228,32 +228,12 @@ 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.
|
||||
"""
|
||||
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
|
||||
return async_subscribe_internal(hass, topic, msg_callback, qos, encoding)
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -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,8 @@ SUBENTRY_PLATFORMS = [
|
||||
Platform.SIREN,
|
||||
Platform.SWITCH,
|
||||
Platform.TEXT,
|
||||
Platform.VALVE,
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
|
||||
_CODE_VALIDATION_MODE = {
|
||||
@@ -831,6 +835,31 @@ 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)
|
||||
)
|
||||
WATER_HEATER_MODE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
"off",
|
||||
"eco",
|
||||
"electric",
|
||||
"gas",
|
||||
"heat_pump",
|
||||
"high_demand",
|
||||
"performance",
|
||||
],
|
||||
multiple=True,
|
||||
translation_key="water_heater_modes",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -1179,6 +1208,16 @@ def validate_text_platform_config(
|
||||
return errors
|
||||
|
||||
|
||||
@callback
|
||||
def validate_water_heater_platform_config(config: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate the water heater platform options."""
|
||||
errors: dict[str, str] = {}
|
||||
if CONF_TEMP_MIN in config and config[CONF_TEMP_MIN] >= config[CONF_TEMP_MAX]:
|
||||
errors["target_temperature_settings"] = "max_below_min_temperature"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
ENTITY_CONFIG_VALIDATOR: dict[
|
||||
str,
|
||||
Callable[[dict[str, Any]], dict[str, str]] | None,
|
||||
@@ -1199,6 +1238,8 @@ ENTITY_CONFIG_VALIDATOR: dict[
|
||||
Platform.SIREN: None,
|
||||
Platform.SWITCH: None,
|
||||
Platform.TEXT: validate_text_platform_config,
|
||||
Platform.VALVE: None,
|
||||
Platform.WATER_HEATER: validate_water_heater_platform_config,
|
||||
}
|
||||
|
||||
|
||||
@@ -1460,6 +1501,40 @@ 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.WATER_HEATER: {
|
||||
CONF_TEMPERATURE_UNIT: PlatformField(
|
||||
selector=TEMPERATURE_UNIT_SELECTOR,
|
||||
validator=validate(cv.temperature_unit),
|
||||
required=True,
|
||||
exclude_from_reconfig=True,
|
||||
default=lambda _: "C"
|
||||
if async_get_hass().config.units.temperature_unit
|
||||
is UnitOfTemperature.CELSIUS
|
||||
else "F",
|
||||
),
|
||||
"water_heater_feature_current_temperature": PlatformField(
|
||||
selector=BOOLEAN_SELECTOR,
|
||||
required=False,
|
||||
exclude_from_config=True,
|
||||
default=lambda config: bool(config.get(CONF_CURRENT_TEMP_TOPIC)),
|
||||
),
|
||||
"water_heater_feature_power": PlatformField(
|
||||
selector=BOOLEAN_SELECTOR,
|
||||
required=False,
|
||||
exclude_from_config=True,
|
||||
default=lambda config: bool(config.get(CONF_POWER_COMMAND_TOPIC)),
|
||||
),
|
||||
},
|
||||
}
|
||||
PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
Platform.ALARM_CONTROL_PANEL: {
|
||||
@@ -3380,6 +3455,235 @@ 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),
|
||||
},
|
||||
Platform.WATER_HEATER: {
|
||||
# operation mode settings
|
||||
CONF_MODE_COMMAND_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_publish_topic,
|
||||
error="invalid_publish_topic",
|
||||
),
|
||||
CONF_MODE_COMMAND_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_MODE_STATE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
),
|
||||
CONF_MODE_STATE_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
CONF_MODE_LIST: PlatformField(
|
||||
selector=WATER_HEATER_MODE_SELECTOR,
|
||||
required=True,
|
||||
default=[],
|
||||
validator=validate(no_empty_list),
|
||||
error="empty_list_not_allowed",
|
||||
),
|
||||
CONF_RETAIN: PlatformField(
|
||||
selector=BOOLEAN_SELECTOR, required=False, validator=validate(bool)
|
||||
),
|
||||
CONF_OPTIMISTIC: PlatformField(
|
||||
selector=BOOLEAN_SELECTOR, required=False, validator=validate(bool)
|
||||
),
|
||||
# target temperature settings
|
||||
CONF_TEMP_COMMAND_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
validator=valid_publish_topic,
|
||||
error="invalid_publish_topic",
|
||||
section="target_temperature_settings",
|
||||
),
|
||||
CONF_TEMP_COMMAND_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
section="target_temperature_settings",
|
||||
),
|
||||
CONF_TEMP_STATE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
section="target_temperature_settings",
|
||||
),
|
||||
CONF_TEMP_STATE_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
section="target_temperature_settings",
|
||||
),
|
||||
CONF_TEMP_MIN: PlatformField(
|
||||
selector=temperature_selector,
|
||||
custom_filtering=True,
|
||||
required=True,
|
||||
default=temperature_default_from_celsius_to_system_default(43.3),
|
||||
section="target_temperature_settings",
|
||||
),
|
||||
CONF_TEMP_MAX: PlatformField(
|
||||
selector=temperature_selector,
|
||||
custom_filtering=True,
|
||||
required=True,
|
||||
default=temperature_default_from_celsius_to_system_default(60),
|
||||
section="target_temperature_settings",
|
||||
),
|
||||
CONF_PRECISION: PlatformField(
|
||||
selector=PRECISION_SELECTOR,
|
||||
required=False,
|
||||
default=default_precision,
|
||||
section="target_temperature_settings",
|
||||
),
|
||||
CONF_TEMP_INITIAL: PlatformField(
|
||||
selector=temperature_selector,
|
||||
custom_filtering=True,
|
||||
required=False,
|
||||
default=temperature_default_from_celsius_to_system_default(43.3),
|
||||
section="target_temperature_settings",
|
||||
),
|
||||
# current temperature settings
|
||||
CONF_CURRENT_TEMP_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
section="current_temperature_settings",
|
||||
conditions=({"water_heater_feature_current_temperature": True},),
|
||||
),
|
||||
CONF_CURRENT_TEMP_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
section="current_temperature_settings",
|
||||
conditions=({"water_heater_feature_current_temperature": True},),
|
||||
),
|
||||
# power on/off support
|
||||
CONF_POWER_COMMAND_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
validator=valid_publish_topic,
|
||||
error="invalid_publish_topic",
|
||||
section="water_heater_power_settings",
|
||||
conditions=({"water_heater_feature_power": True},),
|
||||
),
|
||||
CONF_POWER_COMMAND_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
section="water_heater_power_settings",
|
||||
conditions=({"water_heater_feature_power": True},),
|
||||
),
|
||||
CONF_PAYLOAD_OFF: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
default=DEFAULT_PAYLOAD_OFF,
|
||||
section="water_heater_power_settings",
|
||||
conditions=({"water_heater_feature_power": True},),
|
||||
),
|
||||
CONF_PAYLOAD_ON: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=False,
|
||||
default=DEFAULT_PAYLOAD_ON,
|
||||
section="water_heater_power_settings",
|
||||
conditions=({"water_heater_feature_power": True},),
|
||||
),
|
||||
},
|
||||
}
|
||||
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"
|
||||
|
||||
@@ -242,12 +242,15 @@
|
||||
"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",
|
||||
"supported_features": "Supported features",
|
||||
"temperature_unit": "Temperature unit",
|
||||
"unit_of_measurement": "Unit of measurement"
|
||||
"unit_of_measurement": "Unit of measurement",
|
||||
"water_heater_feature_current_temperature": "[%key:component::mqtt::config_subentries::device::step::entity_platform_config::data::climate_feature_current_temperature%]",
|
||||
"water_heater_feature_power": "[%key:component::mqtt::config_subentries::device::step::entity_platform_config::data::climate_feature_power%]"
|
||||
},
|
||||
"data_description": {
|
||||
"alarm_control_panel_code_mode": "Configures how the alarm control panel validates the code. A local code is configured with the entity and is validated by Home Assistant. A remote code is sent to the device and validated remotely. [Learn more.]({url}#code)",
|
||||
@@ -269,12 +272,15 @@
|
||||
"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)",
|
||||
"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)",
|
||||
"supported_features": "The features that the entity supports.",
|
||||
"temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.",
|
||||
"unit_of_measurement": "Defines the unit of measurement, if any."
|
||||
"temperature_unit": "This determines the native unit of measurement the MQTT device works with.",
|
||||
"unit_of_measurement": "Defines the unit of measurement, if any.",
|
||||
"water_heater_feature_current_temperature": "The water heater supports reporting the current temperature.",
|
||||
"water_heater_feature_power": "The water heater supports the power \"on\" and \"off\" commands."
|
||||
},
|
||||
"description": "Please configure specific details for {platform} entity \"{entity}\":",
|
||||
"sections": {
|
||||
@@ -356,6 +362,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",
|
||||
@@ -396,7 +404,7 @@
|
||||
"min": "Minimum value. [Learn more.]({url}#min)",
|
||||
"mode": "Control how the number should be displayed in the UI. [Learn more.]({url}#mode)",
|
||||
"mode_command_template": "[Template]({command_templating_url}) to define the operation mode to be sent to the operation mode command topic. [Learn more.]({url}#mode_command_template)",
|
||||
"mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)",
|
||||
"mode_command_topic": "The MQTT topic to publish commands to change the operation mode. [Learn more.]({url}#mode_command_topic)",
|
||||
"mode_state_template": "Defines a [template]({value_templating_url}) to extract the operation mode state. [Learn more.]({url}#mode_state_template)",
|
||||
"mode_state_topic": "The MQTT topic subscribed to receive operation mode state messages. [Learn more.]({url}#mode_state_topic)",
|
||||
"modes": "A list of supported operation modes. [Learn more.]({url}#modes)",
|
||||
@@ -407,6 +415,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.",
|
||||
@@ -951,13 +961,13 @@
|
||||
"temperature_state_topic": "Temperature state topic"
|
||||
},
|
||||
"data_description": {
|
||||
"initial": "The climate initializes with this target temperature.",
|
||||
"initial": "The device initializes with this target temperature.",
|
||||
"max_temp": "The maximum target temperature that can be set.",
|
||||
"min_temp": "The minimum target temperature that can be set.",
|
||||
"precision": "The precision in degrees the thermostat is working at.",
|
||||
"temp_step": "The target temperature step in degrees Celsius or Fahrenheit.",
|
||||
"temperature_command_template": "A [template]({command_templating_url}) to compose the payload to be published at the temperature command topic.",
|
||||
"temperature_command_topic": "The MQTT topic to publish commands to change the climate target temperature. [Learn more.]({url}#temperature_command_topic)",
|
||||
"temperature_command_topic": "The MQTT topic to publish commands to change the target temperature. [Learn more.]({url}#temperature_command_topic)",
|
||||
"temperature_high_command_template": "A [template]({command_templating_url}) to compose the payload to be published at the upper temperature command topic.",
|
||||
"temperature_high_command_topic": "The MQTT topic to publish commands to change the climate upper target temperature. [Learn more.]({url}#temperature_high_command_topic)",
|
||||
"temperature_high_state_template": "A [template]({value_templating_url}) to render the value received on the upper temperature state topic with.",
|
||||
@@ -985,6 +995,42 @@
|
||||
"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"
|
||||
},
|
||||
"water_heater_power_settings": {
|
||||
"data": {
|
||||
"payload_off": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data::payload_off%]",
|
||||
"payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data::payload_on%]",
|
||||
"power_command_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::climate_power_settings::data::power_command_template%]",
|
||||
"power_command_topic": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::climate_power_settings::data::power_command_topic%]"
|
||||
},
|
||||
"data_description": {
|
||||
"payload_off": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_off%]",
|
||||
"payload_on": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::data_description::payload_on%]",
|
||||
"power_command_template": "[%key:component::mqtt::config_subentries::device::step::mqtt_platform_config::sections::climate_power_settings::data_description::power_command_template%]",
|
||||
"power_command_topic": "The MQTT topic to publish commands to change the water heater power state. Sends the payload configured with payload \"on\" or payload \"off\". [Learn more.]({url}#power_command_topic)"
|
||||
},
|
||||
"name": "Power settings"
|
||||
}
|
||||
},
|
||||
"title": "Configure MQTT device \"{mqtt_device}\""
|
||||
@@ -1347,6 +1393,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 +1455,9 @@
|
||||
"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%]",
|
||||
"water_heater": "[%key:component::water_heater::title%]"
|
||||
}
|
||||
},
|
||||
"set_ca_cert": {
|
||||
@@ -1446,6 +1500,18 @@
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]"
|
||||
}
|
||||
},
|
||||
"water_heater_modes": {
|
||||
"options": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"eco": "[%key:component::water_heater::entity_component::_::state::eco%]",
|
||||
"electric": "[%key:component::water_heater::entity_component::_::state::electric%]",
|
||||
"gas": "[%key:component::water_heater::entity_component::_::state::gas%]",
|
||||
"heat_pump": "[%key:component::water_heater::entity_component::_::state::heat_pump%]",
|
||||
"high_demand": "[%key:component::water_heater::entity_component::_::state::high_demand%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"performance": "[%key:component::water_heater::entity_component::_::state::performance%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -136,11 +136,12 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
|
||||
vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string,
|
||||
vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_PRECISION): vol.In(
|
||||
[PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]
|
||||
vol.Optional(CONF_PRECISION): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
|
||||
),
|
||||
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
|
||||
vol.Optional(CONF_TEMP_INITIAL): cv.positive_int,
|
||||
vol.Optional(CONF_TEMP_INITIAL): vol.Coerce(float),
|
||||
vol.Optional(CONF_TEMP_MIN): vol.Coerce(float),
|
||||
vol.Optional(CONF_TEMP_MAX): vol.Coerce(float),
|
||||
vol.Optional(CONF_TEMP_COMMAND_TEMPLATE): cv.template,
|
||||
|
||||
@@ -10,7 +10,11 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from . import api
|
||||
from .const import NEATO_DOMAIN, NEATO_LOGIN
|
||||
@@ -33,13 +37,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if CONF_TOKEN not in entry.data:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=NEATO_DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
|
||||
@@ -46,6 +46,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"custom_cleaning": {
|
||||
"description": "Starts a custom cleaning of your house.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, cast
|
||||
|
||||
from aioshelly.block_device import Block
|
||||
from aioshelly.const import RPC_GENERATIONS
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -21,7 +20,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import CONF_SLEEP_PERIOD, MODEL_FRANKEVER_WATER_VALVE, ROLE_GENERIC
|
||||
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator
|
||||
from .entity import (
|
||||
BlockEntityDescription,
|
||||
RestEntityDescription,
|
||||
@@ -39,8 +38,6 @@ from .utils import (
|
||||
async_remove_orphaned_entities,
|
||||
get_blu_trv_device_info,
|
||||
get_device_entry_gen,
|
||||
get_entity_translation_attributes,
|
||||
get_rpc_channel_name,
|
||||
get_rpc_custom_name,
|
||||
get_rpc_key,
|
||||
is_block_momentary_input,
|
||||
@@ -83,32 +80,15 @@ class RpcBinarySensor(ShellyRpcAttributeEntity, BinarySensorEntity):
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, key, attribute, description)
|
||||
|
||||
if description.role != ROLE_GENERIC:
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
if not description.role and description.key == "input":
|
||||
if custom_name := get_rpc_custom_name(coordinator.device, key):
|
||||
self._attr_name = custom_name
|
||||
else:
|
||||
_, _, component_id = get_rpc_key(key)
|
||||
self._attr_translation_placeholders = {"input_number": component_id}
|
||||
self._attr_translation_key = "input_with_number"
|
||||
|
||||
if not description.role and description.key != "input":
|
||||
translation_placeholders, translation_key = (
|
||||
get_entity_translation_attributes(
|
||||
get_rpc_channel_name(coordinator.device, key),
|
||||
description.translation_key,
|
||||
description.device_class,
|
||||
self._default_to_device_class_name(),
|
||||
)
|
||||
)
|
||||
|
||||
if translation_placeholders:
|
||||
self._attr_translation_placeholders = translation_placeholders
|
||||
if translation_key:
|
||||
self._attr_translation_key = translation_key
|
||||
if not description.role:
|
||||
if description.key != "input":
|
||||
self.configure_translation_attributes()
|
||||
elif custom_name := get_rpc_custom_name(coordinator.device, key):
|
||||
self._attr_name = custom_name
|
||||
else:
|
||||
_, _, component_id = get_rpc_key(key)
|
||||
self._attr_translation_placeholders = {"input_number": component_id}
|
||||
self._attr_translation_key = "input_with_number"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
@@ -453,19 +433,6 @@ class BlockBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: BlockBinarySensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyBlockCoordinator,
|
||||
block: Block,
|
||||
attribute: str,
|
||||
description: BlockBinarySensorDescription,
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, block, attribute, description)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if sensor state is on."""
|
||||
@@ -477,18 +444,6 @@ class RestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: RestBinarySensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyBlockCoordinator,
|
||||
attribute: str,
|
||||
description: RestBinarySensorDescription,
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, attribute, description)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if REST sensor state is on."""
|
||||
@@ -502,20 +457,6 @@ class BlockSleepingBinarySensor(
|
||||
|
||||
entity_description: BlockBinarySensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyBlockCoordinator,
|
||||
block: Block | None,
|
||||
attribute: str,
|
||||
description: BlockBinarySensorDescription,
|
||||
entry: RegistryEntry | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sleeping sensor."""
|
||||
super().__init__(coordinator, block, attribute, description, entry)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -552,22 +493,7 @@ class RpcSleepingBinarySensor(
|
||||
super().__init__(coordinator, key, attribute, description, entry)
|
||||
|
||||
if coordinator.device.initialized:
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
translation_placeholders, translation_key = (
|
||||
get_entity_translation_attributes(
|
||||
get_rpc_channel_name(coordinator.device, key),
|
||||
description.translation_key,
|
||||
description.device_class,
|
||||
self._default_to_device_class_name(),
|
||||
)
|
||||
)
|
||||
|
||||
if translation_placeholders:
|
||||
self._attr_translation_placeholders = translation_placeholders
|
||||
if translation_key:
|
||||
self._attr_translation_key = translation_key
|
||||
self.configure_translation_attributes()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
|
||||
@@ -21,7 +21,6 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
@@ -359,9 +358,6 @@ class ShellyBluTrvButton(ShellyRpcAttributeEntity, ButtonEntity):
|
||||
config, ble_addr, coordinator.mac, fw_ver
|
||||
)
|
||||
|
||||
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@rpc_call
|
||||
async def async_press(self) -> None:
|
||||
"""Triggers the Shelly button press service."""
|
||||
@@ -374,19 +370,6 @@ class RpcVirtualButton(ShellyRpcAttributeEntity, ButtonEntity):
|
||||
entity_description: RpcButtonDescription
|
||||
_id: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyRpcCoordinator,
|
||||
key: str,
|
||||
attribute: str,
|
||||
description: RpcButtonDescription,
|
||||
) -> None:
|
||||
"""Initialize select."""
|
||||
super().__init__(coordinator, key, attribute, description)
|
||||
|
||||
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@rpc_call
|
||||
async def async_press(self) -> None:
|
||||
"""Triggers the Shelly button press service."""
|
||||
@@ -401,20 +384,6 @@ class RpcSleepingSmokeMuteButton(ShellySleepingRpcAttributeEntity, ButtonEntity)
|
||||
|
||||
entity_description: RpcButtonDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyRpcCoordinator,
|
||||
key: str,
|
||||
attribute: str,
|
||||
description: RpcButtonDescription,
|
||||
entry: RegistryEntry | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sleeping sensor."""
|
||||
super().__init__(coordinator, key, attribute, description, entry)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@rpc_call
|
||||
async def async_press(self) -> None:
|
||||
"""Triggers the Shelly button press service."""
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
from aioshelly.ble import get_name_from_model_id
|
||||
@@ -29,18 +30,27 @@ from aioshelly.exceptions import (
|
||||
RpcCallError,
|
||||
)
|
||||
from aioshelly.rpc_device import RpcDevice
|
||||
from aioshelly.zeroconf import async_lookup_device_by_name
|
||||
from aioshelly.zeroconf import async_discover_devices, async_lookup_device_by_name
|
||||
from bleak.backends.device import BLEDevice
|
||||
import voluptuous as vol
|
||||
from zeroconf import IPVersion
|
||||
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_ble_device_from_address,
|
||||
async_clear_address_from_match_history,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_BLUETOOTH,
|
||||
SOURCE_ZEROCONF,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE,
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_MODEL,
|
||||
@@ -49,9 +59,11 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
@@ -102,6 +114,8 @@ BLE_SCANNER_OPTIONS = [
|
||||
]
|
||||
|
||||
INTERNAL_WIFI_AP_IP = "192.168.33.1"
|
||||
MANUAL_ENTRY_STRING = "manual"
|
||||
DISCOVERY_SOURCES = {SOURCE_BLUETOOTH, SOURCE_ZEROCONF}
|
||||
|
||||
|
||||
async def async_get_ip_from_ble(ble_device: BLEDevice) -> str | None:
|
||||
@@ -134,6 +148,26 @@ async def async_get_ip_from_ble(ble_device: BLEDevice) -> str | None:
|
||||
BLUETOOTH_FINISHING_STEPS = {"do_provision", "provision_done"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DiscoveredDeviceZeroconf:
|
||||
"""Discovered Shelly device via Zeroconf."""
|
||||
|
||||
name: str
|
||||
mac: str
|
||||
host: str
|
||||
port: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DiscoveredDeviceBluetooth:
|
||||
"""Discovered Shelly device via Bluetooth."""
|
||||
|
||||
name: str
|
||||
mac: str
|
||||
ble_device: BLEDevice
|
||||
discovery_info: BluetoothServiceInfoBleak
|
||||
|
||||
|
||||
async def validate_input(
|
||||
hass: HomeAssistant,
|
||||
host: str,
|
||||
@@ -216,63 +250,280 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_provision_result: ConfigFlowResult | None = None
|
||||
disable_ap_after_provision: bool = True
|
||||
disable_ble_rpc_after_provision: bool = True
|
||||
_discovered_devices: dict[str, DiscoveredDeviceZeroconf | DiscoveredDeviceBluetooth]
|
||||
|
||||
@staticmethod
|
||||
def _get_name_from_mac_and_ble_model(
|
||||
mac: str, parsed_data: dict[str, int | str]
|
||||
) -> str:
|
||||
"""Generate device name from MAC and BLE manufacturer data model ID.
|
||||
|
||||
For devices without a Shelly name, use model name from model ID if available.
|
||||
Gen3/4 devices advertise MAC address as name instead of "ShellyXXX-MACADDR".
|
||||
"""
|
||||
if (
|
||||
(model_id := parsed_data.get("model_id"))
|
||||
and isinstance(model_id, int)
|
||||
and (model_name := get_name_from_model_id(model_id))
|
||||
):
|
||||
# Remove spaces from model name (e.g., "Shelly 1 Mini Gen4" -> "Shelly1MiniGen4")
|
||||
return f"{model_name.replace(' ', '')}-{mac}"
|
||||
return f"Shelly-{mac}"
|
||||
|
||||
def _parse_ble_device_mac_and_name(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> tuple[str | None, str]:
|
||||
"""Parse MAC address and device name from BLE discovery info.
|
||||
|
||||
Returns:
|
||||
Tuple of (mac, device_name) where mac is None if parsing failed.
|
||||
"""
|
||||
device_name = discovery_info.name
|
||||
mac: str | None = None
|
||||
|
||||
# Try to get MAC from device name first
|
||||
if mac := mac_address_from_name(device_name):
|
||||
return mac, device_name
|
||||
|
||||
# Try to parse from manufacturer data
|
||||
if not (
|
||||
(parsed := parse_shelly_manufacturer_data(discovery_info.manufacturer_data))
|
||||
and (mac_with_colons := parsed.get("mac"))
|
||||
and isinstance(mac_with_colons, str)
|
||||
):
|
||||
return None, device_name
|
||||
|
||||
# Convert MAC from "CC:BA:97:C2:D6:72" to "CCBA97C2D672"
|
||||
mac = mac_with_colons.replace(":", "")
|
||||
device_name = self._get_name_from_mac_and_ble_model(mac, parsed)
|
||||
|
||||
return mac, device_name
|
||||
|
||||
async def _async_discover_zeroconf_devices(
|
||||
self,
|
||||
) -> dict[str, DiscoveredDeviceZeroconf]:
|
||||
"""Discover Shelly devices via Zeroconf."""
|
||||
discovered: dict[str, DiscoveredDeviceZeroconf] = {}
|
||||
|
||||
aiozc = await zeroconf.async_get_async_instance(self.hass)
|
||||
zeroconf_devices = await async_discover_devices(aiozc)
|
||||
|
||||
for service_info in zeroconf_devices:
|
||||
device_name = service_info.name.partition(".")[0]
|
||||
if not (mac := mac_address_from_name(device_name)):
|
||||
continue
|
||||
|
||||
# Get IPv4 address from service info (Shelly doesn't support IPv6)
|
||||
if not (
|
||||
ipv4_addresses := service_info.ip_addresses_by_version(IPVersion.V4Only)
|
||||
):
|
||||
continue
|
||||
|
||||
host = str(ipv4_addresses[0])
|
||||
discovered[mac] = DiscoveredDeviceZeroconf(
|
||||
name=device_name,
|
||||
mac=mac,
|
||||
host=host,
|
||||
port=service_info.port or DEFAULT_HTTP_PORT,
|
||||
)
|
||||
|
||||
return discovered
|
||||
|
||||
@callback
|
||||
def _async_discover_bluetooth_devices(
|
||||
self,
|
||||
) -> dict[str, DiscoveredDeviceBluetooth]:
|
||||
"""Discover Shelly devices via Bluetooth."""
|
||||
discovered: dict[str, DiscoveredDeviceBluetooth] = {}
|
||||
|
||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||
mac, device_name = self._parse_ble_device_mac_and_name(discovery_info)
|
||||
|
||||
if not (
|
||||
mac
|
||||
and has_rpc_over_ble(discovery_info.manufacturer_data)
|
||||
and (
|
||||
ble_device := async_ble_device_from_address(
|
||||
self.hass, discovery_info.address, connectable=True
|
||||
)
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
discovered[mac] = DiscoveredDeviceBluetooth(
|
||||
name=device_name,
|
||||
mac=mac,
|
||||
ble_device=ble_device,
|
||||
discovery_info=discovery_info,
|
||||
)
|
||||
|
||||
return discovered
|
||||
|
||||
async def _async_connect_and_get_info(
|
||||
self, host: str, port: int
|
||||
) -> ConfigFlowResult | None:
|
||||
"""Connect to device, validate, and create entry or return None to continue flow.
|
||||
|
||||
This helper consolidates the common logic between Zeroconf device selection
|
||||
and manual entry flows. Returns a ConfigFlowResult if the flow should end
|
||||
(create_entry or abort), or None if the flow should continue (e.g., to credentials).
|
||||
|
||||
Sets self.info, self.host, and self.port on success.
|
||||
"""
|
||||
self.info = await self._async_get_info(host, port)
|
||||
await self.async_set_unique_id(self.info[CONF_MAC], raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host})
|
||||
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
if get_info_auth(self.info):
|
||||
return None # Continue to credentials step
|
||||
|
||||
device_info = await validate_input(
|
||||
self.hass, self.host, self.port, self.info, {}
|
||||
)
|
||||
|
||||
if device_info[CONF_MODEL]:
|
||||
return self.async_create_entry(
|
||||
title=device_info["title"],
|
||||
data={
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
|
||||
CONF_MODEL: device_info[CONF_MODEL],
|
||||
CONF_GEN: device_info[CONF_GEN],
|
||||
},
|
||||
)
|
||||
return self.async_abort(reason="firmware_not_fully_provisioned")
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
"""Handle the initial step - show discovered devices or manual entry."""
|
||||
if user_input is not None:
|
||||
selected = user_input[CONF_DEVICE]
|
||||
if selected == MANUAL_ENTRY_STRING:
|
||||
return await self.async_step_user_manual()
|
||||
|
||||
# User selected a discovered device
|
||||
device_data = self._discovered_devices[selected]
|
||||
|
||||
if isinstance(device_data, DiscoveredDeviceZeroconf):
|
||||
# Zeroconf device - connect directly
|
||||
try:
|
||||
result = await self._async_connect_and_get_info(
|
||||
device_data.host, device_data.port
|
||||
)
|
||||
except AbortFlow:
|
||||
raise # Let AbortFlow propagate (e.g., already_configured)
|
||||
except DeviceConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except MacAddressMismatchError:
|
||||
return self.async_abort(reason="mac_address_mismatch")
|
||||
except CustomPortNotSupported:
|
||||
return self.async_abort(reason="custom_port_not_supported")
|
||||
|
||||
# If result is None, continue to credentials step
|
||||
if result is None:
|
||||
return await self.async_step_credentials()
|
||||
return result
|
||||
|
||||
# BLE device - start provisioning flow
|
||||
self.ble_device = device_data.ble_device
|
||||
self.device_name = device_data.name
|
||||
await self.async_set_unique_id(device_data.mac, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {"name": self.device_name},
|
||||
}
|
||||
)
|
||||
return await self.async_step_bluetooth_confirm()
|
||||
|
||||
# Discover devices from both sources
|
||||
discovered_devices: dict[
|
||||
str, DiscoveredDeviceZeroconf | DiscoveredDeviceBluetooth
|
||||
] = {}
|
||||
|
||||
# Discover BLE devices first, then zeroconf (which will overwrite duplicates)
|
||||
discovered_devices.update(self._async_discover_bluetooth_devices())
|
||||
# Zeroconf devices are preferred over BLE, so update overwrites any duplicates
|
||||
discovered_devices.update(await self._async_discover_zeroconf_devices())
|
||||
|
||||
# Filter out already-configured devices (excluding ignored)
|
||||
# and devices with active discovery flows (already being offered to user)
|
||||
current_ids = self._async_current_ids(include_ignore=False)
|
||||
in_progress_macs = self._async_get_in_progress_discovery_macs()
|
||||
discovered_devices = {
|
||||
mac: device
|
||||
for mac, device in discovered_devices.items()
|
||||
if mac not in current_ids and mac not in in_progress_macs
|
||||
}
|
||||
|
||||
# Store discovered devices for use in selection
|
||||
self._discovered_devices = discovered_devices
|
||||
|
||||
# If no devices discovered, go directly to manual entry
|
||||
if not discovered_devices:
|
||||
return await self.async_step_user_manual()
|
||||
|
||||
# Build selection options for discovered devices
|
||||
device_options: list[SelectOptionDict] = [
|
||||
SelectOptionDict(label=data.name, value=mac)
|
||||
for mac, data in discovered_devices.items()
|
||||
]
|
||||
# Add manual entry option with translation key
|
||||
device_options.append(
|
||||
SelectOptionDict(label="manual", value=MANUAL_ENTRY_STRING)
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=device_options,
|
||||
translation_key=CONF_DEVICE,
|
||||
mode=SelectSelectorMode.LIST,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_user_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle manual entry step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input[CONF_PORT]
|
||||
try:
|
||||
self.info = await self._async_get_info(host, port)
|
||||
result = await self._async_connect_and_get_info(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
except AbortFlow:
|
||||
raise # Let AbortFlow propagate (e.g., already_configured)
|
||||
except DeviceConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidHostError:
|
||||
errors["base"] = "invalid_host"
|
||||
except MacAddressMismatchError:
|
||||
errors["base"] = "mac_address_mismatch"
|
||||
except CustomPortNotSupported:
|
||||
errors["base"] = "custom_port_not_supported"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
self.info[CONF_MAC], raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: host})
|
||||
self.host = host
|
||||
self.port = port
|
||||
if get_info_auth(self.info):
|
||||
# If result is None, continue to credentials step
|
||||
if result is None:
|
||||
return await self.async_step_credentials()
|
||||
|
||||
try:
|
||||
device_info = await validate_input(
|
||||
self.hass, host, port, self.info, {}
|
||||
)
|
||||
except DeviceConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except MacAddressMismatchError:
|
||||
errors["base"] = "mac_address_mismatch"
|
||||
except CustomPortNotSupported:
|
||||
errors["base"] = "custom_port_not_supported"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if device_info[CONF_MODEL]:
|
||||
return self.async_create_entry(
|
||||
title=device_info["title"],
|
||||
data={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
|
||||
CONF_MODEL: device_info[CONF_MODEL],
|
||||
CONF_GEN: device_info[CONF_GEN],
|
||||
},
|
||||
)
|
||||
return self.async_abort(reason="firmware_not_fully_provisioned")
|
||||
return result
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
|
||||
step_id="user_manual", data_schema=CONFIG_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_credentials(
|
||||
@@ -333,6 +584,22 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="credentials", data_schema=vol.Schema(schema), errors=errors
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_get_in_progress_discovery_macs(self) -> set[str]:
|
||||
"""Get MAC addresses of devices with active discovery flows.
|
||||
|
||||
Returns MAC addresses from bluetooth and zeroconf discovery flows
|
||||
that are already in progress, so they can be filtered from the
|
||||
user step device list (since they're already being offered).
|
||||
"""
|
||||
return {
|
||||
mac
|
||||
for flow in self._async_in_progress(include_uninitialized=True)
|
||||
if flow["flow_id"] != self.flow_id
|
||||
and flow["context"].get("source") in DISCOVERY_SOURCES
|
||||
and (mac := flow["context"].get("unique_id"))
|
||||
}
|
||||
|
||||
def _abort_idle_ble_flows(self, mac: str) -> None:
|
||||
"""Abort idle BLE provisioning flows for this device.
|
||||
|
||||
@@ -395,33 +662,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle bluetooth discovery."""
|
||||
# Try to parse MAC address from the Bluetooth device name
|
||||
# If not found, try to get it from manufacturer data
|
||||
device_name = discovery_info.name
|
||||
if (
|
||||
not (mac := mac_address_from_name(device_name))
|
||||
and (
|
||||
parsed := parse_shelly_manufacturer_data(
|
||||
discovery_info.manufacturer_data
|
||||
)
|
||||
)
|
||||
and (mac_with_colons := parsed.get("mac"))
|
||||
and isinstance(mac_with_colons, str)
|
||||
):
|
||||
# parse_shelly_manufacturer_data returns MAC with colons (e.g., "CC:BA:97:C2:D6:72")
|
||||
# Convert to format without colons to match mac_address_from_name output
|
||||
mac = mac_with_colons.replace(":", "")
|
||||
# For devices without a Shelly name, use model name from model ID if available
|
||||
# Gen3/4 devices advertise MAC address as name instead of "ShellyXXX-MACADDR"
|
||||
if (
|
||||
(model_id := parsed.get("model_id"))
|
||||
and isinstance(model_id, int)
|
||||
and (model_name := get_name_from_model_id(model_id))
|
||||
):
|
||||
# Remove spaces from model name (e.g., "Shelly 1 Mini Gen4" -> "Shelly1MiniGen4")
|
||||
device_name = f"{model_name.replace(' ', '')}-{mac}"
|
||||
else:
|
||||
device_name = f"Shelly-{mac}"
|
||||
mac, device_name = self._parse_ble_device_mac_and_name(discovery_info)
|
||||
|
||||
if not mac:
|
||||
return self.async_abort(reason="invalid_discovery_info")
|
||||
@@ -492,7 +733,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Scan for WiFi networks via BLE."""
|
||||
if user_input is not None:
|
||||
self.selected_ssid = user_input[CONF_SSID]
|
||||
return await self.async_step_wifi_credentials()
|
||||
password = user_input[CONF_PASSWORD]
|
||||
return await self.async_step_do_provision({"password": password})
|
||||
|
||||
# Scan for WiFi networks via BLE
|
||||
if TYPE_CHECKING:
|
||||
@@ -510,22 +752,34 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
LOGGER.exception("Unexpected exception during WiFi scan")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
# Create list of SSIDs for selection
|
||||
# If no networks found, still allow custom SSID entry
|
||||
ssid_options = [network["ssid"] for network in self.wifi_networks]
|
||||
# Sort by RSSI (strongest signal first - higher/less negative values first)
|
||||
# and create list of SSIDs for selection
|
||||
sorted_networks = sorted(
|
||||
self.wifi_networks, key=lambda n: n["rssi"], reverse=True
|
||||
)
|
||||
ssid_options = [network["ssid"] for network in sorted_networks]
|
||||
|
||||
# Pre-select SSID if returning from failed provisioning attempt
|
||||
suggested_values: dict[str, Any] = {}
|
||||
if self.selected_ssid:
|
||||
suggested_values[CONF_SSID] = self.selected_ssid
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="wifi_scan",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SSID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=ssid_options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
custom_value=True,
|
||||
)
|
||||
),
|
||||
}
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SSID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=ssid_options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
custom_value=True,
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
suggested_values,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -553,25 +807,6 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
finally:
|
||||
provisioning_registry.pop(normalized_mac, None)
|
||||
|
||||
async def async_step_wifi_credentials(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Get WiFi credentials and provision device."""
|
||||
if user_input is not None:
|
||||
self.selected_ssid = user_input.get(CONF_SSID, self.selected_ssid)
|
||||
password = user_input[CONF_PASSWORD]
|
||||
return await self.async_step_do_provision({"password": password})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="wifi_credentials",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
description_placeholders={"ssid": self.selected_ssid},
|
||||
)
|
||||
|
||||
async def _async_secure_device_after_provision(self, host: str, port: int) -> None:
|
||||
"""Disable AP and/or BLE RPC after successful WiFi provisioning.
|
||||
|
||||
@@ -794,8 +1029,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle failed provisioning - allow retry."""
|
||||
if user_input is not None:
|
||||
# User wants to retry - clear state and go back to wifi_scan
|
||||
self.selected_ssid = ""
|
||||
# User wants to retry - keep selected_ssid so it's pre-selected
|
||||
self.wifi_networks = []
|
||||
return await self.async_step_wifi_scan()
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoo
|
||||
from .utils import (
|
||||
async_remove_shelly_entity,
|
||||
get_block_device_info,
|
||||
get_entity_translation_attributes,
|
||||
get_rpc_channel_name,
|
||||
get_rpc_device_info,
|
||||
get_rpc_key,
|
||||
@@ -595,6 +596,20 @@ class ShellyRpcAttributeEntity(ShellyRpcEntity, Entity):
|
||||
|
||||
return self.entity_description.available(self.sub_status)
|
||||
|
||||
def configure_translation_attributes(self) -> None:
|
||||
"""Configure translation attributes."""
|
||||
translation_placeholders, translation_key = get_entity_translation_attributes(
|
||||
get_rpc_channel_name(self.coordinator.device, self.key),
|
||||
self.entity_description.translation_key,
|
||||
self.entity_description.device_class,
|
||||
self._default_to_device_class_name(),
|
||||
)
|
||||
|
||||
if translation_placeholders:
|
||||
self._attr_translation_placeholders = translation_placeholders
|
||||
if translation_key:
|
||||
self._attr_translation_key = translation_key
|
||||
|
||||
|
||||
class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
|
||||
"""Represent a shelly sleeping block attribute entity."""
|
||||
|
||||
@@ -35,7 +35,6 @@ from .utils import (
|
||||
get_block_custom_name,
|
||||
get_block_number_of_channels,
|
||||
get_device_entry_gen,
|
||||
get_rpc_channel_name,
|
||||
get_rpc_custom_name,
|
||||
get_rpc_key,
|
||||
get_rpc_key_id,
|
||||
@@ -160,8 +159,7 @@ def _async_setup_rpc_entry(
|
||||
)
|
||||
script_events = config_entry.runtime_data.rpc_script_events
|
||||
for script in script_instances:
|
||||
script_name = get_rpc_channel_name(coordinator.device, script)
|
||||
if script_name == BLE_SCRIPT_NAME:
|
||||
if get_rpc_custom_name(coordinator.device, script) == BLE_SCRIPT_NAME:
|
||||
continue
|
||||
|
||||
if script_events and (event_types := script_events[get_rpc_key_id(script)]):
|
||||
@@ -212,9 +210,6 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity):
|
||||
and get_block_number_of_channels(coordinator.device, block) > 1
|
||||
else ""
|
||||
}
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
else:
|
||||
self._attr_name = get_block_channel_name(coordinator.device, block)
|
||||
|
||||
@@ -253,17 +248,17 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
|
||||
|
||||
if description.key == "input":
|
||||
_, component, component_id = get_rpc_key(key)
|
||||
if not get_rpc_custom_name(coordinator.device, key):
|
||||
if custom_name := get_rpc_custom_name(coordinator.device, key):
|
||||
self._attr_name = custom_name
|
||||
else:
|
||||
self._attr_translation_placeholders = {
|
||||
"input_number": component_id
|
||||
if get_rpc_number_of_channels(coordinator.device, component) > 1
|
||||
else ""
|
||||
}
|
||||
else:
|
||||
self._attr_name = get_rpc_channel_name(coordinator.device, key)
|
||||
self.event_id = int(component_id)
|
||||
elif description.key == "script":
|
||||
self._attr_name = get_rpc_channel_name(coordinator.device, key)
|
||||
self._attr_name = get_rpc_custom_name(coordinator.device, key)
|
||||
self.event_id = get_rpc_key_id(key)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
|
||||
@@ -108,9 +108,6 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
|
||||
if description.mode_fn is not None:
|
||||
self._attr_mode = description.mode_fn(coordinator.device.config[key])
|
||||
|
||||
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return value of number."""
|
||||
@@ -411,9 +408,6 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
|
||||
self.restored_data: NumberExtraStoredData | None = None
|
||||
super().__init__(coordinator, block, attribute, description, entry)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@@ -106,9 +106,6 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity):
|
||||
"""Initialize select."""
|
||||
super().__init__(coordinator, key, attribute, description)
|
||||
|
||||
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
if not description.role:
|
||||
translation_placeholders, translation_key = (
|
||||
get_entity_translation_attributes(
|
||||
@@ -1819,10 +1816,6 @@ class BlockSensor(ShellyBlockAttributeEntity, SensorEntity):
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, block, attribute, description)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
self._attr_native_unit_of_measurement = description.native_unit_of_measurement
|
||||
|
||||
@property
|
||||
@@ -1836,18 +1829,6 @@ class RestSensor(ShellyRestAttributeEntity, SensorEntity):
|
||||
|
||||
entity_description: RestSensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyBlockCoordinator,
|
||||
attribute: str,
|
||||
description: RestSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize sensor."""
|
||||
super().__init__(coordinator, attribute, description)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return value of sensor."""
|
||||
@@ -1871,9 +1852,6 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, RestoreSensor):
|
||||
super().__init__(coordinator, block, attribute, description, entry)
|
||||
self.restored_data: SensorExtraStoredData | None = None
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -1920,9 +1898,6 @@ class RpcSleepingSensor(ShellySleepingRpcAttributeEntity, RestoreSensor):
|
||||
self.restored_data: SensorExtraStoredData | None = None
|
||||
|
||||
if coordinator.device.initialized:
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
translation_placeholders, translation_key = (
|
||||
get_entity_translation_attributes(
|
||||
get_rpc_channel_name(coordinator.device, key),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.",
|
||||
"ble_not_permitted": "Device is bound to a Shelly cloud account and cannot be provisioned via Bluetooth. Please use the Shelly app to provision WiFi credentials, then add the device when it appears on your network.",
|
||||
"cannot_connect": "Failed to connect to the device. Ensure the device is powered on and within range.",
|
||||
"custom_port_not_supported": "[%key:component::shelly::config::error::custom_port_not_supported%]",
|
||||
"firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support",
|
||||
"invalid_discovery_info": "Invalid Bluetooth discovery information.",
|
||||
"ipv6_not_supported": "IPv6 is not supported.",
|
||||
@@ -74,12 +75,21 @@
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::shelly::config::step::user::data_description::host%]",
|
||||
"port": "[%key:component::shelly::config::step::user::data_description::port%]"
|
||||
"host": "[%key:component::shelly::config::step::user_manual::data_description::host%]",
|
||||
"port": "[%key:component::shelly::config::step::user_manual::data_description::port%]"
|
||||
},
|
||||
"description": "Update configuration for {device_name}.\n\nBefore setup, battery-powered devices must be woken up, you can now wake the device up using a button on it."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"device": "Device"
|
||||
},
|
||||
"data_description": {
|
||||
"device": "Select a discovered device or enter an address manually."
|
||||
},
|
||||
"description": "Select a Shelly device from the list or enter an address manually.\n\nBefore setup, battery-powered devices must be woken up, you can now wake the device up using a button on it."
|
||||
},
|
||||
"user_manual": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
@@ -90,23 +100,16 @@
|
||||
},
|
||||
"description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it."
|
||||
},
|
||||
"wifi_credentials": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Password for the WiFi network."
|
||||
},
|
||||
"description": "Enter the password for {ssid}."
|
||||
},
|
||||
"wifi_scan": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"ssid": "WiFi network"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Password for the WiFi network.",
|
||||
"ssid": "Select a WiFi network from the list or enter a custom SSID for hidden networks."
|
||||
},
|
||||
"description": "Select a WiFi network from the list or enter a custom SSID for hidden networks."
|
||||
"description": "Select a WiFi network and enter the password to provision the device."
|
||||
},
|
||||
"wifi_scan_failed": {
|
||||
"description": "Failed to scan for WiFi networks via Bluetooth. The device may be out of range or Bluetooth connection failed. Would you like to try again?"
|
||||
@@ -478,7 +481,7 @@
|
||||
"name": "Right slot vial"
|
||||
},
|
||||
"self_test": {
|
||||
"name": "Self test",
|
||||
"name": "Self-test",
|
||||
"state": {
|
||||
"completed": "Completed",
|
||||
"not_completed": "Not completed",
|
||||
@@ -731,6 +734,11 @@
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"passive": "Passive"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"options": {
|
||||
"manual": "Enter address manually"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,9 +425,6 @@ class BlockSleepingMotionSwitch(
|
||||
super().__init__(coordinator, block, attribute, description, entry)
|
||||
self.last_state: State | None = None
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""If motion is active."""
|
||||
@@ -514,10 +511,8 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity):
|
||||
"""Initialize select."""
|
||||
super().__init__(coordinator, key, attribute, description)
|
||||
|
||||
if description.role == ROLE_GENERIC or description.key in ("switch", "script"):
|
||||
if description.key in ("switch", "script"):
|
||||
self._attr_name = get_rpc_channel_name(coordinator.device, key)
|
||||
elif hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
||||
@@ -23,7 +23,6 @@ from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import (
|
||||
@@ -182,9 +181,6 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity):
|
||||
)
|
||||
self._in_progress_old_version: str | None = None
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
"""Version currently in use."""
|
||||
@@ -278,9 +274,6 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity):
|
||||
coordinator.device.gen, coordinator.model, description.beta
|
||||
)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -376,20 +369,6 @@ class RpcSleepingUpdateEntity(
|
||||
|
||||
entity_description: RpcUpdateDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ShellyRpcCoordinator,
|
||||
key: str,
|
||||
attribute: str,
|
||||
description: RpcUpdateDescription,
|
||||
entry: RegistryEntry | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sleeping sensor."""
|
||||
super().__init__(coordinator, key, attribute, description, entry)
|
||||
|
||||
if hasattr(self, "_attr_name"):
|
||||
delattr(self, "_attr_name")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@@ -33,6 +33,7 @@ from homeassistant.const import (
|
||||
ATTR_HW_VERSION,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_MODEL_ID,
|
||||
ATTR_SUGGESTED_AREA,
|
||||
ATTR_SW_VERSION,
|
||||
ATTR_VIA_DEVICE,
|
||||
@@ -461,6 +462,7 @@ def create_devices(
|
||||
kwargs.update(
|
||||
{
|
||||
ATTR_MANUFACTURER: ocf.manufacturer_name,
|
||||
ATTR_MODEL_ID: ocf.model_code,
|
||||
ATTR_MODEL: (
|
||||
(ocf.model_number.split("|")[0]) if ocf.model_number else None
|
||||
),
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysmartthings==3.3.4"]
|
||||
"requirements": ["pysmartthings==3.4.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."
|
||||
|
||||
@@ -210,17 +210,15 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
self._oscillating_template = config.get(CONF_OSCILLATING)
|
||||
self._direction_template = config.get(CONF_DIRECTION)
|
||||
|
||||
self._state: bool | None = False
|
||||
self._percentage: int | None = None
|
||||
self._preset_mode: str | None = None
|
||||
self._oscillating: bool | None = None
|
||||
self._direction: str | None = None
|
||||
# Required for legacy functionality.
|
||||
self._attr_is_on = False
|
||||
self._attr_percentage = None
|
||||
|
||||
# Number of valid speeds
|
||||
self._speed_count = config.get(CONF_SPEED_COUNT)
|
||||
self._attr_speed_count = config.get(CONF_SPEED_COUNT) or 100
|
||||
|
||||
# List of valid preset modes
|
||||
self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES)
|
||||
self._attr_preset_modes: list[str] | None = config.get(CONF_PRESET_MODES)
|
||||
|
||||
self._attr_supported_features |= (
|
||||
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
|
||||
@@ -240,51 +238,21 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
if (action_config := config.get(action_id)) is not None:
|
||||
yield (action_id, action_config, supported_feature)
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
"""Return the number of speeds the fan supports."""
|
||||
return self._speed_count or 100
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Get the list of available preset modes."""
|
||||
return self._preset_modes
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if device is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
return self._preset_mode
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current speed percentage."""
|
||||
return self._percentage
|
||||
|
||||
@property
|
||||
def oscillating(self) -> bool | None:
|
||||
"""Return the oscillation state."""
|
||||
return self._oscillating
|
||||
|
||||
@property
|
||||
def current_direction(self) -> str | None:
|
||||
"""Return the oscillation state."""
|
||||
return self._direction
|
||||
return self._attr_is_on
|
||||
|
||||
def _handle_state(self, result) -> None:
|
||||
if isinstance(result, bool):
|
||||
self._state = result
|
||||
self._attr_is_on = result
|
||||
return
|
||||
|
||||
if isinstance(result, str):
|
||||
self._state = result.lower() in ("true", STATE_ON)
|
||||
self._attr_is_on = result.lower() in ("true", STATE_ON)
|
||||
return
|
||||
|
||||
self._state = False
|
||||
self._attr_is_on = False
|
||||
|
||||
@callback
|
||||
def _update_percentage(self, percentage):
|
||||
@@ -297,18 +265,18 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
percentage,
|
||||
self.entity_id,
|
||||
)
|
||||
self._percentage = 0
|
||||
self._attr_percentage = 0
|
||||
return
|
||||
|
||||
if 0 <= percentage <= 100:
|
||||
self._percentage = percentage
|
||||
self._attr_percentage = percentage
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Received invalid percentage: %s for entity %s",
|
||||
percentage,
|
||||
self.entity_id,
|
||||
)
|
||||
self._percentage = 0
|
||||
self._attr_percentage = 0
|
||||
|
||||
@callback
|
||||
def _update_preset_mode(self, preset_mode):
|
||||
@@ -316,9 +284,9 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
preset_mode = str(preset_mode)
|
||||
|
||||
if self.preset_modes and preset_mode in self.preset_modes:
|
||||
self._preset_mode = preset_mode
|
||||
self._attr_preset_mode = preset_mode
|
||||
elif preset_mode in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
self._preset_mode = None
|
||||
self._attr_preset_mode = None
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Received invalid preset_mode: %s for entity %s. Expected: %s",
|
||||
@@ -326,32 +294,32 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
self.entity_id,
|
||||
self.preset_mode,
|
||||
)
|
||||
self._preset_mode = None
|
||||
self._attr_preset_mode = None
|
||||
|
||||
@callback
|
||||
def _update_oscillating(self, oscillating):
|
||||
# Validate osc
|
||||
if oscillating == "True" or oscillating is True:
|
||||
self._oscillating = True
|
||||
self._attr_oscillating = True
|
||||
elif oscillating == "False" or oscillating is False:
|
||||
self._oscillating = False
|
||||
self._attr_oscillating = False
|
||||
elif oscillating in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
self._oscillating = None
|
||||
self._attr_oscillating = None
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Received invalid oscillating: %s for entity %s. Expected: True/False",
|
||||
oscillating,
|
||||
self.entity_id,
|
||||
)
|
||||
self._oscillating = None
|
||||
self._attr_oscillating = None
|
||||
|
||||
@callback
|
||||
def _update_direction(self, direction):
|
||||
# Validate direction
|
||||
if direction in _VALID_DIRECTIONS:
|
||||
self._direction = direction
|
||||
self._attr_current_direction = direction
|
||||
elif direction in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
self._direction = None
|
||||
self._attr_current_direction = None
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Received invalid direction: %s for entity %s. Expected: %s",
|
||||
@@ -359,7 +327,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
self.entity_id,
|
||||
", ".join(_VALID_DIRECTIONS),
|
||||
)
|
||||
self._direction = None
|
||||
self._attr_current_direction = None
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
@@ -383,7 +351,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
if self._attr_assumed_state:
|
||||
self._state = True
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -393,46 +361,46 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
)
|
||||
|
||||
if self._attr_assumed_state:
|
||||
self._state = False
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the percentage speed of the fan."""
|
||||
self._percentage = percentage
|
||||
self._attr_percentage = percentage
|
||||
|
||||
if script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION):
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={ATTR_PERCENTAGE: self._percentage},
|
||||
run_variables={ATTR_PERCENTAGE: self._attr_percentage},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
if self._attr_assumed_state:
|
||||
self._state = percentage != 0
|
||||
self._attr_is_on = percentage != 0
|
||||
|
||||
if self._attr_assumed_state or self._percentage_template is None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset_mode of the fan."""
|
||||
self._preset_mode = preset_mode
|
||||
self._attr_preset_mode = preset_mode
|
||||
|
||||
if script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION):
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={ATTR_PRESET_MODE: self._preset_mode},
|
||||
run_variables={ATTR_PRESET_MODE: self._attr_preset_mode},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
if self._attr_assumed_state:
|
||||
self._state = True
|
||||
self._attr_is_on = True
|
||||
|
||||
if self._attr_assumed_state or self._preset_mode_template is None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Set oscillation of the fan."""
|
||||
self._oscillating = oscillating
|
||||
self._attr_oscillating = oscillating
|
||||
if (
|
||||
script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION)
|
||||
) is not None:
|
||||
@@ -448,7 +416,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
async def async_set_direction(self, direction: str) -> None:
|
||||
"""Set the direction of the fan."""
|
||||
if direction in _VALID_DIRECTIONS:
|
||||
self._direction = direction
|
||||
self._attr_current_direction = direction
|
||||
if (
|
||||
script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION)
|
||||
) is not None:
|
||||
@@ -496,7 +464,7 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan):
|
||||
def _update_state(self, result):
|
||||
super()._update_state(result)
|
||||
if isinstance(result, TemplateError):
|
||||
self._state = None
|
||||
self._attr_is_on = None
|
||||
return
|
||||
|
||||
self._handle_state(result)
|
||||
@@ -506,12 +474,12 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan):
|
||||
"""Set up templates."""
|
||||
if self._template:
|
||||
self.add_template_attribute(
|
||||
"_state", self._template, None, self._update_state
|
||||
"_attr_is_on", self._template, None, self._update_state
|
||||
)
|
||||
|
||||
if self._preset_mode_template is not None:
|
||||
self.add_template_attribute(
|
||||
"_preset_mode",
|
||||
"_attr_preset_mode",
|
||||
self._preset_mode_template,
|
||||
None,
|
||||
self._update_preset_mode,
|
||||
@@ -519,7 +487,7 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan):
|
||||
)
|
||||
if self._percentage_template is not None:
|
||||
self.add_template_attribute(
|
||||
"_percentage",
|
||||
"_attr_percentage",
|
||||
self._percentage_template,
|
||||
None,
|
||||
self._update_percentage,
|
||||
@@ -527,7 +495,7 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan):
|
||||
)
|
||||
if self._oscillating_template is not None:
|
||||
self.add_template_attribute(
|
||||
"_oscillating",
|
||||
"_attr_oscillating",
|
||||
self._oscillating_template,
|
||||
None,
|
||||
self._update_oscillating,
|
||||
@@ -535,7 +503,7 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan):
|
||||
)
|
||||
if self._direction_template is not None:
|
||||
self.add_template_attribute(
|
||||
"_direction",
|
||||
"_attr_current_direction",
|
||||
self._direction_template,
|
||||
None,
|
||||
self._update_direction,
|
||||
|
||||
@@ -154,12 +154,12 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
|
||||
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
|
||||
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
|
||||
"""Initialize the features."""
|
||||
|
||||
self._state: LockState | None = None
|
||||
self._code_format_template = config.get(CONF_CODE_FORMAT)
|
||||
self._code_format: str | None = None
|
||||
self._code_format_template_error: TemplateError | None = None
|
||||
|
||||
# Legacy behavior, create all locks as Unlocked.
|
||||
self._set_state(LockState.UNLOCKED)
|
||||
|
||||
def _iterate_scripts(
|
||||
self, config: dict[str, Any]
|
||||
) -> Generator[tuple[str, Sequence[dict[str, Any]], LockEntityFeature | int]]:
|
||||
@@ -171,44 +171,17 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
|
||||
if (action_config := config.get(action_id)) is not None:
|
||||
yield (action_id, action_config, supported_feature)
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool:
|
||||
"""Return true if lock is locked."""
|
||||
return self._state == LockState.LOCKED
|
||||
|
||||
@property
|
||||
def is_jammed(self) -> bool:
|
||||
"""Return true if lock is jammed."""
|
||||
return self._state == LockState.JAMMED
|
||||
|
||||
@property
|
||||
def is_unlocking(self) -> bool:
|
||||
"""Return true if lock is unlocking."""
|
||||
return self._state == LockState.UNLOCKING
|
||||
|
||||
@property
|
||||
def is_locking(self) -> bool:
|
||||
"""Return true if lock is locking."""
|
||||
return self._state == LockState.LOCKING
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Return true if lock is open."""
|
||||
return self._state == LockState.OPEN
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
"""Return true if lock is opening."""
|
||||
return self._state == LockState.OPENING
|
||||
|
||||
@property
|
||||
def code_format(self) -> str | None:
|
||||
"""Regex for code format or None if no code is required."""
|
||||
return self._code_format
|
||||
def _set_state(self, state: LockState | None) -> None:
|
||||
self._attr_is_jammed = state == LockState.JAMMED
|
||||
self._attr_is_opening = state == LockState.OPENING
|
||||
self._attr_is_locking = state == LockState.LOCKING
|
||||
self._attr_is_open = state == LockState.OPEN
|
||||
self._attr_is_unlocking = state == LockState.UNLOCKING
|
||||
self._attr_is_locked = state == LockState.LOCKED
|
||||
|
||||
def _handle_state(self, result: Any) -> None:
|
||||
if isinstance(result, bool):
|
||||
self._state = LockState.LOCKED if result else LockState.UNLOCKED
|
||||
self._set_state(LockState.LOCKED if result else LockState.UNLOCKED)
|
||||
return
|
||||
|
||||
if isinstance(result, str):
|
||||
@@ -217,33 +190,33 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
|
||||
"on",
|
||||
"locked",
|
||||
):
|
||||
self._state = LockState.LOCKED
|
||||
self._set_state(LockState.LOCKED)
|
||||
elif result.lower() in (
|
||||
"false",
|
||||
"off",
|
||||
"unlocked",
|
||||
):
|
||||
self._state = LockState.UNLOCKED
|
||||
self._set_state(LockState.UNLOCKED)
|
||||
else:
|
||||
try:
|
||||
self._state = LockState(result.lower())
|
||||
self._set_state(LockState(result.lower()))
|
||||
except ValueError:
|
||||
self._state = None
|
||||
self._set_state(None)
|
||||
return
|
||||
|
||||
self._state = None
|
||||
self._set_state(None)
|
||||
|
||||
@callback
|
||||
def _update_code_format(self, render: str | TemplateError | None):
|
||||
"""Update code format from the template."""
|
||||
if isinstance(render, TemplateError):
|
||||
self._code_format = None
|
||||
self._attr_code_format = None
|
||||
self._code_format_template_error = render
|
||||
elif render in (None, "None", ""):
|
||||
self._code_format = None
|
||||
self._attr_code_format = None
|
||||
self._code_format_template_error = None
|
||||
else:
|
||||
self._code_format = render
|
||||
self._attr_code_format = render
|
||||
self._code_format_template_error = None
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
@@ -253,7 +226,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
|
||||
self._raise_template_error_if_available()
|
||||
|
||||
if self._attr_assumed_state:
|
||||
self._state = LockState.LOCKED
|
||||
self._set_state(LockState.LOCKED)
|
||||
self.async_write_ha_state()
|
||||
|
||||
tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
|
||||
@@ -271,7 +244,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
|
||||
self._raise_template_error_if_available()
|
||||
|
||||
if self._attr_assumed_state:
|
||||
self._state = LockState.UNLOCKED
|
||||
self._set_state(LockState.UNLOCKED)
|
||||
self.async_write_ha_state()
|
||||
|
||||
tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
|
||||
@@ -289,7 +262,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
|
||||
self._raise_template_error_if_available()
|
||||
|
||||
if self._attr_assumed_state:
|
||||
self._state = LockState.OPEN
|
||||
self._set_state(LockState.OPEN)
|
||||
self.async_write_ha_state()
|
||||
|
||||
tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
|
||||
@@ -343,7 +316,7 @@ class StateLockEntity(TemplateEntity, AbstractTemplateLock):
|
||||
"""Update the state from the template."""
|
||||
super()._update_state(result)
|
||||
if isinstance(result, TemplateError):
|
||||
self._state = None
|
||||
self._attr_is_locked = None
|
||||
return
|
||||
|
||||
self._handle_state(result)
|
||||
@@ -353,14 +326,14 @@ class StateLockEntity(TemplateEntity, AbstractTemplateLock):
|
||||
"""Set up templates."""
|
||||
if self._template is not None:
|
||||
self.add_template_attribute(
|
||||
"_state",
|
||||
"_attr_is_locked",
|
||||
self._template,
|
||||
None,
|
||||
self._update_state,
|
||||
)
|
||||
if self._code_format_template:
|
||||
self.add_template_attribute(
|
||||
"_code_format_template",
|
||||
"_attr_code_format",
|
||||
self._code_format_template,
|
||||
None,
|
||||
self._update_code_format,
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["tuya_sharing"],
|
||||
"requirements": ["tuya-device-sharing-sdk==0.2.5"]
|
||||
"requirements": ["tuya-device-sharing-sdk==0.2.6"]
|
||||
}
|
||||
|
||||
@@ -52,26 +52,6 @@ def parse_dptype(dptype: str) -> DPType | None:
|
||||
return _DPTYPE_MAPPING.get(dptype)
|
||||
|
||||
|
||||
def get_dptype(
|
||||
device: CustomerDevice, dpcode: DPCode | None, *, prefer_function: bool = False
|
||||
) -> DPType | None:
|
||||
"""Find a matching DPType type information for this device DPCode."""
|
||||
if dpcode is None:
|
||||
return None
|
||||
|
||||
lookup_tuple = (
|
||||
(device.function, device.status_range)
|
||||
if prefer_function
|
||||
else (device.status_range, device.function)
|
||||
)
|
||||
|
||||
for device_specs in lookup_tuple:
|
||||
if current_definition := device_specs.get(dpcode):
|
||||
return parse_dptype(current_definition.type)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def remap_value(
|
||||
value: float,
|
||||
from_min: float = 0,
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect", "unifi_discovery"],
|
||||
"requirements": ["uiprotect==7.26.0", "unifi-discovery==1.2.0"],
|
||||
"requirements": ["uiprotect==7.28.0", "unifi-discovery==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -17,7 +17,6 @@ from uiprotect.data import (
|
||||
ProtectAdoptableDeviceModel,
|
||||
ProtectDeviceModel,
|
||||
Sensor,
|
||||
SmartDetectObjectType,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -533,18 +532,6 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
LICENSE_PLATE_EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = (
|
||||
ProtectSensorEventEntityDescription(
|
||||
key="smart_obj_licenseplate",
|
||||
icon="mdi:car",
|
||||
translation_key="license_plate_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.LICENSE_PLATE,
|
||||
ufp_required_field="can_detect_license_plate",
|
||||
ufp_event_obj="last_license_plate_detect_event",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
|
||||
ProtectSensorEntityDescription(
|
||||
key="motion_last_trip_time",
|
||||
@@ -680,20 +667,6 @@ def _async_event_entities(
|
||||
camera.display_name,
|
||||
)
|
||||
|
||||
if not camera.feature_flags.has_smart_detect:
|
||||
continue
|
||||
|
||||
for event_desc in LICENSE_PLATE_EVENT_SENSORS:
|
||||
if not event_desc.has_required(camera):
|
||||
continue
|
||||
|
||||
entities.append(ProtectLicensePlateEventSensor(data, camera, event_desc))
|
||||
_LOGGER.debug(
|
||||
"Adding sensor entity %s for %s",
|
||||
description.name,
|
||||
camera.display_name,
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
@@ -738,40 +711,3 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity):
|
||||
"_attr_native_value",
|
||||
"_attr_extra_state_attributes",
|
||||
)
|
||||
|
||||
|
||||
class ProtectLicensePlateEventSensor(ProtectEventSensor):
|
||||
"""A UniFi Protect license plate sensor."""
|
||||
|
||||
device: Camera
|
||||
|
||||
@callback
|
||||
def _set_event_done(self) -> None:
|
||||
self._attr_native_value = OBJECT_TYPE_NONE
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
@callback
|
||||
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
|
||||
description = self.entity_description
|
||||
|
||||
prev_event = self._event
|
||||
prev_event_end = self._event_end
|
||||
super()._async_update_device_from_protect(device)
|
||||
if event := description.get_event_obj(device):
|
||||
self._event = event
|
||||
self._event_end = event.end
|
||||
|
||||
if not (
|
||||
event
|
||||
and (metadata := event.metadata)
|
||||
and (license_plate := metadata.license_plate)
|
||||
and description.has_matching_smart(event)
|
||||
and not self._event_already_ended(prev_event, prev_event_end)
|
||||
):
|
||||
self._set_event_done()
|
||||
return
|
||||
|
||||
self._attr_native_value = license_plate.name
|
||||
self._set_event_attrs(event)
|
||||
if event.end:
|
||||
self._async_event_with_immediate_end()
|
||||
|
||||
@@ -37,7 +37,7 @@ rules:
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: cleanup mock_config_entry vs mock_user_config_entry, cleanup mock_pyvlx vs mock_velux_client, remove unused freezer in test_cover_closed, add tests where missing
|
||||
comment: add tests where missing
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from pythonxbox.api.provider.smartglass import SmartglassProvider
|
||||
from pythonxbox.api.provider.smartglass.models import InputKeyType, PowerState
|
||||
|
||||
from homeassistant.components.remote import (
|
||||
@@ -22,6 +23,22 @@ from .entity import XboxConsoleBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
MAP_COMMAND: dict[str, Callable[[SmartglassProvider], Callable]] = {
|
||||
"WakeUp": lambda x: x.wake_up,
|
||||
"TurnOff": lambda x: x.turn_off,
|
||||
"Reboot": lambda x: x.reboot,
|
||||
"Mute": lambda x: x.mute,
|
||||
"Unmute": lambda x: x.unmute,
|
||||
"Play": lambda x: x.play,
|
||||
"Pause": lambda x: x.pause,
|
||||
"Previous": lambda x: x.previous,
|
||||
"Next": lambda x: x.next,
|
||||
"GoHome": lambda x: x.go_home,
|
||||
"GoBack": lambda x: x.go_back,
|
||||
"ShowGuideTab": lambda x: x.show_guide_tab,
|
||||
"ShowGuide": lambda x: x.show_tv_guide,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -59,10 +76,14 @@ class XboxRemote(XboxConsoleBaseEntity, RemoteEntity):
|
||||
|
||||
for _ in range(num_repeats):
|
||||
for single_command in command:
|
||||
try:
|
||||
if single_command in InputKeyType:
|
||||
button = InputKeyType(single_command)
|
||||
await self.client.smartglass.press_button(self._console.id, button)
|
||||
except ValueError:
|
||||
elif single_command in MAP_COMMAND:
|
||||
await MAP_COMMAND[single_command](self.client.smartglass)(
|
||||
self._console.id
|
||||
)
|
||||
else:
|
||||
await self.client.smartglass.insert_text(
|
||||
self._console.id, single_command
|
||||
)
|
||||
|
||||
@@ -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`
|
||||
|
||||
7
homeassistant/generated/labs.py
generated
7
homeassistant/generated/labs.py
generated
@@ -4,6 +4,13 @@ To update, run python3 -m script.hassfest
|
||||
"""
|
||||
|
||||
LABS_PREVIEW_FEATURES = {
|
||||
"frontend": {
|
||||
"winter_mode": {
|
||||
"feedback_url": "",
|
||||
"learn_more_url": "",
|
||||
"report_issue_url": "",
|
||||
},
|
||||
},
|
||||
"kitchen_sink": {
|
||||
"special_repair": {
|
||||
"feedback_url": "https://community.home-assistant.io",
|
||||
|
||||
@@ -30,17 +30,17 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==46.0.2
|
||||
dbus-fast==3.0.0
|
||||
dbus-fast==3.1.2
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.3.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==5.7.0
|
||||
hass-nabucasa==1.5.1
|
||||
hass-nabucasa==1.6.0
|
||||
hassil==3.4.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20251105.1
|
||||
home-assistant-intents==2025.11.7
|
||||
home-assistant-intents==2025.11.24
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
"fnv-hash-fast==1.6.0",
|
||||
# hass-nabucasa is imported by helpers which don't depend on the cloud
|
||||
# integration
|
||||
"hass-nabucasa==1.5.1",
|
||||
"hass-nabucasa==1.6.0",
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
"httpx==0.28.1",
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -22,7 +22,7 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
fnv-hash-fast==1.6.0
|
||||
hass-nabucasa==1.5.1
|
||||
hass-nabucasa==1.6.0
|
||||
httpx==0.28.1
|
||||
home-assistant-bluetooth==1.13.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
24
requirements_all.txt
generated
24
requirements_all.txt
generated
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.2
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==9.0.2
|
||||
aioamazondevices==9.0.3
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==42.7.0
|
||||
aioesphomeapi==42.8.0
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -776,7 +776,7 @@ datadog==0.52.0
|
||||
datapoint==0.12.1
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
dbus-fast==3.0.0
|
||||
dbus-fast==3.1.2
|
||||
|
||||
# homeassistant.components.debugpy
|
||||
debugpy==1.8.16
|
||||
@@ -872,7 +872,7 @@ elgato==5.1.2
|
||||
eliqonline==1.2.2
|
||||
|
||||
# homeassistant.components.elkm1
|
||||
elkm1-lib==2.2.12
|
||||
elkm1-lib==2.2.13
|
||||
|
||||
# homeassistant.components.inels
|
||||
elkoep-aio-mqtt==0.1.0b4
|
||||
@@ -1151,7 +1151,7 @@ habluetooth==5.7.0
|
||||
hanna-cloud==0.0.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.5.1
|
||||
hass-nabucasa==1.6.0
|
||||
|
||||
# homeassistant.components.splunk
|
||||
hass-splunk==0.1.1
|
||||
@@ -1195,7 +1195,7 @@ holidays==0.84
|
||||
home-assistant-frontend==20251105.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.11.7
|
||||
home-assistant-intents==2025.11.24
|
||||
|
||||
# homeassistant.components.homematicip_cloud
|
||||
homematicip==2.4.0
|
||||
@@ -1258,7 +1258,7 @@ imeon_inverter_api==0.4.0
|
||||
imgw_pib==1.5.6
|
||||
|
||||
# homeassistant.components.incomfort
|
||||
incomfort-client==0.6.9
|
||||
incomfort-client==0.6.10
|
||||
|
||||
# homeassistant.components.influxdb
|
||||
influxdb-client==1.48.0
|
||||
@@ -2136,7 +2136,7 @@ pykwb==0.0.8
|
||||
pylacrosse==0.4
|
||||
|
||||
# homeassistant.components.lamarzocco
|
||||
pylamarzocco==2.2.0
|
||||
pylamarzocco==2.2.2
|
||||
|
||||
# homeassistant.components.lastfm
|
||||
pylast==5.1.0
|
||||
@@ -2391,7 +2391,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==0.9.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.3.4
|
||||
pysmartthings==3.4.0
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
@@ -2403,7 +2403,7 @@ pysmhi==1.1.0
|
||||
pysml==0.1.5
|
||||
|
||||
# homeassistant.components.smlight
|
||||
pysmlight==0.2.8
|
||||
pysmlight==0.2.11
|
||||
|
||||
# homeassistant.components.snmp
|
||||
pysnmp==7.1.22
|
||||
@@ -3023,7 +3023,7 @@ ttls==1.8.3
|
||||
ttn_client==1.2.3
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-sharing-sdk==0.2.5
|
||||
tuya-device-sharing-sdk==0.2.6
|
||||
|
||||
# homeassistant.components.twentemilieu
|
||||
twentemilieu==2.2.1
|
||||
@@ -3041,7 +3041,7 @@ typedmonarchmoney==0.4.4
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==7.26.0
|
||||
uiprotect==7.28.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
|
||||
24
requirements_test_all.txt
generated
24
requirements_test_all.txt
generated
@@ -178,7 +178,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.2
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==9.0.2
|
||||
aioamazondevices==9.0.3
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -240,7 +240,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==42.7.0
|
||||
aioesphomeapi==42.8.0
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -682,7 +682,7 @@ datadog==0.52.0
|
||||
datapoint==0.12.1
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
dbus-fast==3.0.0
|
||||
dbus-fast==3.1.2
|
||||
|
||||
# homeassistant.components.debugpy
|
||||
debugpy==1.8.16
|
||||
@@ -763,7 +763,7 @@ elevenlabs==2.3.0
|
||||
elgato==5.1.2
|
||||
|
||||
# homeassistant.components.elkm1
|
||||
elkm1-lib==2.2.12
|
||||
elkm1-lib==2.2.13
|
||||
|
||||
# homeassistant.components.inels
|
||||
elkoep-aio-mqtt==0.1.0b4
|
||||
@@ -1015,7 +1015,7 @@ habluetooth==5.7.0
|
||||
hanna-cloud==0.0.6
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.5.1
|
||||
hass-nabucasa==1.6.0
|
||||
|
||||
# homeassistant.components.assist_satellite
|
||||
# homeassistant.components.conversation
|
||||
@@ -1047,7 +1047,7 @@ holidays==0.84
|
||||
home-assistant-frontend==20251105.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.11.7
|
||||
home-assistant-intents==2025.11.24
|
||||
|
||||
# homeassistant.components.homematicip_cloud
|
||||
homematicip==2.4.0
|
||||
@@ -1098,7 +1098,7 @@ imeon_inverter_api==0.4.0
|
||||
imgw_pib==1.5.6
|
||||
|
||||
# homeassistant.components.incomfort
|
||||
incomfort-client==0.6.9
|
||||
incomfort-client==0.6.10
|
||||
|
||||
# homeassistant.components.influxdb
|
||||
influxdb-client==1.48.0
|
||||
@@ -1786,7 +1786,7 @@ pykrakenapi==0.1.8
|
||||
pykulersky==0.5.8
|
||||
|
||||
# homeassistant.components.lamarzocco
|
||||
pylamarzocco==2.2.0
|
||||
pylamarzocco==2.2.2
|
||||
|
||||
# homeassistant.components.lastfm
|
||||
pylast==5.1.0
|
||||
@@ -1999,7 +1999,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==0.9.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.3.4
|
||||
pysmartthings==3.4.0
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
@@ -2011,7 +2011,7 @@ pysmhi==1.1.0
|
||||
pysml==0.1.5
|
||||
|
||||
# homeassistant.components.smlight
|
||||
pysmlight==0.2.8
|
||||
pysmlight==0.2.11
|
||||
|
||||
# homeassistant.components.snmp
|
||||
pysnmp==7.1.22
|
||||
@@ -2502,7 +2502,7 @@ ttls==1.8.3
|
||||
ttn_client==1.2.3
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-sharing-sdk==0.2.5
|
||||
tuya-device-sharing-sdk==0.2.6
|
||||
|
||||
# homeassistant.components.twentemilieu
|
||||
twentemilieu==2.2.1
|
||||
@@ -2520,7 +2520,7 @@ typedmonarchmoney==0.4.4
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==7.26.0
|
||||
uiprotect==7.28.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
|
||||
@@ -4,7 +4,6 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant import core
|
||||
from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION as GO2RTC_VERSION
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.util import executor, thread
|
||||
from script.gen_requirements_all import gather_recursive_requirements
|
||||
@@ -12,6 +11,10 @@ from script.gen_requirements_all import gather_recursive_requirements
|
||||
from .model import Config, Integration
|
||||
from .requirements import PACKAGE_REGEX, PIP_VERSION_RANGE_SEPARATOR
|
||||
|
||||
_GO2RTC_SHA = (
|
||||
"baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640" # 1.9.12
|
||||
)
|
||||
|
||||
DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest.
|
||||
#
|
||||
# To update, run python3 -m script.hassfest -p docker
|
||||
@@ -29,21 +32,14 @@ ARG QEMU_CPU
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
# Needs to be redefined inside the FROM statement to be set for RUN commands
|
||||
ARG BUILD_ARCH
|
||||
# Get go2rtc binary
|
||||
RUN \
|
||||
case "${{BUILD_ARCH}}" in \
|
||||
"aarch64") go2rtc_suffix='arm64' ;; \
|
||||
*) go2rtc_suffix=${{BUILD_ARCH}} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
# Add go2rtc binary
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:{go2rtc} /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv=={uv}
|
||||
RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
# Install uv
|
||||
&& pip3 install uv=={uv}
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
@@ -164,8 +160,6 @@ def _generate_hassfest_dockerimage(
|
||||
packages.update(
|
||||
gather_recursive_requirements(platform.value, already_checked_domains)
|
||||
)
|
||||
# Add go2rtc requirements as this file needs the go2rtc integration
|
||||
packages.update(gather_recursive_requirements("go2rtc", already_checked_domains))
|
||||
|
||||
return File(
|
||||
_HASSFEST_TEMPLATE.format(
|
||||
@@ -201,7 +195,7 @@ def _generate_files(config: Config) -> list[File]:
|
||||
DOCKERFILE_TEMPLATE.format(
|
||||
timeout=timeout,
|
||||
**package_versions,
|
||||
go2rtc=GO2RTC_VERSION,
|
||||
go2rtc=_GO2RTC_SHA,
|
||||
),
|
||||
config.root / "Dockerfile",
|
||||
),
|
||||
|
||||
3
script/hassfest/docker/Dockerfile
generated
3
script/hassfest/docker/Dockerfile
generated
@@ -29,10 +29,9 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \
|
||||
tqdm==4.67.1 \
|
||||
ruff==0.13.0 \
|
||||
PyTurboJPEG==1.8.0 \
|
||||
go2rtc-client==0.3.0 \
|
||||
ha-ffmpeg==3.2.2 \
|
||||
hassil==3.4.0 \
|
||||
home-assistant-intents==2025.11.7 \
|
||||
home-assistant-intents==2025.11.24 \
|
||||
mutagen==1.47.0 \
|
||||
pymicro-vad==1.0.1 \
|
||||
pyspeex-noise==1.0.2
|
||||
|
||||
@@ -6,16 +6,19 @@
|
||||
You are a Home Assistant expert and help users with their tasks.
|
||||
Current time is 15:59:00. Today's date is 2025-06-14.
|
||||
''',
|
||||
'created': HAFakeDatetime(2025, 6, 14, 22, 59, tzinfo=datetime.timezone.utc),
|
||||
'role': 'system',
|
||||
}),
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': 'Test prompt',
|
||||
'created': HAFakeDatetime(2025, 6, 14, 22, 59, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'ai_task.test_task_entity',
|
||||
'content': 'Mock result',
|
||||
'created': HAFakeDatetime(2025, 6, 14, 22, 59, tzinfo=datetime.timezone.utc),
|
||||
'native': None,
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
|
||||
@@ -9,16 +9,19 @@
|
||||
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
|
||||
Current time is 16:00:00. Today's date is 2024-06-03.
|
||||
''',
|
||||
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'system',
|
||||
}),
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': 'Please call the test function',
|
||||
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': None,
|
||||
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'),
|
||||
'role': 'assistant',
|
||||
'thinking_content': 'The user asked me to call a test function.Is it a test? What would the function do? Would it violate any privacy or security policies?',
|
||||
@@ -27,6 +30,7 @@
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': None,
|
||||
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'),
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
@@ -35,6 +39,7 @@
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': 'Certainly, calling it now!',
|
||||
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'),
|
||||
'role': 'assistant',
|
||||
'thinking_content': "Okay, let's give it a shot. Will I pass the test?",
|
||||
@@ -51,6 +56,7 @@
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'tool_result',
|
||||
'tool_call_id': 'toolu_0123456789AbCdEfGhIjKlM',
|
||||
'tool_name': 'test_tool',
|
||||
@@ -59,6 +65,7 @@
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': 'I have successfully called the function',
|
||||
'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': None,
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
@@ -460,11 +467,13 @@
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': 'ANTHROPIC_MAGIC_STRING_TRIGGER_REDACTED_THINKING_46C9A13E193C177646C7398A98432ECCCE4C1253D5E2D82641AC0E52CC2876CB',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': None,
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'),
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
@@ -473,6 +482,7 @@
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': None,
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'),
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
@@ -481,6 +491,7 @@
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': 'How can I help you today?',
|
||||
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': RedactedThinkingBlock(data='EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', type='redacted_thinking'),
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
@@ -527,11 +538,13 @@
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': "What's on the news today?",
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': "To get today's news, I'll perform a web search",
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': ThinkingBlock(signature='ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', thinking='', type='thinking'),
|
||||
'role': 'assistant',
|
||||
'thinking_content': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.",
|
||||
@@ -548,6 +561,7 @@
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'tool_result',
|
||||
'tool_call_id': 'srvtoolu_12345ABC',
|
||||
'tool_name': 'web_search',
|
||||
@@ -578,6 +592,7 @@
|
||||
2. Something incredible happened
|
||||
Those are the main headlines making news today.
|
||||
''',
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': dict({
|
||||
'citation_details': list([
|
||||
dict({
|
||||
|
||||
@@ -516,6 +516,7 @@ async def test_extended_thinking(
|
||||
assert chat_log.content[2].content == "Hello, how can I help you today?"
|
||||
|
||||
|
||||
@freeze_time("2024-05-24 12:00:00")
|
||||
async def test_redacted_thinking(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_extended_thinking: MockConfigEntry,
|
||||
@@ -618,6 +619,7 @@ async def test_extended_thinking_tool_call(
|
||||
assert mock_create_stream.mock_calls[1][2]["messages"] == snapshot
|
||||
|
||||
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_web_search(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_with_web_search: MockConfigEntry,
|
||||
|
||||
@@ -493,6 +493,7 @@
|
||||
'data': dict({
|
||||
'chat_log_delta': dict({
|
||||
'agent_id': 'test-agent',
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'tool_result',
|
||||
'tool_call_id': 'test_tool_id',
|
||||
'tool_name': 'test_tool',
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Generator
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
|
||||
from freezegun import freeze_time
|
||||
from hassil.recognize import Intent, IntentData, RecognizeResult
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
@@ -1637,6 +1638,7 @@ async def test_pipeline_language_used_instead_of_conversation_language(
|
||||
),
|
||||
],
|
||||
)
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_chat_log_tts_streaming(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
|
||||
@@ -11,6 +11,8 @@ from mozart_api.models import (
|
||||
ListeningMode,
|
||||
ListeningModeFeatures,
|
||||
ListeningModeRef,
|
||||
PairedRemote,
|
||||
PairedRemoteResponse,
|
||||
PlaybackContentMetadata,
|
||||
PlaybackProgress,
|
||||
PlaybackState,
|
||||
@@ -46,6 +48,7 @@ from .const import (
|
||||
TEST_NAME,
|
||||
TEST_NAME_2,
|
||||
TEST_NAME_3,
|
||||
TEST_REMOTE_SERIAL,
|
||||
TEST_SERIAL_NUMBER,
|
||||
TEST_SERIAL_NUMBER_2,
|
||||
TEST_SERIAL_NUMBER_3,
|
||||
@@ -373,7 +376,19 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
repeat="none",
|
||||
shuffle=False,
|
||||
)
|
||||
|
||||
client.get_bluetooth_remotes = AsyncMock()
|
||||
client.get_bluetooth_remotes.return_value = PairedRemoteResponse(
|
||||
items=[
|
||||
PairedRemote(
|
||||
address="",
|
||||
app_version="1.0.0",
|
||||
battery_level=50,
|
||||
connected=True,
|
||||
serial_number=TEST_REMOTE_SERIAL,
|
||||
name="BEORC",
|
||||
)
|
||||
]
|
||||
)
|
||||
client.post_standby = AsyncMock()
|
||||
client.set_current_volume_level = AsyncMock()
|
||||
client.set_volume_mute = AsyncMock()
|
||||
|
||||
@@ -69,8 +69,13 @@ TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.44444444@products.bang-oluf
|
||||
TEST_MEDIA_PLAYER_ENTITY_ID_4 = "media_player.beosound_balance_44444444"
|
||||
TEST_HOST_4 = "192.168.0.4"
|
||||
|
||||
# Beoremote One
|
||||
TEST_REMOTE_SERIAL = "55555555"
|
||||
TEST_REMOTE_SERIAL_PAIRED = f"{TEST_REMOTE_SERIAL}_{TEST_SERIAL_NUMBER}"
|
||||
TEST_REMOTE_SW_VERSION = "1.0.0"
|
||||
|
||||
TEST_BUTTON_EVENT_ENTITY_ID = "event.beosound_balance_11111111_play_pause"
|
||||
TEST_REMOTE_KEY_EVENT_ENTITY_ID = "event.beoremote_one_55555555_11111111_control_play"
|
||||
|
||||
TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local."
|
||||
TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local."
|
||||
|
||||
@@ -11,14 +11,99 @@
|
||||
'event.beosound_balance_11111111_favorite_4',
|
||||
'event.beosound_balance_11111111_previous',
|
||||
'event.beosound_balance_11111111_volume',
|
||||
'event.beoremote_one_55555555_11111111_light_blue',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_0',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_1',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_2',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_3',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_4',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_5',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_6',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_7',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_8',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_9',
|
||||
'event.beoremote_one_55555555_11111111_light_down',
|
||||
'event.beoremote_one_55555555_11111111_light_green',
|
||||
'event.beoremote_one_55555555_11111111_light_left',
|
||||
'event.beoremote_one_55555555_11111111_light_play',
|
||||
'event.beoremote_one_55555555_11111111_light_red',
|
||||
'event.beoremote_one_55555555_11111111_light_rewind',
|
||||
'event.beoremote_one_55555555_11111111_light_right',
|
||||
'event.beoremote_one_55555555_11111111_light_select',
|
||||
'event.beoremote_one_55555555_11111111_light_stop',
|
||||
'event.beoremote_one_55555555_11111111_light_up',
|
||||
'event.beoremote_one_55555555_11111111_light_wind',
|
||||
'event.beoremote_one_55555555_11111111_light_yellow',
|
||||
'event.beoremote_one_55555555_11111111_light_function_1',
|
||||
'event.beoremote_one_55555555_11111111_light_function_2',
|
||||
'event.beoremote_one_55555555_11111111_light_function_3',
|
||||
'event.beoremote_one_55555555_11111111_light_function_4',
|
||||
'event.beoremote_one_55555555_11111111_light_function_5',
|
||||
'event.beoremote_one_55555555_11111111_light_function_6',
|
||||
'event.beoremote_one_55555555_11111111_light_function_7',
|
||||
'event.beoremote_one_55555555_11111111_light_function_8',
|
||||
'event.beoremote_one_55555555_11111111_light_function_9',
|
||||
'event.beoremote_one_55555555_11111111_light_function_10',
|
||||
'event.beoremote_one_55555555_11111111_light_function_11',
|
||||
'event.beoremote_one_55555555_11111111_light_function_12',
|
||||
'event.beoremote_one_55555555_11111111_light_function_13',
|
||||
'event.beoremote_one_55555555_11111111_light_function_14',
|
||||
'event.beoremote_one_55555555_11111111_light_function_15',
|
||||
'event.beoremote_one_55555555_11111111_light_function_16',
|
||||
'event.beoremote_one_55555555_11111111_light_function_17',
|
||||
'event.beoremote_one_55555555_11111111_control_blue',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_0',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_1',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_2',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_3',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_4',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_5',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_6',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_7',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_8',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_9',
|
||||
'event.beoremote_one_55555555_11111111_control_down',
|
||||
'event.beoremote_one_55555555_11111111_control_green',
|
||||
'event.beoremote_one_55555555_11111111_control_left',
|
||||
'event.beoremote_one_55555555_11111111_control_play',
|
||||
'event.beoremote_one_55555555_11111111_control_red',
|
||||
'event.beoremote_one_55555555_11111111_control_rewind',
|
||||
'event.beoremote_one_55555555_11111111_control_right',
|
||||
'event.beoremote_one_55555555_11111111_control_select',
|
||||
'event.beoremote_one_55555555_11111111_control_stop',
|
||||
'event.beoremote_one_55555555_11111111_control_up',
|
||||
'event.beoremote_one_55555555_11111111_control_wind',
|
||||
'event.beoremote_one_55555555_11111111_control_yellow',
|
||||
'event.beoremote_one_55555555_11111111_control_function_1',
|
||||
'event.beoremote_one_55555555_11111111_control_function_2',
|
||||
'event.beoremote_one_55555555_11111111_control_function_3',
|
||||
'event.beoremote_one_55555555_11111111_control_function_4',
|
||||
'event.beoremote_one_55555555_11111111_control_function_5',
|
||||
'event.beoremote_one_55555555_11111111_control_function_6',
|
||||
'event.beoremote_one_55555555_11111111_control_function_7',
|
||||
'event.beoremote_one_55555555_11111111_control_function_8',
|
||||
'event.beoremote_one_55555555_11111111_control_function_9',
|
||||
'event.beoremote_one_55555555_11111111_control_function_10',
|
||||
'event.beoremote_one_55555555_11111111_control_function_11',
|
||||
'event.beoremote_one_55555555_11111111_control_function_12',
|
||||
'event.beoremote_one_55555555_11111111_control_function_13',
|
||||
'event.beoremote_one_55555555_11111111_control_function_14',
|
||||
'event.beoremote_one_55555555_11111111_control_function_15',
|
||||
'event.beoremote_one_55555555_11111111_control_function_16',
|
||||
'event.beoremote_one_55555555_11111111_control_function_17',
|
||||
'event.beoremote_one_55555555_11111111_control_function_18',
|
||||
'event.beoremote_one_55555555_11111111_control_function_19',
|
||||
'event.beoremote_one_55555555_11111111_control_function_20',
|
||||
'event.beoremote_one_55555555_11111111_control_function_21',
|
||||
'event.beoremote_one_55555555_11111111_control_function_22',
|
||||
'event.beoremote_one_55555555_11111111_control_function_23',
|
||||
'event.beoremote_one_55555555_11111111_control_function_24',
|
||||
'event.beoremote_one_55555555_11111111_control_function_25',
|
||||
'event.beoremote_one_55555555_11111111_control_function_26',
|
||||
'event.beoremote_one_55555555_11111111_control_function_27',
|
||||
'media_player.beosound_balance_11111111',
|
||||
])
|
||||
# ---
|
||||
# name: test_button_event_creation_beoconnect_core
|
||||
list([
|
||||
'media_player.beoconnect_core_22222222',
|
||||
])
|
||||
# ---
|
||||
# name: test_button_event_creation_beosound_premiere
|
||||
list([
|
||||
'event.beosound_premiere_33333333_microphone',
|
||||
@@ -30,6 +115,101 @@
|
||||
'event.beosound_premiere_33333333_favorite_4',
|
||||
'event.beosound_premiere_33333333_previous',
|
||||
'event.beosound_premiere_33333333_volume',
|
||||
'event.beoremote_one_55555555_33333333_light_blue',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_0',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_1',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_2',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_3',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_4',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_5',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_6',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_7',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_8',
|
||||
'event.beoremote_one_55555555_33333333_light_digit_9',
|
||||
'event.beoremote_one_55555555_33333333_light_down',
|
||||
'event.beoremote_one_55555555_33333333_light_green',
|
||||
'event.beoremote_one_55555555_33333333_light_left',
|
||||
'event.beoremote_one_55555555_33333333_light_play',
|
||||
'event.beoremote_one_55555555_33333333_light_red',
|
||||
'event.beoremote_one_55555555_33333333_light_rewind',
|
||||
'event.beoremote_one_55555555_33333333_light_right',
|
||||
'event.beoremote_one_55555555_33333333_light_select',
|
||||
'event.beoremote_one_55555555_33333333_light_stop',
|
||||
'event.beoremote_one_55555555_33333333_light_up',
|
||||
'event.beoremote_one_55555555_33333333_light_wind',
|
||||
'event.beoremote_one_55555555_33333333_light_yellow',
|
||||
'event.beoremote_one_55555555_33333333_light_function_1',
|
||||
'event.beoremote_one_55555555_33333333_light_function_2',
|
||||
'event.beoremote_one_55555555_33333333_light_function_3',
|
||||
'event.beoremote_one_55555555_33333333_light_function_4',
|
||||
'event.beoremote_one_55555555_33333333_light_function_5',
|
||||
'event.beoremote_one_55555555_33333333_light_function_6',
|
||||
'event.beoremote_one_55555555_33333333_light_function_7',
|
||||
'event.beoremote_one_55555555_33333333_light_function_8',
|
||||
'event.beoremote_one_55555555_33333333_light_function_9',
|
||||
'event.beoremote_one_55555555_33333333_light_function_10',
|
||||
'event.beoremote_one_55555555_33333333_light_function_11',
|
||||
'event.beoremote_one_55555555_33333333_light_function_12',
|
||||
'event.beoremote_one_55555555_33333333_light_function_13',
|
||||
'event.beoremote_one_55555555_33333333_light_function_14',
|
||||
'event.beoremote_one_55555555_33333333_light_function_15',
|
||||
'event.beoremote_one_55555555_33333333_light_function_16',
|
||||
'event.beoremote_one_55555555_33333333_light_function_17',
|
||||
'event.beoremote_one_55555555_33333333_control_blue',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_0',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_1',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_2',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_3',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_4',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_5',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_6',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_7',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_8',
|
||||
'event.beoremote_one_55555555_33333333_control_digit_9',
|
||||
'event.beoremote_one_55555555_33333333_control_down',
|
||||
'event.beoremote_one_55555555_33333333_control_green',
|
||||
'event.beoremote_one_55555555_33333333_control_left',
|
||||
'event.beoremote_one_55555555_33333333_control_play',
|
||||
'event.beoremote_one_55555555_33333333_control_red',
|
||||
'event.beoremote_one_55555555_33333333_control_rewind',
|
||||
'event.beoremote_one_55555555_33333333_control_right',
|
||||
'event.beoremote_one_55555555_33333333_control_select',
|
||||
'event.beoremote_one_55555555_33333333_control_stop',
|
||||
'event.beoremote_one_55555555_33333333_control_up',
|
||||
'event.beoremote_one_55555555_33333333_control_wind',
|
||||
'event.beoremote_one_55555555_33333333_control_yellow',
|
||||
'event.beoremote_one_55555555_33333333_control_function_1',
|
||||
'event.beoremote_one_55555555_33333333_control_function_2',
|
||||
'event.beoremote_one_55555555_33333333_control_function_3',
|
||||
'event.beoremote_one_55555555_33333333_control_function_4',
|
||||
'event.beoremote_one_55555555_33333333_control_function_5',
|
||||
'event.beoremote_one_55555555_33333333_control_function_6',
|
||||
'event.beoremote_one_55555555_33333333_control_function_7',
|
||||
'event.beoremote_one_55555555_33333333_control_function_8',
|
||||
'event.beoremote_one_55555555_33333333_control_function_9',
|
||||
'event.beoremote_one_55555555_33333333_control_function_10',
|
||||
'event.beoremote_one_55555555_33333333_control_function_11',
|
||||
'event.beoremote_one_55555555_33333333_control_function_12',
|
||||
'event.beoremote_one_55555555_33333333_control_function_13',
|
||||
'event.beoremote_one_55555555_33333333_control_function_14',
|
||||
'event.beoremote_one_55555555_33333333_control_function_15',
|
||||
'event.beoremote_one_55555555_33333333_control_function_16',
|
||||
'event.beoremote_one_55555555_33333333_control_function_17',
|
||||
'event.beoremote_one_55555555_33333333_control_function_18',
|
||||
'event.beoremote_one_55555555_33333333_control_function_19',
|
||||
'event.beoremote_one_55555555_33333333_control_function_20',
|
||||
'event.beoremote_one_55555555_33333333_control_function_21',
|
||||
'event.beoremote_one_55555555_33333333_control_function_22',
|
||||
'event.beoremote_one_55555555_33333333_control_function_23',
|
||||
'event.beoremote_one_55555555_33333333_control_function_24',
|
||||
'event.beoremote_one_55555555_33333333_control_function_25',
|
||||
'event.beoremote_one_55555555_33333333_control_function_26',
|
||||
'event.beoremote_one_55555555_33333333_control_function_27',
|
||||
'media_player.beosound_premiere_33333333',
|
||||
])
|
||||
# ---
|
||||
# name: test_no_button_and_remote_key_event_creation
|
||||
list([
|
||||
'media_player.beoconnect_core_22222222',
|
||||
])
|
||||
# ---
|
||||
|
||||
316
tests/components/bang_olufsen/snapshots/test_websocket.ambr
Normal file
316
tests/components/bang_olufsen/snapshots/test_websocket.ambr
Normal file
@@ -0,0 +1,316 @@
|
||||
# serializer version: 1
|
||||
# name: test_on_remote_control_already_added
|
||||
list([
|
||||
'event.beosound_balance_11111111_bluetooth',
|
||||
'event.beosound_balance_11111111_microphone',
|
||||
'event.beosound_balance_11111111_next',
|
||||
'event.beosound_balance_11111111_play_pause',
|
||||
'event.beosound_balance_11111111_favorite_1',
|
||||
'event.beosound_balance_11111111_favorite_2',
|
||||
'event.beosound_balance_11111111_favorite_3',
|
||||
'event.beosound_balance_11111111_favorite_4',
|
||||
'event.beosound_balance_11111111_previous',
|
||||
'event.beosound_balance_11111111_volume',
|
||||
'event.beoremote_one_55555555_11111111_light_blue',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_0',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_1',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_2',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_3',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_4',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_5',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_6',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_7',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_8',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_9',
|
||||
'event.beoremote_one_55555555_11111111_light_down',
|
||||
'event.beoremote_one_55555555_11111111_light_green',
|
||||
'event.beoremote_one_55555555_11111111_light_left',
|
||||
'event.beoremote_one_55555555_11111111_light_play',
|
||||
'event.beoremote_one_55555555_11111111_light_red',
|
||||
'event.beoremote_one_55555555_11111111_light_rewind',
|
||||
'event.beoremote_one_55555555_11111111_light_right',
|
||||
'event.beoremote_one_55555555_11111111_light_select',
|
||||
'event.beoremote_one_55555555_11111111_light_stop',
|
||||
'event.beoremote_one_55555555_11111111_light_up',
|
||||
'event.beoremote_one_55555555_11111111_light_wind',
|
||||
'event.beoremote_one_55555555_11111111_light_yellow',
|
||||
'event.beoremote_one_55555555_11111111_light_function_1',
|
||||
'event.beoremote_one_55555555_11111111_light_function_2',
|
||||
'event.beoremote_one_55555555_11111111_light_function_3',
|
||||
'event.beoremote_one_55555555_11111111_light_function_4',
|
||||
'event.beoremote_one_55555555_11111111_light_function_5',
|
||||
'event.beoremote_one_55555555_11111111_light_function_6',
|
||||
'event.beoremote_one_55555555_11111111_light_function_7',
|
||||
'event.beoremote_one_55555555_11111111_light_function_8',
|
||||
'event.beoremote_one_55555555_11111111_light_function_9',
|
||||
'event.beoremote_one_55555555_11111111_light_function_10',
|
||||
'event.beoremote_one_55555555_11111111_light_function_11',
|
||||
'event.beoremote_one_55555555_11111111_light_function_12',
|
||||
'event.beoremote_one_55555555_11111111_light_function_13',
|
||||
'event.beoremote_one_55555555_11111111_light_function_14',
|
||||
'event.beoremote_one_55555555_11111111_light_function_15',
|
||||
'event.beoremote_one_55555555_11111111_light_function_16',
|
||||
'event.beoremote_one_55555555_11111111_light_function_17',
|
||||
'event.beoremote_one_55555555_11111111_control_blue',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_0',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_1',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_2',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_3',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_4',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_5',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_6',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_7',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_8',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_9',
|
||||
'event.beoremote_one_55555555_11111111_control_down',
|
||||
'event.beoremote_one_55555555_11111111_control_green',
|
||||
'event.beoremote_one_55555555_11111111_control_left',
|
||||
'event.beoremote_one_55555555_11111111_control_play',
|
||||
'event.beoremote_one_55555555_11111111_control_red',
|
||||
'event.beoremote_one_55555555_11111111_control_rewind',
|
||||
'event.beoremote_one_55555555_11111111_control_right',
|
||||
'event.beoremote_one_55555555_11111111_control_select',
|
||||
'event.beoremote_one_55555555_11111111_control_stop',
|
||||
'event.beoremote_one_55555555_11111111_control_up',
|
||||
'event.beoremote_one_55555555_11111111_control_wind',
|
||||
'event.beoremote_one_55555555_11111111_control_yellow',
|
||||
'event.beoremote_one_55555555_11111111_control_function_1',
|
||||
'event.beoremote_one_55555555_11111111_control_function_2',
|
||||
'event.beoremote_one_55555555_11111111_control_function_3',
|
||||
'event.beoremote_one_55555555_11111111_control_function_4',
|
||||
'event.beoremote_one_55555555_11111111_control_function_5',
|
||||
'event.beoremote_one_55555555_11111111_control_function_6',
|
||||
'event.beoremote_one_55555555_11111111_control_function_7',
|
||||
'event.beoremote_one_55555555_11111111_control_function_8',
|
||||
'event.beoremote_one_55555555_11111111_control_function_9',
|
||||
'event.beoremote_one_55555555_11111111_control_function_10',
|
||||
'event.beoremote_one_55555555_11111111_control_function_11',
|
||||
'event.beoremote_one_55555555_11111111_control_function_12',
|
||||
'event.beoremote_one_55555555_11111111_control_function_13',
|
||||
'event.beoremote_one_55555555_11111111_control_function_14',
|
||||
'event.beoremote_one_55555555_11111111_control_function_15',
|
||||
'event.beoremote_one_55555555_11111111_control_function_16',
|
||||
'event.beoremote_one_55555555_11111111_control_function_17',
|
||||
'event.beoremote_one_55555555_11111111_control_function_18',
|
||||
'event.beoremote_one_55555555_11111111_control_function_19',
|
||||
'event.beoremote_one_55555555_11111111_control_function_20',
|
||||
'event.beoremote_one_55555555_11111111_control_function_21',
|
||||
'event.beoremote_one_55555555_11111111_control_function_22',
|
||||
'event.beoremote_one_55555555_11111111_control_function_23',
|
||||
'event.beoremote_one_55555555_11111111_control_function_24',
|
||||
'event.beoremote_one_55555555_11111111_control_function_25',
|
||||
'event.beoremote_one_55555555_11111111_control_function_26',
|
||||
'event.beoremote_one_55555555_11111111_control_function_27',
|
||||
'media_player.beosound_balance_11111111',
|
||||
])
|
||||
# ---
|
||||
# name: test_on_remote_control_paired
|
||||
list([
|
||||
'event.beosound_balance_11111111_bluetooth',
|
||||
'event.beosound_balance_11111111_microphone',
|
||||
'event.beosound_balance_11111111_next',
|
||||
'event.beosound_balance_11111111_play_pause',
|
||||
'event.beosound_balance_11111111_favorite_1',
|
||||
'event.beosound_balance_11111111_favorite_2',
|
||||
'event.beosound_balance_11111111_favorite_3',
|
||||
'event.beosound_balance_11111111_favorite_4',
|
||||
'event.beosound_balance_11111111_previous',
|
||||
'event.beosound_balance_11111111_volume',
|
||||
'event.beoremote_one_55555555_11111111_light_blue',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_0',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_1',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_2',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_3',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_4',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_5',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_6',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_7',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_8',
|
||||
'event.beoremote_one_55555555_11111111_light_digit_9',
|
||||
'event.beoremote_one_55555555_11111111_light_down',
|
||||
'event.beoremote_one_55555555_11111111_light_green',
|
||||
'event.beoremote_one_55555555_11111111_light_left',
|
||||
'event.beoremote_one_55555555_11111111_light_play',
|
||||
'event.beoremote_one_55555555_11111111_light_red',
|
||||
'event.beoremote_one_55555555_11111111_light_rewind',
|
||||
'event.beoremote_one_55555555_11111111_light_right',
|
||||
'event.beoremote_one_55555555_11111111_light_select',
|
||||
'event.beoremote_one_55555555_11111111_light_stop',
|
||||
'event.beoremote_one_55555555_11111111_light_up',
|
||||
'event.beoremote_one_55555555_11111111_light_wind',
|
||||
'event.beoremote_one_55555555_11111111_light_yellow',
|
||||
'event.beoremote_one_55555555_11111111_light_function_1',
|
||||
'event.beoremote_one_55555555_11111111_light_function_2',
|
||||
'event.beoremote_one_55555555_11111111_light_function_3',
|
||||
'event.beoremote_one_55555555_11111111_light_function_4',
|
||||
'event.beoremote_one_55555555_11111111_light_function_5',
|
||||
'event.beoremote_one_55555555_11111111_light_function_6',
|
||||
'event.beoremote_one_55555555_11111111_light_function_7',
|
||||
'event.beoremote_one_55555555_11111111_light_function_8',
|
||||
'event.beoremote_one_55555555_11111111_light_function_9',
|
||||
'event.beoremote_one_55555555_11111111_light_function_10',
|
||||
'event.beoremote_one_55555555_11111111_light_function_11',
|
||||
'event.beoremote_one_55555555_11111111_light_function_12',
|
||||
'event.beoremote_one_55555555_11111111_light_function_13',
|
||||
'event.beoremote_one_55555555_11111111_light_function_14',
|
||||
'event.beoremote_one_55555555_11111111_light_function_15',
|
||||
'event.beoremote_one_55555555_11111111_light_function_16',
|
||||
'event.beoremote_one_55555555_11111111_light_function_17',
|
||||
'event.beoremote_one_55555555_11111111_control_blue',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_0',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_1',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_2',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_3',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_4',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_5',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_6',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_7',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_8',
|
||||
'event.beoremote_one_55555555_11111111_control_digit_9',
|
||||
'event.beoremote_one_55555555_11111111_control_down',
|
||||
'event.beoremote_one_55555555_11111111_control_green',
|
||||
'event.beoremote_one_55555555_11111111_control_left',
|
||||
'event.beoremote_one_55555555_11111111_control_play',
|
||||
'event.beoremote_one_55555555_11111111_control_red',
|
||||
'event.beoremote_one_55555555_11111111_control_rewind',
|
||||
'event.beoremote_one_55555555_11111111_control_right',
|
||||
'event.beoremote_one_55555555_11111111_control_select',
|
||||
'event.beoremote_one_55555555_11111111_control_stop',
|
||||
'event.beoremote_one_55555555_11111111_control_up',
|
||||
'event.beoremote_one_55555555_11111111_control_wind',
|
||||
'event.beoremote_one_55555555_11111111_control_yellow',
|
||||
'event.beoremote_one_55555555_11111111_control_function_1',
|
||||
'event.beoremote_one_55555555_11111111_control_function_2',
|
||||
'event.beoremote_one_55555555_11111111_control_function_3',
|
||||
'event.beoremote_one_55555555_11111111_control_function_4',
|
||||
'event.beoremote_one_55555555_11111111_control_function_5',
|
||||
'event.beoremote_one_55555555_11111111_control_function_6',
|
||||
'event.beoremote_one_55555555_11111111_control_function_7',
|
||||
'event.beoremote_one_55555555_11111111_control_function_8',
|
||||
'event.beoremote_one_55555555_11111111_control_function_9',
|
||||
'event.beoremote_one_55555555_11111111_control_function_10',
|
||||
'event.beoremote_one_55555555_11111111_control_function_11',
|
||||
'event.beoremote_one_55555555_11111111_control_function_12',
|
||||
'event.beoremote_one_55555555_11111111_control_function_13',
|
||||
'event.beoremote_one_55555555_11111111_control_function_14',
|
||||
'event.beoremote_one_55555555_11111111_control_function_15',
|
||||
'event.beoremote_one_55555555_11111111_control_function_16',
|
||||
'event.beoremote_one_55555555_11111111_control_function_17',
|
||||
'event.beoremote_one_55555555_11111111_control_function_18',
|
||||
'event.beoremote_one_55555555_11111111_control_function_19',
|
||||
'event.beoremote_one_55555555_11111111_control_function_20',
|
||||
'event.beoremote_one_55555555_11111111_control_function_21',
|
||||
'event.beoremote_one_55555555_11111111_control_function_22',
|
||||
'event.beoremote_one_55555555_11111111_control_function_23',
|
||||
'event.beoremote_one_55555555_11111111_control_function_24',
|
||||
'event.beoremote_one_55555555_11111111_control_function_25',
|
||||
'event.beoremote_one_55555555_11111111_control_function_26',
|
||||
'event.beoremote_one_55555555_11111111_control_function_27',
|
||||
'media_player.beosound_balance_11111111',
|
||||
'event.beoremote_one_66666666_11111111_light_blue',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_0',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_1',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_2',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_3',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_4',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_5',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_6',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_7',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_8',
|
||||
'event.beoremote_one_66666666_11111111_light_digit_9',
|
||||
'event.beoremote_one_66666666_11111111_light_down',
|
||||
'event.beoremote_one_66666666_11111111_light_green',
|
||||
'event.beoremote_one_66666666_11111111_light_left',
|
||||
'event.beoremote_one_66666666_11111111_light_play',
|
||||
'event.beoremote_one_66666666_11111111_light_red',
|
||||
'event.beoremote_one_66666666_11111111_light_rewind',
|
||||
'event.beoremote_one_66666666_11111111_light_right',
|
||||
'event.beoremote_one_66666666_11111111_light_select',
|
||||
'event.beoremote_one_66666666_11111111_light_stop',
|
||||
'event.beoremote_one_66666666_11111111_light_up',
|
||||
'event.beoremote_one_66666666_11111111_light_wind',
|
||||
'event.beoremote_one_66666666_11111111_light_yellow',
|
||||
'event.beoremote_one_66666666_11111111_light_function_1',
|
||||
'event.beoremote_one_66666666_11111111_light_function_2',
|
||||
'event.beoremote_one_66666666_11111111_light_function_3',
|
||||
'event.beoremote_one_66666666_11111111_light_function_4',
|
||||
'event.beoremote_one_66666666_11111111_light_function_5',
|
||||
'event.beoremote_one_66666666_11111111_light_function_6',
|
||||
'event.beoremote_one_66666666_11111111_light_function_7',
|
||||
'event.beoremote_one_66666666_11111111_light_function_8',
|
||||
'event.beoremote_one_66666666_11111111_light_function_9',
|
||||
'event.beoremote_one_66666666_11111111_light_function_10',
|
||||
'event.beoremote_one_66666666_11111111_light_function_11',
|
||||
'event.beoremote_one_66666666_11111111_light_function_12',
|
||||
'event.beoremote_one_66666666_11111111_light_function_13',
|
||||
'event.beoremote_one_66666666_11111111_light_function_14',
|
||||
'event.beoremote_one_66666666_11111111_light_function_15',
|
||||
'event.beoremote_one_66666666_11111111_light_function_16',
|
||||
'event.beoremote_one_66666666_11111111_light_function_17',
|
||||
'event.beoremote_one_66666666_11111111_control_blue',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_0',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_1',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_2',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_3',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_4',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_5',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_6',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_7',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_8',
|
||||
'event.beoremote_one_66666666_11111111_control_digit_9',
|
||||
'event.beoremote_one_66666666_11111111_control_down',
|
||||
'event.beoremote_one_66666666_11111111_control_green',
|
||||
'event.beoremote_one_66666666_11111111_control_left',
|
||||
'event.beoremote_one_66666666_11111111_control_play',
|
||||
'event.beoremote_one_66666666_11111111_control_red',
|
||||
'event.beoremote_one_66666666_11111111_control_rewind',
|
||||
'event.beoremote_one_66666666_11111111_control_right',
|
||||
'event.beoremote_one_66666666_11111111_control_select',
|
||||
'event.beoremote_one_66666666_11111111_control_stop',
|
||||
'event.beoremote_one_66666666_11111111_control_up',
|
||||
'event.beoremote_one_66666666_11111111_control_wind',
|
||||
'event.beoremote_one_66666666_11111111_control_yellow',
|
||||
'event.beoremote_one_66666666_11111111_control_function_1',
|
||||
'event.beoremote_one_66666666_11111111_control_function_2',
|
||||
'event.beoremote_one_66666666_11111111_control_function_3',
|
||||
'event.beoremote_one_66666666_11111111_control_function_4',
|
||||
'event.beoremote_one_66666666_11111111_control_function_5',
|
||||
'event.beoremote_one_66666666_11111111_control_function_6',
|
||||
'event.beoremote_one_66666666_11111111_control_function_7',
|
||||
'event.beoremote_one_66666666_11111111_control_function_8',
|
||||
'event.beoremote_one_66666666_11111111_control_function_9',
|
||||
'event.beoremote_one_66666666_11111111_control_function_10',
|
||||
'event.beoremote_one_66666666_11111111_control_function_11',
|
||||
'event.beoremote_one_66666666_11111111_control_function_12',
|
||||
'event.beoremote_one_66666666_11111111_control_function_13',
|
||||
'event.beoremote_one_66666666_11111111_control_function_14',
|
||||
'event.beoremote_one_66666666_11111111_control_function_15',
|
||||
'event.beoremote_one_66666666_11111111_control_function_16',
|
||||
'event.beoremote_one_66666666_11111111_control_function_17',
|
||||
'event.beoremote_one_66666666_11111111_control_function_18',
|
||||
'event.beoremote_one_66666666_11111111_control_function_19',
|
||||
'event.beoremote_one_66666666_11111111_control_function_20',
|
||||
'event.beoremote_one_66666666_11111111_control_function_21',
|
||||
'event.beoremote_one_66666666_11111111_control_function_22',
|
||||
'event.beoremote_one_66666666_11111111_control_function_23',
|
||||
'event.beoremote_one_66666666_11111111_control_function_24',
|
||||
'event.beoremote_one_66666666_11111111_control_function_25',
|
||||
'event.beoremote_one_66666666_11111111_control_function_26',
|
||||
'event.beoremote_one_66666666_11111111_control_function_27',
|
||||
])
|
||||
# ---
|
||||
# name: test_on_remote_control_unpaired
|
||||
list([
|
||||
'event.beosound_balance_11111111_bluetooth',
|
||||
'event.beosound_balance_11111111_microphone',
|
||||
'event.beosound_balance_11111111_next',
|
||||
'event.beosound_balance_11111111_play_pause',
|
||||
'event.beosound_balance_11111111_favorite_1',
|
||||
'event.beosound_balance_11111111_favorite_2',
|
||||
'event.beosound_balance_11111111_favorite_3',
|
||||
'event.beosound_balance_11111111_favorite_4',
|
||||
'event.beosound_balance_11111111_previous',
|
||||
'event.beosound_balance_11111111_volume',
|
||||
'media_player.beosound_balance_11111111',
|
||||
])
|
||||
# ---
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from inflection import underscore
|
||||
from mozart_api.models import ButtonEvent
|
||||
from mozart_api.models import BeoRemoteButton, ButtonEvent, PairedRemoteResponse
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.bang_olufsen.const import (
|
||||
BEO_REMOTE_KEY_EVENTS,
|
||||
DEVICE_BUTTON_EVENTS,
|
||||
DEVICE_BUTTONS,
|
||||
EVENT_TRANSLATION_MAP,
|
||||
BangOlufsenButtons,
|
||||
)
|
||||
from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
@@ -18,7 +16,12 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_registry import EntityRegistry
|
||||
|
||||
from .conftest import mock_websocket_connection
|
||||
from .const import TEST_BUTTON_EVENT_ENTITY_ID
|
||||
from .const import (
|
||||
TEST_BUTTON_EVENT_ENTITY_ID,
|
||||
TEST_REMOTE_KEY_EVENT_ENTITY_ID,
|
||||
TEST_SERIAL_NUMBER_3,
|
||||
)
|
||||
from .util import get_button_entity_ids, get_remote_entity_ids
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -32,12 +35,7 @@ async def test_button_event_creation_balance(
|
||||
"""Test button event entities are created when using a Balance (Most devices support all buttons like the Balance)."""
|
||||
|
||||
# Add Button Event entity ids
|
||||
entity_ids = [
|
||||
f"event.beosound_balance_11111111_{underscore(button_type)}".replace(
|
||||
"preset", "favorite_"
|
||||
)
|
||||
for button_type in DEVICE_BUTTONS
|
||||
]
|
||||
entity_ids: list[str] = [*get_button_entity_ids(), *get_remote_entity_ids()]
|
||||
|
||||
# Check that the entities are available
|
||||
for entity_id in entity_ids:
|
||||
@@ -48,17 +46,21 @@ async def test_button_event_creation_balance(
|
||||
entity_ids_available = list(entity_registry.entities.keys())
|
||||
assert len(entity_ids_available) == 1 + len(entity_ids)
|
||||
|
||||
# Check snapshot
|
||||
assert entity_ids_available == snapshot
|
||||
|
||||
|
||||
async def test_button_event_creation_beoconnect_core(
|
||||
async def test_no_button_and_remote_key_event_creation(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_core: MockConfigEntry,
|
||||
mock_mozart_client: AsyncMock,
|
||||
entity_registry: EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test button event entities are not created when using a Beoconnect Core."""
|
||||
"""Test button event entities are not created when using a Beoconnect Core with no Beoremote One connected."""
|
||||
mock_mozart_client.get_bluetooth_remotes.return_value = PairedRemoteResponse(
|
||||
items=[]
|
||||
)
|
||||
|
||||
# Load entry
|
||||
mock_config_entry_core.add_to_hass(hass)
|
||||
@@ -70,6 +72,7 @@ async def test_button_event_creation_beoconnect_core(
|
||||
entity_ids_available = list(entity_registry.entities.keys())
|
||||
assert len(entity_ids_available) == 1
|
||||
|
||||
# Check snapshot
|
||||
assert entity_ids_available == snapshot
|
||||
|
||||
|
||||
@@ -88,15 +91,11 @@ async def test_button_event_creation_beosound_premiere(
|
||||
await mock_websocket_connection(hass, mock_mozart_client)
|
||||
|
||||
# Add Button Event entity ids
|
||||
premiere_buttons = DEVICE_BUTTONS.copy()
|
||||
premiere_buttons.remove(BangOlufsenButtons.BLUETOOTH.value)
|
||||
|
||||
entity_ids = [
|
||||
f"event.beosound_premiere_33333333_{underscore(button_type)}".replace(
|
||||
"preset", "favorite_"
|
||||
)
|
||||
for button_type in premiere_buttons
|
||||
*get_button_entity_ids("beosound_premiere_33333333"),
|
||||
*get_remote_entity_ids(device_serial=TEST_SERIAL_NUMBER_3),
|
||||
]
|
||||
entity_ids.remove("event.beosound_premiere_33333333_bluetooth")
|
||||
|
||||
# Check that the entities are available
|
||||
for entity_id in entity_ids:
|
||||
@@ -139,3 +138,37 @@ async def test_button(
|
||||
states.attributes[ATTR_EVENT_TYPE]
|
||||
== EVENT_TRANSLATION_MAP["shortPress (Release)"]
|
||||
)
|
||||
|
||||
|
||||
async def test_remote_key(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_mozart_client: AsyncMock,
|
||||
entity_registry: EntityRegistry,
|
||||
) -> None:
|
||||
"""Test remote key event entity."""
|
||||
# Load entry
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
# Enable the entity
|
||||
entity_registry.async_update_entity(
|
||||
TEST_REMOTE_KEY_EVENT_ENTITY_ID, disabled_by=None
|
||||
)
|
||||
hass.config_entries.async_schedule_reload(mock_config_entry.entry_id)
|
||||
|
||||
assert (states := hass.states.get(TEST_REMOTE_KEY_EVENT_ENTITY_ID))
|
||||
assert states.state is STATE_UNKNOWN
|
||||
assert states.attributes[ATTR_EVENT_TYPES] == list(BEO_REMOTE_KEY_EVENTS)
|
||||
|
||||
# Check button reacts as expected to WebSocket events
|
||||
notification_callback = (
|
||||
mock_mozart_client.get_beo_remote_button_notifications.call_args[0][0]
|
||||
)
|
||||
|
||||
notification_callback(BeoRemoteButton(key="Control/Play", type="KeyPress"))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (states := hass.states.get(TEST_REMOTE_KEY_EVENT_ENTITY_ID))
|
||||
assert states.state is not None
|
||||
assert states.attributes[ATTR_EVENT_TYPE] == EVENT_TRANSLATION_MAP["KeyPress"]
|
||||
|
||||
@@ -205,7 +205,7 @@ async def test_async_update_sources_remote(
|
||||
assert mock_mozart_client.get_remote_menu.call_count == 1
|
||||
|
||||
# Send the remote menu Websocket event
|
||||
notification_callback(WebsocketNotificationTag(value="remoteMenuChanged"))
|
||||
await notification_callback(WebsocketNotificationTag(value="remoteMenuChanged"))
|
||||
|
||||
assert mock_mozart_client.get_available_sources.call_count == 2
|
||||
assert mock_mozart_client.get_remote_menu.call_count == 2
|
||||
@@ -293,7 +293,7 @@ async def test_async_update_playback_error(
|
||||
playback_error_callback(TEST_PLAYBACK_ERROR)
|
||||
|
||||
assert (
|
||||
"Exception in _async_update_playback_error when dispatching '11111111_playback_error': (PlaybackError(error='Test error', item=None),)"
|
||||
"Exception in _async_update_playback_error when dispatching 'bang_olufsen_11111111_playback_error': (PlaybackError(error='Test error', item=None),)"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
@@ -485,7 +485,7 @@ async def test_async_update_beolink_line_in(
|
||||
|
||||
# Set source
|
||||
source_change_callback(BangOlufsenSource.LINE_IN)
|
||||
beolink_callback(WebsocketNotificationTag(value="beolinkListeners"))
|
||||
await beolink_callback(WebsocketNotificationTag(value="beolinkListeners"))
|
||||
|
||||
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
|
||||
assert states.attributes["group_members"] == []
|
||||
@@ -561,7 +561,7 @@ async def test_async_update_name_and_beolink(
|
||||
mock_mozart_client.get_notification_notifications.call_args[0][0]
|
||||
)
|
||||
# Trigger callback
|
||||
configuration_callback(WebsocketNotificationTag(value="configuration"))
|
||||
await configuration_callback(WebsocketNotificationTag(value="configuration"))
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user