Compare commits

..

55 Commits

Author SHA1 Message Date
epenet
15328a4aff Cleanup unused function in Tuya util (#157227) 2025-11-25 10:58:40 +01:00
Paul Bottein
083cfb89af Add winter mode to front-end integration under lab preview feature (#157181) 2025-11-25 09:34:11 +01:00
epenet
bd129c2085 Bump tuya-device-sharing-sdk to 0.2.6 (#157223) 2025-11-25 09:18:02 +01:00
Petro31
f73bc9242b Modernize template fan (#156470) 2025-11-25 08:08:19 +01:00
wollew
4506be5065 Complete test coverage for velux light and cover entities (#156770)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-25 08:07:36 +01:00
Manu
80c611e562 Add support for additional remote commands to Xbox integration (#157206) 2025-11-25 07:49:13 +01:00
Will Moss
b44aafc294 Improved error handling for oauth2 configuration in neato integration (#156300) 2025-11-25 07:28:04 +01:00
Petro31
af1e3205b8 Modernize template lock (#156402) 2025-11-25 07:26:42 +01:00
dependabot[bot]
1360fe7f23 Bump actions/setup-python from 6.0.0 to 6.1.0 (#157219)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-25 07:22:24 +01:00
dependabot[bot]
b5bb8583f8 Bump github/codeql-action from 4.31.4 to 4.31.5 (#157220)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-25 07:21:59 +01:00
Andre Lengwenus
9b62c212ce Bump LCN quality scale to Silver (#157151) 2025-11-25 06:45:26 +01:00
Simone Chemelli
8fa56ad92e Bump aioamazondevices to 9.0.3 (#157205)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-25 06:09:20 +01:00
ndrwrbgs
f82f0a1862 Correctcurrent_temp local variable name in homekit _get_current_temperature (#157202) 2025-11-25 06:04:13 +01:00
J. Nick Koston
878881b100 Fix elkm1 connection cleanup on setup failure (#157208) 2025-11-24 23:12:04 -05:00
J. Nick Koston
743583d9bd Bump aioesphomeapi to 42.8.0 (#157214) 2025-11-24 22:49:57 -05:00
Jan Bouwhuis
f537204d22 Fix websocket_api timeout test (#157204) 2025-11-24 20:12:32 -06:00
Glenn Waters
ec74be7922 Bump elkm1-lib to 2.2.13 (#157212)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-24 18:27:53 -06:00
David Rapan
3574f647d0 Move Shelly binary sensor translation logic to base class (#157127)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-25 00:31:25 +02:00
David Rapan
6c4296a0de Remove Shelly binary sensor name removal (#157065)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-24 22:35:25 +01:00
Paulus Schoutsen
e780e3db8c Add chat log subscription endpoint (#155287)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-24 22:31:19 +01:00
David Rapan
4ed2efa4e8 Remove Shelly switch name removal (#157072)
Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-11-24 22:24:24 +01:00
David Rapan
abef6f7b3e Remove Shelly sensor name removal (#157071)
Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-11-24 22:09:42 +01:00
David Rapan
5556fb99e6 Remove Shelly event name removal (#157067)
Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-11-24 22:01:58 +01:00
J. Nick Koston
16669e39bd Filter devices with active discovery flows from Shelly user step (#157201) 2025-11-24 14:52:05 -06:00
Raphael Hehl
ca088d81c3 Bump uiprotect to version 7.28.0 (#157198)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-24 14:44:39 -06:00
Michael Hansen
12847fb0a4 Bump intents (#157200) 2025-11-24 21:39:24 +01:00
J. Nick Koston
8b758c46f4 Combine Shelly BLE WiFi provisioning SSID and password steps (#157199) 2025-11-24 14:34:59 -06:00
J. Nick Koston
f439471dc1 Add BLE IP fallback for Shelly provisioning when zeroconf fails (#157144) 2025-11-24 13:55:09 -06:00
Raphael Hehl
5ff3233b09 Remove license plate event sensor (#157196) 2025-11-24 13:48:38 -06:00
Jan Bouwhuis
22daed083f Fix MQTT link text to be sentence cased (#157191) 2025-11-24 20:27:22 +01:00
Peter Norlander
13384de464 Extend Matter lock to support selecting OperatingMode (#157132)
Co-authored-by: Ludovic BOUÉ <lboue@users.noreply.github.com>
2025-11-24 20:16:56 +01:00
J. Nick Koston
f5e5183190 Show available shelly devices in user config flow (#157138) 2025-11-24 13:16:42 -06:00
Jan Bouwhuis
e18668b8f9 Add MQTT water heater subentry support (#157182) 2025-11-24 19:54:42 +01:00
Åke Strandberg
15647f2720 Add miele select platform to support sabbath mode (#156866) 2025-11-24 19:29:13 +01:00
victorigualada
c961126ee5 Bump hass-nabucasa from 1.5.1 to 1.6.0 (#157177) 2025-11-24 16:43:10 +00:00
Joost Lekkerkerker
5142c5f418 Use SmartThings modelCode as model_id (#157179) 2025-11-24 17:07:22 +01:00
David Rapan
3d459704e1 Remove Shelly update name removal (#157073)
Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-11-24 18:02:23 +02:00
Joost Lekkerkerker
5a8ddcd0b3 Bump pySmartThings to 3.4.0 (#157178) 2025-11-24 16:55:18 +01:00
David Rapan
2667a40b92 Remove Shelly number name removal (#157069)
Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-11-24 17:08:13 +02:00
David Rapan
08baa99691 Remove Shelly button name removal (#157066)
Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-11-24 14:28:38 +02:00
David Rapan
d84cf26f40 Fix Shelly Self-test sensor name (#157169) 2025-11-24 14:03:56 +02:00
Robert Resch
ba5472da90 Pin go2rtc version to sha hash (#157166)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-24 12:52:58 +01:00
Jan Bouwhuis
e20b88a54f Revert MQTT subscribe on_subscribe arg (#157168) 2025-11-24 12:32:55 +01:00
TimL
ac69712a51 update firmware handling in SMLIGHT integration (#157145) 2025-11-24 12:13:28 +01:00
Jan Bouwhuis
f0e75ba0ed Add MQTT valve subentry support (#157124)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-11-24 12:04:38 +01:00
mettolen
e64598e7f5 Add light entity to Saunum integration (#157081)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-24 12:02:46 +01:00
vexofp
e6f9a8e7d6 Assign icons for more Octoprint sensors (#157150) 2025-11-24 11:51:58 +01:00
Josef Zweck
1e8b42f843 Bump pylamarzocco to 2.2.2 (#157165) 2025-11-24 11:50:11 +01:00
Franck Nijhof
430eee0b28 Address Home Assistant Labs review comments (#157075)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-24 11:34:38 +01:00
Paulus Schoutsen
b4799aa7ea Abort Z-Wave JS discovery from ESPHome if add-on umanaged (#157013)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-24 11:09:20 +01:00
Markus Jacobsen
ab45460069 Add Beoremote One support to Bang & Olufsen (#155082)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-24 08:17:04 +01:00
dependabot[bot]
c8fd6db3ff Bump actions/ai-inference from 2.0.1 to 2.0.2 (#157153) 2025-11-24 07:54:24 +01:00
Jan Bouwhuis
0a9f200ca4 Bump incomfort-client to v0.6.10 (#157136) 2025-11-24 06:31:50 +01:00
J. Nick Koston
8591335660 Bump dbus-fast to 3.1.2 (#157147) 2025-11-23 23:03:26 -05:00
TimL
c01089e994 Bump pysmlight to 0.2.11 (#157146) 2025-11-23 21:05:41 -06:00
138 changed files with 8456 additions and 1775 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==9.0.2"]
"requirements": ["aioamazondevices==9.0.3"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.2.0"]
"requirements": ["pylamarzocco==2.2.2"]
}

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.FAN,
Platform.LIGHT,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.VACUUM,

View 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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.3.4"]
"requirements": ["pysmartthings==3.4.0"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
])
# ---

View 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',
])
# ---

View File

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

View File

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