Compare commits

..

87 Commits

Author SHA1 Message Date
abmantis
81415a3cb1 Add LG Infrared integration 2026-02-05 22:36:29 +00:00
abmantis
ed1bb685da Dynamic modulation + cleanup 2026-02-05 22:23:29 +00:00
abmantis
6c610dfe73 Add infrared platform to ESPHome 2026-02-05 20:23:19 +00:00
abmantis
90bacbb98e Add infrared entity integration 2026-02-05 20:06:33 +00:00
Bram Kragten
dee7a237ee Update frontend to 20260128.6 (#162214) 2026-02-04 16:58:12 +01:00
Åke Strandberg
3975eba12c Add missing codes for Miele coffe systems (#162206) 2026-02-04 15:06:35 +01:00
epenet
ade91ebdab Cleanup deprecated COLOR_MODE light constants (#162197) 2026-02-04 15:00:12 +01:00
Norbert Rittel
1bf194dd0f Clarify action descriptions in media_player (#162172) 2026-02-04 14:58:40 +01:00
Manu
2eca8db8aa Add action exceptions to Xbox integration (#162198) 2026-02-04 14:56:52 +01:00
Robert Svensson
78415bc1ff Add missing OUI to Axis integration, discovery would abort with unsup… (#161943) 2026-02-04 14:43:04 +01:00
Denis Shulyaka
e2469bcd0f Anthropic repair deprecated models (#162162)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-04 14:40:31 +01:00
Michael Hansen
54d64b7da2 Bump intents (#162205) 2026-02-04 14:25:28 +01:00
Erik Montnemery
d548f3d12f Bump python-otbr-api to 2.8.0 (#162167) 2026-02-04 14:23:02 +01:00
epenet
668995da73 Fix incorrect exception in telegram_bot (#162191) 2026-02-04 13:32:29 +01:00
epenet
9eeae8eac6 Improve typing in telegram_bot (#162190) 2026-02-04 12:15:23 +00:00
andreimoraru
7e7056aa94 Bump yt-dlp to 2026.02.04 (#162204)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-04 12:07:40 +00:00
epenet
b633b8d271 Fix test_before_setup IQS check (#162187)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-04 12:41:59 +01:00
Marc Mueller
45c7b9ccb8 Pin auth0-python to <5.0 (#162203) 2026-02-04 12:19:49 +01:00
dependabot[bot]
3ad1a57dfc Bump github/codeql-action from 4.32.0 to 4.32.1 (#162118)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 10:40:44 +01:00
Brandon Rothweiler
3cbe236a36 Bump py-aosmith to 1.0.16 (#162160) 2026-02-04 10:40:09 +01:00
Przemko92
39816c1e8a Bump compit-inext-api to 0.8.0 (#162166) 2026-02-04 10:39:12 +01:00
johanzander
5587dd43b9 Bump growattServer to 1.9.0 (#162179)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 10:38:18 +01:00
Jonathan Bangert
715d1e4eb8 Bump bleak-esphome to 3.6.0 (#162028) 2026-02-04 10:34:30 +01:00
Oliver
af172fb70d Bump denonavr to 1.3.1 (#162183) 2026-02-04 10:33:44 +01:00
TheJulianJES
8c8bc104eb Bump ZHA to 0.0.89 (#162195) 2026-02-04 10:27:23 +01:00
Liquidmasl
51b20fb5db Adjust radarr constants and strings (#162159) 2026-02-04 10:16:47 +01:00
Kamil Breguła
445ba26667 Enable check for duplicate exception handlers (#162169)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-04 10:14:46 +01:00
epenet
886448f4ba Cleanup deprecated mired handling in light platform (#161777) 2026-02-04 09:52:53 +01:00
Petro31
ede4341ef3 Fix template weather humidity (#161945) 2026-02-04 08:02:09 +01:00
Liquidmasl
fe363f32ec Jellyfin native client controls (#161982) 2026-02-03 20:18:59 +01:00
Kamil Breguła
31562e7571 Remove duplicated exception handler in overkiz (#162171)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2026-02-03 20:16:06 +01:00
Kamil Breguła
0bdb51e4ca Remove duplicated exception handler in systemmonitor (#162170)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2026-02-03 20:14:37 +01:00
epenet
67a5d7ac21 Move neato service registration (#162146) 2026-02-03 20:05:12 +01:00
epenet
5e7f06c476 Move sharkiq service registration (#162147) 2026-02-03 19:52:54 +01:00
epenet
9a69852296 Move xiaomi_miio service registration (#162148) 2026-02-03 19:48:39 +01:00
Bram Kragten
a722925b8e Update frontend to 20260128.5 (#162156) 2026-02-03 17:57:15 +01:00
Joost Lekkerkerker
419c5de50e Add Heiman virtual brand (#162152) 2026-02-03 17:20:25 +01:00
Joost Lekkerkerker
37faed565e Add Heatit virtual brand (#162155) 2026-02-03 17:19:50 +01:00
Paul Bottein
622953e61f Update title and description of YAML dashboard repair (#162138) 2026-02-03 17:09:23 +01:00
Steven Travers
17926c3f6a Modify Analytics text on feature labs (#162151) 2026-02-03 16:09:34 +01:00
victorigualada
48d85170c2 Handle chat log attachments in Cloud integration (#162121) 2026-02-03 15:54:56 +01:00
epenet
08d179c520 Move ecovacs service registration (#162145) 2026-02-03 15:50:17 +01:00
hanwg
5752387da8 Add entity_id parameter for Telegram bot actions (#159745)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-02-03 15:34:39 +01:00
Sebastiaan Speck
1ebde65f03 Add sound horn and flash lights buttons to Renault (#161976)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-03 15:18:55 +01:00
Denis Shulyaka
89f536e332 Anthropic: Switch default model to Haiku 4.5 (#162093) 2026-02-03 14:12:21 +01:00
Shay Levy
8784329333 Fix Shelly xpercent sensor state_class (#162107) 2026-02-03 14:11:55 +01:00
Marc Mueller
d73538722d Use Generator and AsyncGenerator for contextmanager typing (#162144) 2026-02-03 13:52:33 +01:00
Brett Adams
d49d3f0a2f Mark test-coverage as done for Teslemetry quality scale (#161958)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-03 12:57:14 +01:00
Blaine Cook
8466dd4c2b Add temperature sensor to Huum integration (#161405)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-03 12:38:39 +01:00
Brett Adams
6bb1e688c6 Mark reconfiguration-flow as done for Teslemetry (#162139)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 12:37:24 +01:00
epenet
9bc1c4c4f3 Simplify reolink method arguments (#162137) 2026-02-03 12:23:33 +01:00
jameson_uk
a554cb8211 Remove invalid notification sensors for Alexa devices (#160422)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2026-02-03 11:48:57 +01:00
Liquidmasl
145d38403e Add get_queue and get_movies service calls to Radarr (#160753)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-03 11:30:30 +01:00
Erwin Douna
10d4af5674 Use asyncio.gather pattern in portainer (#160888) 2026-02-03 11:23:34 +01:00
Brett Adams
ed3b4d2de3 Fix oauth debug log bug in Teslemetry (#161652) 2026-02-03 11:16:45 +01:00
epenet
e66d324877 Move openhome service registration (#162127) 2026-02-03 11:15:53 +01:00
epenet
f7f18627a2 Move squeezebox service registration (#162132) 2026-02-03 11:15:28 +01:00
epenet
d18630020f Move songpal service registration (#162131) 2026-02-03 11:15:05 +01:00
epenet
a715ec318c Move snapcast service registration (#162130) 2026-02-03 11:14:39 +01:00
epenet
0ef5a77dc9 Move roon service registration (#162129) 2026-02-03 11:14:06 +01:00
epenet
b43abf83b8 Move roku service registration (#162128) 2026-02-03 11:13:19 +01:00
epenet
84d28db3a7 Move linkplay service registration (#162126) 2026-02-03 11:12:32 +01:00
epenet
74d99fa0be Move denonavr service registration (#162123) 2026-02-03 11:12:04 +01:00
epenet
3ff0320ed8 Move vizio service registration (#162133) 2026-02-03 11:11:19 +01:00
epenet
16cb9e9785 Move kodi service registration (#162125) 2026-02-03 11:10:41 +01:00
epenet
d92279dfcb Move epson service registration (#162124) 2026-02-03 11:10:10 +01:00
Kamil Breguła
4b9d28d0e5 Handle missing battery stats in systemmonitor (#158287)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-03 10:54:31 +01:00
Wendelin
e6a60dfe50 Add option to use frontend PR artifact to frontend integration (#161291)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-02-03 10:23:25 +01:00
Tom
d219056e9d Add target_humidity_step attribute to climate (#160418) 2026-02-03 09:34:31 +02:00
epenet
6ff6b099b5 Move bring service registration (#162077) 2026-02-03 07:42:03 +01:00
Brett Adams
c5b9699098 Add model_id and sw_version to Teslemetry device info (#161959)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:58:02 +01:00
mettolen
6937bfdf67 Add number entity to Liebherr integration (#162011) 2026-02-02 21:48:39 +01:00
epenet
39ee3fcfaa Move bond service registration (#162075) 2026-02-02 21:47:30 +01:00
J. Diego Rodríguez Royo
16cdfd05a0 Remove coffee machine's hot water sensor's state class at Home Connect (#161246) 2026-02-02 21:30:43 +01:00
mezz64
f49d4787be Bump pyhik to 0.4.2 (#162092)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-02 21:22:42 +01:00
epenet
2076700dc4 Move rainbird service registration (#162089) 2026-02-02 21:02:57 +01:00
Åke Strandberg
76c135913e Update Senz temperature sensor (#162016) 2026-02-02 20:10:46 +01:00
epenet
c3534d5445 Mark tts method type hints as mandatory (#161235) 2026-02-02 19:49:55 +01:00
epenet
fc60b16d65 Mark device_tracker method type hints as mandatory (#161232) 2026-02-02 19:49:26 +01:00
epenet
0443c93f77 Move webostv service registration (#162091) 2026-02-02 20:48:21 +02:00
Bram Kragten
f97cf0e446 Update frontend to 20260128.4 (#162096) 2026-02-02 19:03:38 +01:00
epenet
bd4fa0d5c2 Move reolink service registration (#162085) 2026-02-02 19:02:09 +01:00
Steven Travers
f60d367184 Add learn more data for Analytics in labs (#162094) 2026-02-02 17:22:01 +01:00
epenet
6e231f2ec5 Move husqvarna_automower service registration (#162087) 2026-02-02 17:08:41 +01:00
epenet
13ba2d2e47 Move litterrobot service registration (#162088) 2026-02-02 17:08:07 +01:00
epenet
ba4a163e24 Move roborock service registration (#162090) 2026-02-02 17:07:44 +01:00
epenet
b7db8684db Move elgato service registration (#162086) 2026-02-02 17:05:48 +01:00
337 changed files with 10051 additions and 2972 deletions

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
uses: github/codeql-action/init@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
uses: github/codeql-action/analyze@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
with:
category: "/language:python"

View File

@@ -282,6 +282,7 @@ homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
@@ -314,6 +315,7 @@ homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
homeassistant.components.lg_infrared.*
homeassistant.components.libre_hardware_monitor.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*

2
CODEOWNERS generated
View File

@@ -782,6 +782,8 @@ build.json @home-assistant/supervisor
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
/homeassistant/components/infrared/ @home-assistant/core
/tests/components/infrared/ @home-assistant/core
/homeassistant/components/inkbird/ @bdraco
/tests/components/inkbird/ @bdraco
/homeassistant/components/input_boolean/ @home-assistant/core

View File

@@ -0,0 +1,5 @@
{
"domain": "heatit",
"name": "Heatit",
"iot_standards": ["zwave"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "heiman",
"name": "Heiman",
"iot_standards": ["matter", "zigbee"]
}

View File

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

View File

@@ -28,6 +28,7 @@ from homeassistant.helpers.typing import StateType
from .const import CATEGORY_NOTIFICATIONS, CATEGORY_SENSORS
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_remove_unsupported_notification_sensors
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -105,6 +106,9 @@ async def async_setup_entry(
coordinator = entry.runtime_data
# Remove notification sensors from unsupported devices
await async_remove_unsupported_notification_sensors(hass, coordinator)
known_devices: set[str] = set()
def _check_device() -> None:
@@ -122,6 +126,7 @@ async def async_setup_entry(
AmazonSensorEntity(coordinator, serial_num, notification_desc)
for notification_desc in NOTIFICATIONS
for serial_num in new_devices
if coordinator.data[serial_num].notifications_supported
]
async_add_entities(sensors_list + notifications_list)

View File

@@ -5,8 +5,14 @@ from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.const.schedules import (
NOTIFICATION_ALARM,
NOTIFICATION_REMINDER,
NOTIFICATION_TIMER,
)
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -81,3 +87,27 @@ async def async_remove_dnd_from_virtual_group(
if entity_id and is_group:
entity_registry.async_remove(entity_id)
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)
async def async_remove_unsupported_notification_sensors(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
) -> None:
"""Remove notification sensors from unsupported devices."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
for notification_key in (
NOTIFICATION_ALARM,
NOTIFICATION_REMINDER,
NOTIFICATION_TIMER,
):
unique_id = f"{serial_num}-{notification_key}"
entity_id = entity_registry.async_get_entity_id(
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
)
is_unsupported = not coordinator.data[serial_num].notifications_supported
if entity_id and is_unsupported:
entity_registry.async_remove(entity_id)
_LOGGER.debug("Removed unsupported notification sensor %s", entity_id)

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable
from collections.abc import AsyncGenerator, Callable
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
@@ -202,7 +202,7 @@ class AmcrestChecker(ApiWrapper):
@asynccontextmanager
async def async_stream_command(
self, *args: Any, **kwargs: Any
) -> AsyncIterator[httpx.Response]:
) -> AsyncGenerator[httpx.Response]:
"""amcrest.ApiWrapper.command wrapper to catch errors."""
async with (
self._async_command_wrapper(),
@@ -211,7 +211,7 @@ class AmcrestChecker(ApiWrapper):
yield ret
@asynccontextmanager
async def _async_command_wrapper(self) -> AsyncIterator[None]:
async def _async_command_wrapper(self) -> AsyncGenerator[None]:
try:
yield
except LoginError as ex:

View File

@@ -10,6 +10,7 @@
"preview_features": {
"snapshots": {
"feedback_url": "https://forms.gle/GqvRmgmghSDco8M46",
"learn_more_url": "https://www.home-assistant.io/blog/2026/02/02/about-device-database/",
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
}
},

View File

@@ -1,7 +1,7 @@
{
"preview_features": {
"snapshots": {
"description": "This free, open source device database of the Open Home Foundation helps users find useful information about smart home devices used in real installations.\n\nYou can help build it by anonymously sharing data about your devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).\n\nLearn more about the device database and how we process your data in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement), which you accept by opting in.",
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
"disable_confirmation": "Your data will no longer be shared with the Open Home Foundation's device database.",
"enable_confirmation": "This feature is still in development and may change. The device database is being refined based on user feedback and is not yet complete.",
"name": "Device database"

View File

@@ -14,10 +14,18 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.typing import ConfigType
from .const import DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
from .const import (
CONF_CHAT_MODEL,
DATA_REPAIR_DEFER_RELOAD,
DEFAULT_CONVERSATION_NAME,
DEPRECATED_MODELS,
DOMAIN,
LOGGER,
)
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -27,6 +35,7 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Anthropic."""
hass.data.setdefault(DOMAIN, {}).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
await async_migrate_integration(hass)
return True
@@ -50,6 +59,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
entry.async_on_unload(entry.add_update_listener(async_update_options))
for subentry in entry.subentries.values():
if (model := subentry.data.get(CONF_CHAT_MODEL)) and model.startswith(
tuple(DEPRECATED_MODELS)
):
ir.async_create_issue(
hass,
DOMAIN,
"model_deprecated",
is_fixable=True,
is_persistent=False,
learn_more_url="https://platform.claude.com/docs/en/about-claude/model-deprecations",
severity=ir.IssueSeverity.WARNING,
translation_key="model_deprecated",
)
break
return True
@@ -62,6 +87,11 @@ async def async_update_options(
hass: HomeAssistant, entry: AnthropicConfigEntry
) -> None:
"""Update options."""
defer_reload_entries: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault(
DATA_REPAIR_DEFER_RELOAD, set()
)
if entry.entry_id in defer_reload_entries:
return
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -92,6 +92,40 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
await client.models.list(timeout=10.0)
async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
models = (await client.models.list()).data
except anthropic.AnthropicError:
models = []
_LOGGER.debug("Available models: %s", models)
model_options: list[SelectOptionDict] = []
short_form = re.compile(r"[^\d]-\d$")
for model_info in models:
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id
not in (
"claude-3-haiku-20240307",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
)
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
if model_alias.endswith(("haiku", "opus", "sonnet")):
model_alias += "-latest"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
value=model_alias,
)
)
return model_options
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthropic."""
@@ -401,38 +435,13 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
async def _get_model_list(self) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
client = await self.hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=self._get_entry().data[CONF_API_KEY],
)
client = await self.hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=self._get_entry().data[CONF_API_KEY],
)
models = (await client.models.list()).data
except anthropic.AnthropicError:
models = []
_LOGGER.debug("Available models: %s", models)
model_options: list[SelectOptionDict] = []
short_form = re.compile(r"[^\d]-\d$")
for model_info in models:
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
if model_alias.endswith(("haiku", "opus", "sonnet")):
model_alias += "-latest"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
value=model_alias,
)
)
return model_options
)
return await get_model_list(client)
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""

View File

@@ -22,8 +22,10 @@ CONF_WEB_SEARCH_REGION = "region"
CONF_WEB_SEARCH_COUNTRY = "country"
CONF_WEB_SEARCH_TIMEZONE = "timezone"
DATA_REPAIR_DEFER_RELOAD = "repair_defer_reload"
DEFAULT = {
CONF_CHAT_MODEL: "claude-3-5-haiku-latest",
CONF_CHAT_MODEL: "claude-haiku-4-5",
CONF_MAX_TOKENS: 3000,
CONF_TEMPERATURE: 1.0,
CONF_THINKING_BUDGET: 0,
@@ -46,3 +48,10 @@ WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
]
DEPRECATED_MODELS = [
"claude-3-5-haiku",
"claude-3-7-sonnet",
"claude-3-5-sonnet",
"claude-3-opus",
]

View File

@@ -0,0 +1,275 @@
"""Issue repair flow for Anthropic."""
from __future__ import annotations
from collections.abc import Iterator
from typing import cast
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .config_flow import get_model_list
from .const import (
CONF_CHAT_MODEL,
DATA_REPAIR_DEFER_RELOAD,
DEFAULT,
DEPRECATED_MODELS,
DOMAIN,
)
class ModelDeprecatedRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow."""
_subentry_iter: Iterator[tuple[str, str]] | None
_current_entry_id: str | None
_current_subentry_id: str | None
_reload_pending: set[str]
_pending_updates: dict[str, dict[str, str]]
def __init__(self) -> None:
"""Initialize the flow."""
super().__init__()
self._subentry_iter = None
self._current_entry_id = None
self._current_subentry_id = None
self._reload_pending = set()
self._pending_updates = {}
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
previous_entry_id: str | None = None
if user_input is not None:
previous_entry_id = self._async_update_current_subentry(user_input)
self._clear_current_target()
target = await self._async_next_target()
next_entry_id = target[0].entry_id if target else None
if previous_entry_id and previous_entry_id != next_entry_id:
await self._async_apply_pending_updates(previous_entry_id)
if target is None:
await self._async_apply_all_pending_updates()
return self.async_create_entry(data={})
entry, subentry, model = target
client = entry.runtime_data
model_list = [
model_option
for model_option in await get_model_list(client)
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
]
if "opus" in model:
suggested_model = "claude-opus-4-5"
elif "haiku" in model:
suggested_model = "claude-haiku-4-5"
elif "sonnet" in model:
suggested_model = "claude-sonnet-4-5"
else:
suggested_model = cast(str, DEFAULT[CONF_CHAT_MODEL])
schema = vol.Schema(
{
vol.Required(
CONF_CHAT_MODEL,
default=suggested_model,
): SelectSelector(
SelectSelectorConfig(options=model_list, custom_value=True)
),
}
)
return self.async_show_form(
step_id="init",
data_schema=schema,
description_placeholders={
"entry_name": entry.title,
"model": model,
"subentry_name": subentry.title,
"subentry_type": self._format_subentry_type(subentry.subentry_type),
},
)
def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]:
"""Yield entry/subentry pairs that use deprecated models."""
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.state is not ConfigEntryState.LOADED:
continue
for subentry in entry.subentries.values():
model = subentry.data.get(CONF_CHAT_MODEL)
if model and model.startswith(tuple(DEPRECATED_MODELS)):
yield entry.entry_id, subentry.subentry_id
async def _async_next_target(
self,
) -> tuple[ConfigEntry, ConfigSubentry, str] | None:
"""Return the next deprecated subentry target."""
if self._subentry_iter is None:
self._subentry_iter = self._iter_deprecated_subentries()
while True:
try:
entry_id, subentry_id = next(self._subentry_iter)
except StopIteration:
return None
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is None:
continue
subentry = entry.subentries.get(subentry_id)
if subentry is None:
continue
model = self._pending_model(entry_id, subentry_id)
if model is None:
model = subentry.data.get(CONF_CHAT_MODEL)
if not model or not model.startswith(tuple(DEPRECATED_MODELS)):
continue
self._current_entry_id = entry_id
self._current_subentry_id = subentry_id
return entry, subentry, model
def _async_update_current_subentry(self, user_input: dict[str, str]) -> str | None:
"""Update the currently selected subentry."""
if not self._current_entry_id or not self._current_subentry_id:
return None
entry = self.hass.config_entries.async_get_entry(self._current_entry_id)
if entry is None:
return None
subentry = entry.subentries.get(self._current_subentry_id)
if subentry is None:
return None
updated_data = {
**subentry.data,
CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL],
}
if updated_data == subentry.data:
return entry.entry_id
self._queue_pending_update(
entry.entry_id,
subentry.subentry_id,
updated_data[CONF_CHAT_MODEL],
)
return entry.entry_id
def _clear_current_target(self) -> None:
"""Clear current target tracking."""
self._current_entry_id = None
self._current_subentry_id = None
def _format_subentry_type(self, subentry_type: str) -> str:
"""Return a user-friendly subentry type label."""
if subentry_type == "conversation":
return "Conversation agent"
if subentry_type in ("ai_task", "ai_task_data"):
return "AI task"
return subentry_type
def _queue_pending_update(
self, entry_id: str, subentry_id: str, model: str
) -> None:
"""Store a pending model update for a subentry."""
self._pending_updates.setdefault(entry_id, {})[subentry_id] = model
def _pending_model(self, entry_id: str, subentry_id: str) -> str | None:
"""Return a pending model update if one exists."""
return self._pending_updates.get(entry_id, {}).get(subentry_id)
def _mark_entry_for_reload(self, entry_id: str) -> None:
"""Prevent reload until repairs are complete for the entry."""
self._reload_pending.add(entry_id)
defer_reload_entries: set[str] = self.hass.data.setdefault(
DOMAIN, {}
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
defer_reload_entries.add(entry_id)
async def _async_reload_entry(self, entry_id: str) -> None:
"""Reload an entry once all repairs are completed."""
if entry_id not in self._reload_pending:
return
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is not None and entry.state is not ConfigEntryState.LOADED:
self._clear_defer_reload(entry_id)
self._reload_pending.discard(entry_id)
return
if entry is not None:
await self.hass.config_entries.async_reload(entry_id)
self._clear_defer_reload(entry_id)
self._reload_pending.discard(entry_id)
def _clear_defer_reload(self, entry_id: str) -> None:
"""Remove entry from the deferred reload set."""
defer_reload_entries: set[str] = self.hass.data.setdefault(
DOMAIN, {}
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
defer_reload_entries.discard(entry_id)
async def _async_apply_pending_updates(self, entry_id: str) -> None:
"""Apply pending subentry updates for a single entry."""
updates = self._pending_updates.pop(entry_id, None)
if not updates:
return
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.state is not ConfigEntryState.LOADED:
return
changed = False
for subentry_id, model in updates.items():
subentry = entry.subentries.get(subentry_id)
if subentry is None:
continue
updated_data = {
**subentry.data,
CONF_CHAT_MODEL: model,
}
if updated_data == subentry.data:
continue
if not changed:
self._mark_entry_for_reload(entry_id)
changed = True
self.hass.config_entries.async_update_subentry(
entry,
subentry,
data=updated_data,
)
if not changed:
return
await self._async_reload_entry(entry_id)
async def _async_apply_all_pending_updates(self) -> None:
"""Apply all pending updates across entries."""
for entry_id in list(self._pending_updates):
await self._async_apply_pending_updates(entry_id)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if issue_id == "model_deprecated":
return ModelDeprecatedRepairFlow()
raise HomeAssistantError("Unknown issue ID")

View File

@@ -109,5 +109,21 @@
}
}
}
},
"issues": {
"model_deprecated": {
"fix_flow": {
"step": {
"init": {
"data": {
"chat_model": "[%key:common::generic::model%]"
},
"description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated. Select a supported model to continue.",
"title": "Update model"
}
}
},
"title": "Model deprecated"
}
}
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.15"]
"requirements": ["py-aosmith==1.0.16"]
}

View File

@@ -52,7 +52,7 @@ from .const import (
from .errors import AuthenticationRequired, CannotConnect
from .hub import AxisHub, get_axis_api
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f"}
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
DEFAULT_PORT = 443
DEFAULT_PROTOCOL = "https"
PROTOCOL_CHOICES = ["https", "http"]

View File

@@ -26,6 +26,7 @@ EXCLUDE_FROM_BACKUP = [
"tmp_backups/*.tar",
"OZW_Log.txt",
"tts/*",
".cache/*",
]
EXCLUDE_DATABASE_FROM_BACKUP = [

View File

@@ -1,6 +1,7 @@
"""Support for Baidu speech service."""
import logging
from typing import Any
from aip import AipSpeech
import voluptuous as vol
@@ -9,6 +10,7 @@ from homeassistant.components.tts import (
CONF_LANG,
PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA,
Provider,
TtsAudioType,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import config_validation as cv
@@ -85,17 +87,17 @@ class BaiduTTSProvider(Provider):
}
@property
def default_language(self):
def default_language(self) -> str:
"""Return the default language."""
return self._lang
@property
def supported_languages(self):
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
return SUPPORTED_LANGUAGES
@property
def default_options(self):
def default_options(self) -> dict[str, Any]:
"""Return a dict including default options."""
return {
CONF_PERSON: self._speech_conf_data[_OPTIONS[CONF_PERSON]],
@@ -105,11 +107,16 @@ class BaiduTTSProvider(Provider):
}
@property
def supported_options(self):
def supported_options(self) -> list[str]:
"""Return a list of supported options."""
return SUPPORTED_OPTIONS
def get_tts_audio(self, message, language, options):
def get_tts_audio(
self,
message: str,
language: str,
options: dict[str, Any],
) -> TtsAudioType:
"""Load TTS from BaiduTTS."""
aip_speech = AipSpeech(

View File

@@ -16,14 +16,17 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import SLOW_UPDATE_WARNING
from homeassistant.helpers.typing import ConfigType
from .const import BRIDGE_MAKE, DOMAIN
from .models import BondData
from .services import async_setup_services
from .utils import BondHub
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BUTTON,
Platform.COVER,
@@ -38,6 +41,12 @@ _LOGGER = logging.getLogger(__name__)
type BondConfigEntry = ConfigEntry[BondData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BondConfigEntry) -> bool:
"""Set up Bond from a config entry."""
host = entry.data[CONF_HOST]

View File

@@ -5,10 +5,3 @@ BRIDGE_MAKE = "Olibra"
DOMAIN = "bond"
CONF_BOND_ID: str = "bond_id"
SERVICE_SET_FAN_SPEED_TRACKED_STATE = "set_fan_speed_tracked_state"
SERVICE_SET_POWER_TRACKED_STATE = "set_switch_power_tracked_state"
SERVICE_SET_LIGHT_POWER_TRACKED_STATE = "set_light_power_tracked_state"
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE = "set_light_brightness_tracked_state"
ATTR_POWER_STATE = "power_state"

View File

@@ -8,7 +8,6 @@ from typing import Any
from aiohttp.client_exceptions import ClientResponseError
from bond_async import Action, DeviceType, Direction
import voluptuous as vol
from homeassistant.components.fan import (
DIRECTION_FORWARD,
@@ -18,7 +17,6 @@ from homeassistant.components.fan import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
@@ -27,7 +25,6 @@ from homeassistant.util.percentage import (
from homeassistant.util.scaling import int_states_in_range
from . import BondConfigEntry
from .const import SERVICE_SET_FAN_SPEED_TRACKED_STATE
from .entity import BondEntity
from .models import BondData
from .utils import BondDevice
@@ -44,12 +41,6 @@ async def async_setup_entry(
) -> None:
"""Set up Bond fan devices."""
data = entry.runtime_data
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_FAN_SPEED_TRACKED_STATE,
{vol.Required("speed"): vol.All(vol.Number(scale=0), vol.Range(0, 100))},
"async_set_speed_belief",
)
async_add_entities(
BondFan(data, device)

View File

@@ -7,37 +7,20 @@ from typing import Any
from aiohttp.client_exceptions import ClientResponseError
from bond_async import Action, DeviceType
import voluptuous as vol
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BondConfigEntry
from .const import (
ATTR_POWER_STATE,
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE,
SERVICE_SET_LIGHT_POWER_TRACKED_STATE,
)
from .entity import BondEntity
from .models import BondData
from .utils import BondDevice
_LOGGER = logging.getLogger(__name__)
SERVICE_START_INCREASING_BRIGHTNESS = "start_increasing_brightness"
SERVICE_START_DECREASING_BRIGHTNESS = "start_decreasing_brightness"
SERVICE_STOP = "stop"
ENTITY_SERVICES = [
SERVICE_START_INCREASING_BRIGHTNESS,
SERVICE_START_DECREASING_BRIGHTNESS,
SERVICE_STOP,
]
async def async_setup_entry(
hass: HomeAssistant,
@@ -48,14 +31,6 @@ async def async_setup_entry(
data = entry.runtime_data
hub = data.hub
platform = entity_platform.async_get_current_platform()
for service in ENTITY_SERVICES:
platform.async_register_entity_service(
service,
None,
f"async_{service}",
)
fan_lights: list[Entity] = [
BondLight(data, device)
for device in hub.devices
@@ -94,22 +69,6 @@ async def async_setup_entry(
if DeviceType.is_light(device.type)
]
platform.async_register_entity_service(
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE,
{
vol.Required(ATTR_BRIGHTNESS): vol.All(
vol.Number(scale=0), vol.Range(0, 255)
)
},
"async_set_brightness_belief",
)
platform.async_register_entity_service(
SERVICE_SET_LIGHT_POWER_TRACKED_STATE,
{vol.Required(ATTR_POWER_STATE): vol.All(cv.boolean)},
"async_set_power_belief",
)
async_add_entities(
fan_lights + fan_up_lights + fan_down_lights + fireplaces + fp_lights + lights,
)

View File

@@ -0,0 +1,101 @@
"""Support for Bond services."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
ATTR_POWER_STATE = "power_state"
# Fan
SERVICE_SET_FAN_SPEED_TRACKED_STATE = "set_fan_speed_tracked_state"
# Switch
SERVICE_SET_POWER_TRACKED_STATE = "set_switch_power_tracked_state"
# Light
SERVICE_SET_LIGHT_POWER_TRACKED_STATE = "set_light_power_tracked_state"
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE = "set_light_brightness_tracked_state"
SERVICE_START_INCREASING_BRIGHTNESS = "start_increasing_brightness"
SERVICE_START_DECREASING_BRIGHTNESS = "start_decreasing_brightness"
SERVICE_STOP = "stop"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
# Fan entity services
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_FAN_SPEED_TRACKED_STATE,
entity_domain=FAN_DOMAIN,
schema={vol.Required("speed"): vol.All(vol.Number(scale=0), vol.Range(0, 100))},
func="async_set_speed_belief",
)
# Light entity services
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_START_INCREASING_BRIGHTNESS,
entity_domain=LIGHT_DOMAIN,
schema=None,
func="async_start_increasing_brightness",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_START_DECREASING_BRIGHTNESS,
entity_domain=LIGHT_DOMAIN,
schema=None,
func="async_start_decreasing_brightness",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_STOP,
entity_domain=LIGHT_DOMAIN,
schema=None,
func="async_stop",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE,
entity_domain=LIGHT_DOMAIN,
schema={
vol.Required(ATTR_BRIGHTNESS): vol.All(
vol.Number(scale=0), vol.Range(0, 255)
)
},
func="async_set_brightness_belief",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_LIGHT_POWER_TRACKED_STATE,
entity_domain=LIGHT_DOMAIN,
schema={vol.Required(ATTR_POWER_STATE): vol.All(cv.boolean)},
func="async_set_power_belief",
)
# Switch entity services
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_POWER_TRACKED_STATE,
entity_domain=SWITCH_DOMAIN,
schema={vol.Required(ATTR_POWER_STATE): cv.boolean},
func="async_set_power_belief",
)

View File

@@ -6,16 +6,13 @@ from typing import Any
from aiohttp.client_exceptions import ClientResponseError
from bond_async import Action, DeviceType
import voluptuous as vol
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BondConfigEntry
from .const import ATTR_POWER_STATE, SERVICE_SET_POWER_TRACKED_STATE
from .entity import BondEntity
@@ -26,12 +23,6 @@ async def async_setup_entry(
) -> None:
"""Set up Bond generic devices."""
data = entry.runtime_data
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_POWER_TRACKED_STATE,
{vol.Required(ATTR_POWER_STATE): cv.boolean},
"async_set_power_belief",
)
async_add_entities(
BondSwitch(data, device)

View File

@@ -1,14 +1,3 @@
"""Constants for the Bring! integration."""
from typing import Final
DOMAIN = "bring"
ATTR_SENDER: Final = "sender"
ATTR_ITEM_NAME: Final = "item"
ATTR_NOTIFICATION_TYPE: Final = "message"
ATTR_REACTION: Final = "reaction"
ATTR_ACTIVITY: Final = "uuid"
ATTR_RECEIVER: Final = "publicUserUuid"
SERVICE_PUSH_NOTIFICATION = "send_message"
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"

View File

@@ -1,6 +1,5 @@
"""Actions for Bring! integration."""
import logging
from typing import TYPE_CHECKING
from bring_api import (
@@ -13,22 +12,28 @@ from bring_api import (
import voluptuous as vol
from homeassistant.components.event import ATTR_EVENT_TYPE
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from .const import (
ATTR_ACTIVITY,
ATTR_REACTION,
ATTR_RECEIVER,
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
from homeassistant.helpers import (
config_validation as cv,
entity_registry as er,
service,
)
from .const import DOMAIN
from .coordinator import BringConfigEntry
_LOGGER = logging.getLogger(__name__)
ATTR_ACTIVITY = "uuid"
ATTR_ITEM_NAME = "item"
ATTR_NOTIFICATION_TYPE = "message"
ATTR_REACTION = "reaction"
ATTR_RECEIVER = "publicUserUuid"
SERVICE_PUSH_NOTIFICATION = "send_message"
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema(
{
@@ -54,6 +59,7 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry:
return entry
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Bring! integration."""
@@ -108,3 +114,17 @@ def async_setup_services(hass: HomeAssistant) -> None:
async_send_activity_stream_reaction,
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_PUSH_NOTIFICATION,
entity_domain=TODO_DOMAIN,
schema={
vol.Required(ATTR_NOTIFICATION_TYPE): vol.All(
vol.Upper, vol.Coerce(BringNotificationType)
),
vol.Optional(ATTR_ITEM_NAME): cv.string,
},
func="async_send_message",
)

View File

@@ -13,7 +13,6 @@ from bring_api import (
BringNotificationType,
BringRequestException,
)
import voluptuous as vol
from homeassistant.components.todo import (
TodoItem,
@@ -23,15 +22,9 @@ from homeassistant.components.todo import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_ITEM_NAME,
ATTR_NOTIFICATION_TYPE,
DOMAIN,
SERVICE_PUSH_NOTIFICATION,
)
from .const import DOMAIN
from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator
from .entity import BringBaseEntity
@@ -63,19 +56,6 @@ async def async_setup_entry(
coordinator.async_add_listener(add_entities)
add_entities()
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_PUSH_NOTIFICATION,
{
vol.Required(ATTR_NOTIFICATION_TYPE): vol.All(
vol.Upper, vol.Coerce(BringNotificationType)
),
vol.Optional(ATTR_ITEM_NAME): cv.string,
},
"async_send_message",
)
class BringTodoListEntity(BringBaseEntity, TodoListEntity):
"""A To-do List representation of the Bring! Shopping List."""

View File

@@ -49,6 +49,7 @@ from .const import ( # noqa: F401
ATTR_SWING_HORIZONTAL_MODES,
ATTR_SWING_MODE,
ATTR_SWING_MODES,
ATTR_TARGET_HUMIDITY_STEP,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TARGET_TEMP_STEP,
@@ -234,6 +235,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"max_temp",
"min_humidity",
"max_humidity",
"target_humidity_step",
}
@@ -249,6 +251,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
ATTR_MAX_TEMP,
ATTR_MIN_HUMIDITY,
ATTR_MAX_HUMIDITY,
ATTR_TARGET_HUMIDITY_STEP,
ATTR_TARGET_TEMP_STEP,
ATTR_PRESET_MODES,
}
@@ -275,6 +278,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_swing_horizontal_mode: str | None
_attr_swing_horizontal_modes: list[str] | None
_attr_target_humidity: float | None = None
_attr_target_humidity_step: int | None = None
_attr_target_temperature_high: float | None
_attr_target_temperature_low: float | None
_attr_target_temperature_step: float | None = None
@@ -323,6 +327,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
data[ATTR_MIN_HUMIDITY] = self.min_humidity
data[ATTR_MAX_HUMIDITY] = self.max_humidity
if self.target_humidity_step is not None:
data[ATTR_TARGET_HUMIDITY_STEP] = self.target_humidity_step
if ClimateEntityFeature.FAN_MODE in supported_features:
data[ATTR_FAN_MODES] = self.fan_modes
@@ -728,6 +735,11 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the maximum humidity."""
return self._attr_max_humidity
@cached_property
def target_humidity_step(self) -> int | None:
"""Return the supported step of humidity."""
return self._attr_target_humidity_step
async def async_service_humidity_set(
entity: ClimateEntity, service_call: ServiceCall

View File

@@ -114,6 +114,7 @@ ATTR_SWING_MODES = "swing_modes"
ATTR_SWING_MODE = "swing_mode"
ATTR_SWING_HORIZONTAL_MODE = "swing_horizontal_mode"
ATTR_SWING_HORIZONTAL_MODES = "swing_horizontal_modes"
ATTR_TARGET_HUMIDITY_STEP = "target_humidity_step"
ATTR_TARGET_TEMP_HIGH = "target_temp_high"
ATTR_TARGET_TEMP_LOW = "target_temp_low"
ATTR_TARGET_TEMP_STEP = "target_temp_step"

View File

@@ -459,8 +459,17 @@ class BaseCloudLLMEntity(Entity):
last_content: Any = chat_log.content[-1]
if last_content.role == "user" and last_content.attachments:
files = await self._async_prepare_files_for_prompt(last_content.attachments)
current_content = last_content.content
last_content = [*(current_content or []), *files]
last_message = cast(dict[str, Any], messages[-1])
assert (
last_message["type"] == "message"
and last_message["role"] == "user"
and isinstance(last_message["content"], str)
)
last_message["content"] = [
{"type": "input_text", "text": last_message["content"]},
*files,
]
tools: list[ToolParam] = []
tool_choice: str | None = None

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
"requirements": ["compit-inext-api==0.7.0"]
"requirements": ["compit-inext-api==0.8.0"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.3"]
}

View File

@@ -67,6 +67,7 @@ async def async_setup_entry(
target_temp_high=None,
target_temp_low=None,
hvac_modes=[cls for cls in HVACMode if cls != HVACMode.HEAT_COOL],
target_humidity_step=5,
),
DemoClimate(
unique_id="climate_3",
@@ -118,6 +119,7 @@ class DemoClimate(ClimateEntity):
target_temp_low: float | None,
hvac_modes: list[HVACMode],
preset_modes: list[str] | None = None,
target_humidity_step: int | None = None,
) -> None:
"""Initialize the climate device."""
self._unique_id = unique_id
@@ -163,6 +165,7 @@ class DemoClimate(ClimateEntity):
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_target_humidity_step = target_humidity_step
@property
def unique_id(self) -> str:

View File

@@ -9,8 +9,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_SHOW_ALL_SOURCES,
@@ -24,9 +25,12 @@ from .const import (
DEFAULT_USE_TELNET,
DEFAULT_ZONE2,
DEFAULT_ZONE3,
DOMAIN,
)
from .receiver import ConnectDenonAVR
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
@@ -34,6 +38,12 @@ _LOGGER = logging.getLogger(__name__)
type DenonavrConfigEntry = ConfigEntry[DenonAVR]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> bool:
"""Set up the denonavr components from a config entry."""
# Connect to receiver

View File

@@ -2,6 +2,7 @@
DOMAIN = "denonavr"
ATTR_DYNAMIC_EQ = "dynamic_eq"
CONF_SHOW_ALL_SOURCES = "show_all_sources"
CONF_ZONE2 = "zone2"

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["denonavr"],
"requirements": ["denonavr==1.2.0"],
"requirements": ["denonavr==1.3.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -26,7 +26,6 @@ from denonavr.exceptions import (
AvrTimoutError,
DenonAvrError,
)
import voluptuous as vol
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
@@ -35,14 +34,14 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE
from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DenonavrConfigEntry
from .const import (
ATTR_DYNAMIC_EQ,
CONF_MANUFACTURER,
CONF_SERIAL_NUMBER,
CONF_UPDATE_AUDYSSEY,
@@ -53,7 +52,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
ATTR_SOUND_MODE_RAW = "sound_mode_raw"
ATTR_DYNAMIC_EQ = "dynamic_eq"
SUPPORT_DENON = (
MediaPlayerEntityFeature.VOLUME_STEP
@@ -76,11 +74,6 @@ SUPPORT_MEDIA_MODES = (
SCAN_INTERVAL = timedelta(seconds=10)
PARALLEL_UPDATES = 1
# Services
SERVICE_GET_COMMAND = "get_command"
SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq"
SERVICE_UPDATE_AUDYSSEY = "update_audyssey"
# HA Telnet events
TELNET_EVENTS = {
"HD",
@@ -134,24 +127,6 @@ async def async_setup_entry(
"%s receiver at host %s initialized", receiver.manufacturer, receiver.host
)
# Register additional services
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_GET_COMMAND,
{vol.Required(ATTR_COMMAND): cv.string},
f"async_{SERVICE_GET_COMMAND}",
)
platform.async_register_entity_service(
SERVICE_SET_DYNAMIC_EQ,
{vol.Required(ATTR_DYNAMIC_EQ): cv.boolean},
f"async_{SERVICE_SET_DYNAMIC_EQ}",
)
platform.async_register_entity_service(
SERVICE_UPDATE_AUDYSSEY,
None,
f"async_{SERVICE_UPDATE_AUDYSSEY}",
)
async_add_entities(entities, update_before_add=True)

View File

@@ -0,0 +1,47 @@
"""Support for Denon AVR receivers using their HTTP interface."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.const import ATTR_COMMAND
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_DYNAMIC_EQ, DOMAIN
# Services
SERVICE_GET_COMMAND = "get_command"
SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq"
SERVICE_UPDATE_AUDYSSEY = "update_audyssey"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_GET_COMMAND,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_COMMAND): cv.string},
func=f"async_{SERVICE_GET_COMMAND}",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_DYNAMIC_EQ,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_DYNAMIC_EQ): cv.boolean},
func=f"async_{SERVICE_SET_DYNAMIC_EQ}",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_UPDATE_AUDYSSEY,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func=f"async_{SERVICE_UPDATE_AUDYSSEY}",
)

View File

@@ -5,8 +5,12 @@ from sucks import VacBot
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .controller import EcovacsController
from .services import async_setup_services
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -22,6 +26,14 @@ PLATFORMS = [
]
type EcovacsConfigEntry = ConfigEntry[EcovacsController]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool:
"""Set up this integration using UI."""

View File

@@ -0,0 +1,27 @@
"""Ecovacs services."""
from __future__ import annotations
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.helpers import service
from .const import DOMAIN
SERVICE_RAW_GET_POSITIONS = "raw_get_positions"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
# Vacuum Services
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_RAW_GET_POSITIONS,
entity_domain=VACUUM_DOMAIN,
schema=None,
func="async_raw_get_positions",
supports_response=SupportsResponse.ONLY,
)

View File

@@ -18,9 +18,8 @@ from homeassistant.components.vacuum import (
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
@@ -32,9 +31,6 @@ from .util import get_name_key
_LOGGER = logging.getLogger(__name__)
ATTR_ERROR = "error"
ATTR_COMPONENT_PREFIX = "component_"
SERVICE_RAW_GET_POSITIONS = "raw_get_positions"
async def async_setup_entry(
@@ -56,14 +52,6 @@ async def async_setup_entry(
_LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums)
async_add_entities(vacuums)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_RAW_GET_POSITIONS,
None,
"async_raw_get_positions",
supports_response=SupportsResponse.ONLY,
)
class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
"""Legacy Ecovacs vacuums."""

View File

@@ -2,12 +2,23 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.BUTTON, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ElgatoConfigEntry) -> bool:
"""Set up Elgato Light from a config entry."""
coordinator = ElgatoDataUpdateCoordinator(hass, entry)

View File

@@ -14,6 +14,3 @@ SCAN_INTERVAL = timedelta(seconds=10)
# Attributes
ATTR_ON = "on"
# Services
SERVICE_IDENTIFY = "identify"

View File

@@ -15,13 +15,9 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .const import SERVICE_IDENTIFY
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
@@ -37,13 +33,6 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities([ElgatoLight(coordinator)])
platform = async_get_current_platform()
platform.async_register_entity_service(
SERVICE_IDENTIFY,
None,
ElgatoLight.async_identify.__name__,
)
class ElgatoLight(ElgatoEntity, LightEntity):
"""Defines an Elgato Light."""

View File

@@ -0,0 +1,25 @@
"""Support for Elgato services."""
from __future__ import annotations
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import service
from .const import DOMAIN
SERVICE_IDENTIFY = "identify"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_IDENTIFY,
entity_domain=LIGHT_DOMAIN,
schema=None,
func="async_identify",
)

View File

@@ -11,11 +11,15 @@ from epson_projector.const import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_CONNECTION_TYPE, HTTP
from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP
from .exceptions import CannotConnect, PoweredOff
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
@@ -47,6 +51,12 @@ async def validate_projector(
return epson_proj
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool:
"""Set up epson from a config entry."""
projector = await validate_projector(

View File

@@ -1,7 +1,7 @@
"""Constants for the epson integration."""
DOMAIN = "epson"
SERVICE_SELECT_CMODE = "select_cmode"
CONF_CONNECTION_TYPE = "connection_type"
ATTR_CMODE = "cmode"

View File

@@ -27,7 +27,6 @@ from epson_projector.const import (
VOL_UP,
VOLUME,
)
import voluptuous as vol
from homeassistant.components.media_player import (
MediaPlayerEntity,
@@ -36,17 +35,12 @@ from homeassistant.components.media_player import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_platform,
entity_registry as er,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EpsonConfigEntry
from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE
from .const import ATTR_CMODE, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -63,12 +57,6 @@ async def async_setup_entry(
entry=config_entry,
)
async_add_entities([projector_entity], True)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SELECT_CMODE,
{vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET))},
SERVICE_SELECT_CMODE,
)
class EpsonProjectorMediaPlayer(MediaPlayerEntity):

View File

@@ -0,0 +1,27 @@
"""Support for Epson projector."""
from __future__ import annotations
from epson_projector.const import CMODE_LIST_SET
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_CMODE, DOMAIN
SERVICE_SELECT_CMODE = "select_cmode"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SELECT_CMODE,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET))},
func=SERVICE_SELECT_CMODE,
)

View File

@@ -29,6 +29,7 @@ from aioesphomeapi import (
Event,
EventInfo,
FanInfo,
InfraredInfo,
LightInfo,
LockInfo,
MediaPlayerInfo,
@@ -85,6 +86,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
DateTimeInfo: Platform.DATETIME,
EventInfo: Platform.EVENT,
FanInfo: Platform.FAN,
InfraredInfo: Platform.INFRARED,
LightInfo: Platform.LIGHT,
LockInfo: Platform.LOCK,
MediaPlayerInfo: Platform.MEDIA_PLAYER,
@@ -520,6 +522,27 @@ class RuntimeEntryData:
),
)
@callback
def async_on_infrared_proxy_receive(
self, hass: HomeAssistant, receive_event: Any
) -> None:
"""Handle an infrared proxy receive event."""
# Fire a Home Assistant event with the infrared data
device_info = self.device_info
if not device_info:
return
hass.bus.async_fire(
f"{DOMAIN}_infrared_proxy_received",
{
"device_name": device_info.name,
"device_mac": device_info.mac_address,
"entry_id": self.entry_id,
"key": receive_event.key,
"timings": receive_event.timings,
},
)
@callback
def async_register_assist_satellite_config_updated_callback(
self,

View File

@@ -0,0 +1,100 @@
"""Infrared platform for ESPHome."""
from __future__ import annotations
import logging
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import EsphomeEntity, async_static_info_updated
from .entry_data import ESPHomeConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
"""ESPHome infrared entity using native API."""
_attr_has_entity_name = True
_attr_name = "Infrared Transmitter"
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
# Infrared entities should go available as soon as the device comes online
self.async_write_ha_state()
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command.
Raises:
HomeAssistantError: If transmission fails.
"""
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
_LOGGER.debug("Sending command: %s", timings)
try:
self._client.infrared_rf_transmit_raw_timings(
self._static_info.key,
carrier_frequency=command.modulation,
timings=timings,
)
except Exception as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_sending_ir_command",
translation_placeholders={
"device_name": self._device_info.name,
"error": str(err),
},
) from err
async def async_setup_entry(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ESPHome infrared entities, filtering out receiver-only devices."""
entry_data = entry.runtime_data
entry_data.info[InfraredInfo] = {}
platform = entity_platform.async_get_current_platform()
def filtered_static_info_update(infos: list[EntityInfo]) -> None:
transmitter_infos: list[EntityInfo] = [
info
for info in infos
if isinstance(info, InfraredInfo)
and info.capabilities & InfraredCapability.TRANSMITTER
]
async_static_info_updated(
hass,
entry_data,
platform,
async_add_entities,
InfraredInfo,
EsphomeInfraredEntity,
EntityState,
transmitter_infos,
)
entry_data.cleanup_callbacks.append(
entry_data.async_register_static_info_callback(
InfraredInfo, filtered_static_info_update
)
)

View File

@@ -17,6 +17,8 @@ from aioesphomeapi import (
EncryptionPlaintextAPIError,
ExecuteServiceResponse,
HomeassistantServiceCall,
InfraredCapability,
InfraredInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
LogLevel,
@@ -692,6 +694,15 @@ class ESPHomeManager:
cli.subscribe_zwave_proxy_request(self._async_zwave_proxy_request)
)
if any(
isinstance(info, InfraredInfo)
and info.capabilities & InfraredCapability.RECEIVER
for info in entity_infos
):
entry_data.disconnect_callbacks.add(
cli.subscribe_infrared_rf_receive(self._async_infrared_proxy_receive)
)
cli.subscribe_home_assistant_states_and_services(
on_state=entry_data.async_update_state,
on_service_call=self.async_on_service_call,
@@ -722,6 +733,10 @@ class ESPHomeManager:
self.hass, self.entry_data.device_info, zwave_home_id
)
def _async_infrared_proxy_receive(self, receive_event: Any) -> None:
"""Handle an infrared proxy receive event."""
self.entry_data.async_on_infrared_proxy_receive(self.hass, receive_event)
async def on_disconnect(self, expected_disconnect: bool) -> None:
"""Run disconnect callbacks on API disconnect."""
entry_data = self.entry_data

View File

@@ -19,7 +19,7 @@
"requirements": [
"aioesphomeapi==43.14.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.5.0"
"bleak-esphome==3.6.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@@ -137,6 +137,9 @@
"error_compiling": {
"message": "Error compiling {configuration}. Try again in ESPHome dashboard for more information."
},
"error_sending_ir_command": {
"message": "Error sending IR command to {device_name}: {error}"
},
"error_uploading": {
"message": "Error during OTA (Over-The-Air) update of {configuration}. Try again in ESPHome dashboard for more information."
},

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
@@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@contextmanager
def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]:
def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Generator[Path]:
"""Get an uploaded file.
File is removed at the end of the context.

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager, contextmanager
from datetime import timedelta
import logging
@@ -132,7 +132,7 @@ class FjaraskupanCoordinator(DataUpdateCoordinator[State]):
self.async_set_updated_data(self.device.state)
@asynccontextmanager
async def async_connect_and_update(self) -> AsyncIterator[Device]:
async def async_connect_and_update(self) -> AsyncGenerator[Device]:
"""Provide an up-to-date device for use during connections."""
if (
ble_device := async_ble_device_from_address(

View File

@@ -7,6 +7,7 @@ from functools import lru_cache, partial
import logging
import os
import pathlib
import shutil
from typing import Any, TypedDict
from aiohttp import hdrs, web, web_urldispatcher
@@ -36,6 +37,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.util.hass_dict import HassKey
from .pr_download import download_pr_artifact
from .storage import (
async_setup_frontend_storage,
async_system_store as async_system_store,
@@ -55,6 +57,10 @@ CONF_EXTRA_MODULE_URL = "extra_module_url"
CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5"
CONF_FRONTEND_REPO = "development_repo"
CONF_JS_VERSION = "javascript_version"
CONF_DEVELOPMENT_PR = "development_pr"
CONF_GITHUB_TOKEN = "github_token"
DEV_ARTIFACTS_DIR = "development_artifacts"
DEFAULT_THEME_COLOR = "#2980b9"
@@ -133,6 +139,8 @@ CONFIG_SCHEMA = vol.Schema(
DOMAIN: vol.Schema(
{
vol.Optional(CONF_FRONTEND_REPO): cv.isdir,
vol.Inclusive(CONF_DEVELOPMENT_PR, "development_pr"): cv.positive_int,
vol.Inclusive(CONF_GITHUB_TOKEN, "development_pr"): cv.string,
vol.Optional(CONF_THEMES): vol.All(dict, _validate_themes),
vol.Optional(CONF_EXTRA_MODULE_URL): vol.All(
cv.ensure_list, [cv.string]
@@ -425,6 +433,49 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
repo_path = conf.get(CONF_FRONTEND_REPO)
dev_pr_number = conf.get(CONF_DEVELOPMENT_PR)
pr_cache_dir = pathlib.Path(hass.config.cache_path(DOMAIN, DEV_ARTIFACTS_DIR))
if not dev_pr_number and pr_cache_dir.exists():
try:
await hass.async_add_executor_job(shutil.rmtree, pr_cache_dir)
_LOGGER.debug("Cleaned up frontend development artifacts")
except OSError as err:
_LOGGER.warning(
"Could not clean up frontend development artifacts: %s", err
)
# Priority: development_repo > development_pr > integrated
if repo_path and dev_pr_number:
_LOGGER.warning(
"Both development_repo and development_pr are specified for frontend. "
"Using development_repo, remove development_repo to use "
"automatic PR download"
)
dev_pr_number = None
if dev_pr_number:
github_token: str = conf[CONF_GITHUB_TOKEN]
try:
dev_pr_dir = await download_pr_artifact(
hass, dev_pr_number, github_token, pr_cache_dir
)
repo_path = str(dev_pr_dir)
_LOGGER.info("Using frontend from PR #%s", dev_pr_number)
except HomeAssistantError as err:
_LOGGER.error(
"Failed to download PR #%s: %s, falling back to the integrated frontend",
dev_pr_number,
err,
)
except Exception: # pylint: disable=broad-exception-caught
_LOGGER.exception(
"Unexpected error downloading PR #%s, "
"falling back to the integrated frontend",
dev_pr_number,
)
is_dev = repo_path is not None
root_path = _frontend_root(repo_path)

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260128.3"]
"requirements": ["home-assistant-frontend==20260128.6"]
}

View File

@@ -0,0 +1,242 @@
"""GitHub PR artifact download functionality for frontend development."""
from __future__ import annotations
import io
import logging
import pathlib
import shutil
import zipfile
from aiogithubapi import (
GitHubAPI,
GitHubAuthenticationException,
GitHubException,
GitHubNotFoundException,
GitHubPermissionException,
GitHubRatelimitException,
)
from aiohttp import ClientError, ClientResponseError, ClientTimeout
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
GITHUB_REPO = "home-assistant/frontend"
ARTIFACT_NAME = "frontend-build"
# Zip bomb protection limits (10x typical frontend build size)
# Typical frontend build: ~4500 files, ~135MB uncompressed
MAX_ZIP_FILES = 50000
MAX_ZIP_SIZE = 1500 * 1024 * 1024 # 1.5GB
ERROR_INVALID_TOKEN = (
"GitHub token is invalid or expired. "
"Please check your github_token in the frontend configuration. "
"Generate a new token at https://github.com/settings/tokens"
)
ERROR_RATE_LIMIT = (
"GitHub API rate limit exceeded or token lacks permissions. "
"Ensure your token has 'repo' or 'public_repo' scope"
)
async def _get_pr_head_sha(client: GitHubAPI, pr_number: int) -> str:
"""Get the head SHA for the PR."""
try:
response = await client.generic(
endpoint=f"/repos/home-assistant/frontend/pulls/{pr_number}",
)
return str(response.data["head"]["sha"])
except GitHubAuthenticationException as err:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
except (GitHubRatelimitException, GitHubPermissionException) as err:
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
except GitHubNotFoundException as err:
raise HomeAssistantError(
f"PR #{pr_number} does not exist in repository {GITHUB_REPO}"
) from err
except GitHubException as err:
raise HomeAssistantError(f"GitHub API error: {err}") from err
async def _find_pr_artifact(client: GitHubAPI, pr_number: int, head_sha: str) -> str:
"""Find the build artifact for the given PR and commit SHA.
Returns the artifact download URL.
"""
try:
response = await client.generic(
endpoint="/repos/home-assistant/frontend/actions/workflows/ci.yaml/runs",
params={"head_sha": head_sha, "per_page": 10},
)
for run in response.data.get("workflow_runs", []):
if run["status"] == "completed" and run["conclusion"] == "success":
artifacts_response = await client.generic(
endpoint=f"/repos/home-assistant/frontend/actions/runs/{run['id']}/artifacts",
)
for artifact in artifacts_response.data.get("artifacts", []):
if artifact["name"] == ARTIFACT_NAME:
_LOGGER.info(
"Found artifact '%s' from CI run #%s",
ARTIFACT_NAME,
run["id"],
)
return str(artifact["archive_download_url"])
raise HomeAssistantError(
f"No '{ARTIFACT_NAME}' artifact found for PR #{pr_number}. "
"Possible reasons: CI has not run yet or is running, "
"or the build failed, or the PR artifact expired. "
f"Check https://github.com/{GITHUB_REPO}/pull/{pr_number}/checks"
)
except GitHubAuthenticationException as err:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
except (GitHubRatelimitException, GitHubPermissionException) as err:
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
except GitHubException as err:
raise HomeAssistantError(f"GitHub API error: {err}") from err
async def _download_artifact_data(
hass: HomeAssistant, artifact_url: str, github_token: str
) -> bytes:
"""Download artifact data from GitHub."""
session = async_get_clientsession(hass)
headers = {
"Authorization": f"token {github_token}",
"Accept": "application/vnd.github+json",
}
try:
response = await session.get(
artifact_url, headers=headers, timeout=ClientTimeout(total=60)
)
response.raise_for_status()
return await response.read()
except ClientResponseError as err:
if err.status == 401:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
if err.status == 403:
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
raise HomeAssistantError(
f"Failed to download artifact: HTTP {err.status}"
) from err
except TimeoutError as err:
raise HomeAssistantError(
"Timeout downloading artifact (>60s). Check your network connection"
) from err
except ClientError as err:
raise HomeAssistantError(f"Network error downloading artifact: {err}") from err
def _extract_artifact(
artifact_data: bytes,
cache_dir: pathlib.Path,
head_sha: str,
) -> None:
"""Extract artifact and save SHA (runs in executor)."""
frontend_dir = cache_dir / "hass_frontend"
if cache_dir.exists():
shutil.rmtree(cache_dir)
frontend_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(io.BytesIO(artifact_data)) as zip_file:
# Validate zip contents to protect against zip bombs
# See: https://github.com/python/cpython/issues/80643
total_size = 0
for file_count, info in enumerate(zip_file.infolist(), start=1):
total_size += info.file_size
if file_count > MAX_ZIP_FILES:
raise ValueError(
f"Zip contains too many files (>{MAX_ZIP_FILES}), possible zip bomb"
)
if total_size > MAX_ZIP_SIZE:
raise ValueError(
f"Zip uncompressed size too large (>{MAX_ZIP_SIZE} bytes), "
"possible zip bomb"
)
zip_file.extractall(str(frontend_dir))
# Save the commit SHA for cache validation
sha_file = cache_dir / ".sha"
sha_file.write_text(head_sha)
async def download_pr_artifact(
hass: HomeAssistant,
pr_number: int,
github_token: str,
tmp_dir: pathlib.Path,
) -> pathlib.Path:
"""Download and extract frontend PR artifact from GitHub.
Returns the path to the tmp directory containing hass_frontend/.
Raises HomeAssistantError on failure.
"""
try:
session = async_get_clientsession(hass)
except Exception as err:
raise HomeAssistantError(f"Failed to get HTTP client session: {err}") from err
client = GitHubAPI(token=github_token, session=session)
head_sha = await _get_pr_head_sha(client, pr_number)
frontend_dir = tmp_dir / "hass_frontend"
sha_file = tmp_dir / ".sha"
if frontend_dir.exists() and sha_file.exists():
try:
cached_sha = await hass.async_add_executor_job(sha_file.read_text)
if cached_sha.strip() == head_sha:
_LOGGER.info(
"Using cached PR #%s (commit %s) from %s",
pr_number,
head_sha[:8],
tmp_dir,
)
return tmp_dir
_LOGGER.info(
"PR #%s has new commits (cached: %s, current: %s), re-downloading",
pr_number,
cached_sha[:8],
head_sha[:8],
)
except OSError as err:
_LOGGER.debug("Failed to read cache SHA file: %s", err)
artifact_url = await _find_pr_artifact(client, pr_number, head_sha)
_LOGGER.info("Downloading frontend PR #%s artifact", pr_number)
artifact_data = await _download_artifact_data(hass, artifact_url, github_token)
try:
await hass.async_add_executor_job(
_extract_artifact, artifact_data, tmp_dir, head_sha
)
except zipfile.BadZipFile as err:
raise HomeAssistantError(
f"Downloaded artifact for PR #{pr_number} is corrupted or invalid"
) from err
except ValueError as err:
raise HomeAssistantError(
f"Downloaded artifact for PR #{pr_number} failed validation: {err}"
) from err
except OSError as err:
raise HomeAssistantError(
f"Failed to extract artifact for PR #{pr_number}: {err}"
) from err
_LOGGER.info(
"Successfully downloaded and extracted PR #%s (commit %s) to %s",
pr_number,
head_sha[:8],
tmp_dir,
)
return tmp_dir

View File

@@ -86,7 +86,7 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity):
)
@property
def battery_level(self):
def battery_level(self) -> int | None:
"""Return battery value of the device."""
return self._battery

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"requirements": ["growattServer==1.7.1"]
"requirements": ["growattServer==1.9.0"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["pyhik"],
"quality_scale": "legacy",
"requirements": ["pyHik==0.4.1"]
"requirements": ["pyHik==0.4.2"]
}

View File

@@ -115,7 +115,6 @@ SENSORS = (
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
device_class=SensorDeviceClass.VOLUME,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="hot_water_counter",
),
HomeConnectSensorEntityDescription(

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import AsyncIterator, Awaitable, Callable
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import logging
from typing import TYPE_CHECKING, Protocol, TypedDict
@@ -281,7 +281,7 @@ def async_is_firmware_update_in_progress(hass: HomeAssistant, device: str) -> bo
@asynccontextmanager
async def async_firmware_update_context(
hass: HomeAssistant, device: str, source_domain: str
) -> AsyncIterator[None]:
) -> AsyncGenerator[None]:
"""Register a device as having its firmware being actively updated."""
async_register_firmware_update_in_progress(hass, device, source_domain)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Sequence
from collections.abc import AsyncGenerator, Callable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
@@ -125,7 +125,7 @@ class OwningAddon:
return addon_info.state == AddonState.RUNNING
@asynccontextmanager
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]:
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncGenerator[None]:
"""Temporarily stop the add-on, restarting it after completion."""
addon_manager = self._get_addon_manager(hass)
@@ -165,7 +165,7 @@ class OwningIntegration:
)
@asynccontextmanager
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]:
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncGenerator[None]:
"""Temporarily stop the integration, restarting it after completion."""
if (entry := hass.config_entries.async_get_entry(self.config_entry_id)) is None:
yield
@@ -368,7 +368,7 @@ async def probe_silabs_firmware_type(
@asynccontextmanager
async def async_firmware_flashing_context(
hass: HomeAssistant, device: str, source_domain: str
) -> AsyncIterator[None]:
) -> AsyncGenerator[None]:
"""Register a device as having its firmware being actively interacted with."""
async with async_firmware_update_context(hass, device, source_domain):
firmware_info = await guess_firmware_info(hass, device)

View File

@@ -1,21 +1,25 @@
"""The Husqvarna Automower integration."""
import logging
from aioautomower.session import AutomowerSession
from aiohttp import ClientResponseError
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
config_validation as cv,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from . import api
from .const import DOMAIN
from .coordinator import AutomowerConfigEntry, AutomowerDataUpdateCoordinator
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
@@ -30,6 +34,12 @@ PLATFORMS: list[Platform] = [
]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool:
"""Set up this integration using UI."""
implementation = (

View File

@@ -142,3 +142,6 @@ ERROR_KEYS = [
"wrong_pin_code",
"zone_generator_problem",
]
MOW = "mow"
PARK = "park"

View File

@@ -1,11 +1,9 @@
"""Husqvarna Automower lawn mower entity."""
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from aioautomower.model import MowerActivities, MowerStates, WorkArea
import voluptuous as vol
from homeassistant.components.lawn_mower import (
LawnMowerActivity,
@@ -14,16 +12,13 @@ from homeassistant.components.lawn_mower import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry
from .const import DOMAIN, ERROR_STATES
from .const import DOMAIN, ERROR_STATES, MOW, PARK
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerBaseEntity, handle_sending_exception
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING)
@@ -41,9 +36,6 @@ SUPPORT_STATE_SERVICES = (
| LawnMowerEntityFeature.PAUSE
| LawnMowerEntityFeature.START_MOWING
)
MOW = "mow"
PARK = "park"
OVERRIDE_MODES = [MOW, PARK]
async def async_setup_entry(
@@ -62,31 +54,6 @@ async def async_setup_entry(
_async_add_new_devices(set(coordinator.data))
coordinator.new_devices_callbacks.append(_async_add_new_devices)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
"override_schedule",
{
vol.Required("override_mode"): vol.In(OVERRIDE_MODES),
vol.Required("duration"): vol.All(
cv.time_period,
cv.positive_timedelta,
vol.Range(min=timedelta(minutes=1), max=timedelta(days=42)),
),
},
"async_override_schedule",
)
platform.async_register_entity_service(
"override_schedule_work_area",
{
vol.Required("work_area_id"): vol.Coerce(int),
vol.Required("duration"): vol.All(
cv.time_period,
cv.positive_timedelta,
vol.Range(min=timedelta(minutes=1), max=timedelta(days=42)),
),
},
"async_override_schedule_work_area",
)
class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity):

View File

@@ -0,0 +1,49 @@
"""Husqvarna Automower services."""
from datetime import timedelta
import voluptuous as vol
from homeassistant.components.lawn_mower import DOMAIN as LAWN_MOWER_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN, MOW, PARK
OVERRIDE_MODES = [MOW, PARK]
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
"override_schedule",
entity_domain=LAWN_MOWER_DOMAIN,
schema={
vol.Required("override_mode"): vol.In(OVERRIDE_MODES),
vol.Required("duration"): vol.All(
cv.time_period,
cv.positive_timedelta,
vol.Range(min=timedelta(minutes=1), max=timedelta(days=42)),
),
},
func="async_override_schedule",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"override_schedule_work_area",
entity_domain=LAWN_MOWER_DOMAIN,
schema={
vol.Required("work_area_id"): vol.Coerce(int),
vol.Required("duration"): vol.All(
cv.time_period,
cv.positive_timedelta,
vol.Range(min=timedelta(minutes=1), max=timedelta(days=42)),
),
},
func="async_override_schedule_work_area",
)

View File

@@ -4,7 +4,13 @@ from homeassistant.const import Platform
DOMAIN = "huum"
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR,
]
CONFIG_STEAMER = 1
CONFIG_LIGHT = 2

View File

@@ -0,0 +1,42 @@
"""Sensor platform for Huum sauna integration."""
from __future__ import annotations
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
from .entity import HuumBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HuumConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Huum sensors from a config entry."""
async_add_entities([HuumTemperatureSensor(config_entry.runtime_data)])
class HuumTemperatureSensor(HuumBaseEntity, SensorEntity):
"""Representation of a Huum temperature sensor."""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None:
"""Initialize the temperature sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_temperature"
@property
def native_value(self) -> int | None:
"""Return the current temperature."""
return self.coordinator.data.temperature

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine
from collections.abc import AsyncGenerator, Callable, Coroutine
from contextlib import asynccontextmanager
from dataclasses import dataclass
import logging
@@ -297,7 +297,7 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
@asynccontextmanager
async def _async_provision_context(
self, ble_mac: str
) -> AsyncIterator[asyncio.Future[str]]:
) -> AsyncGenerator[asyncio.Future[str]]:
"""Context manager to register and cleanup provisioning future."""
future = self.hass.loop.create_future()
provisioning_futures = async_get_provisioning_futures(self.hass)

View File

@@ -0,0 +1,156 @@
"""Provides functionality to interact with infrared devices."""
from __future__ import annotations
from abc import abstractmethod
from datetime import datetime, timedelta
import logging
from typing import final
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .protocols import InfraredCommand, NECInfraredCommand, Timing
__all__ = [
"DOMAIN",
"InfraredCommand",
"InfraredEntity",
"InfraredEntityDescription",
"NECInfraredCommand",
"Timing",
"async_get_emitters",
"async_send_command",
]
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the infrared domain."""
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@callback
def async_get_emitters(hass: HomeAssistant) -> list[InfraredEntity]:
"""Get all infrared emitters."""
component = hass.data.get(DATA_COMPONENT)
if component is None:
return []
return list(component.entities)
async def async_send_command(
hass: HomeAssistant,
entity_uuid: str,
command: InfraredCommand,
context: Context | None = None,
) -> None:
"""Send an IR command to the specified infrared entity.
Raises:
HomeAssistantError: If the infrared entity is not found.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
ent_reg = er.async_get(hass)
entity_id = er.async_validate_entity_id(ent_reg, entity_uuid)
entity = component.get_entity(entity_id)
if entity is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={"entity_id": entity_id},
)
if context is not None:
entity.async_set_context(context)
await entity.async_send_command_internal(command)
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared entities."""
class InfraredEntity(RestoreEntity):
"""Base class for infrared transmitter entities."""
entity_description: InfraredEntityDescription
_attr_should_poll = False
_attr_state: None
__last_command_sent: datetime | None = None
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
if (last_command := self.__last_command_sent) is None:
return None
return last_command.isoformat(timespec="milliseconds")
@final
async def async_send_command_internal(self, command: InfraredCommand) -> None:
"""Send an IR command and update state.
Should not be overridden, handles setting last sent timestamp.
"""
await self.async_send_command(command)
self.__last_command_sent = dt_util.utcnow()
self.async_write_ha_state()
@final
async def async_internal_added_to_hass(self) -> None:
"""Call when the infrared entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state is not None:
self.__last_command_sent = dt_util.parse_datetime(state.state)
@abstractmethod
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command.
Args:
command: The IR command to send.
Raises:
HomeAssistantError: If transmission fails.
"""

View File

@@ -0,0 +1,5 @@
"""Constants for the Infrared integration."""
from typing import Final
DOMAIN: Final = "infrared"

View File

@@ -0,0 +1,7 @@
{
"entity_component": {
"_": {
"default": "mdi:led-on"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "infrared",
"name": "Infrared",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,124 @@
"""IR protocol definitions for the Infrared integration."""
import abc
from dataclasses import dataclass
from typing import override
@dataclass(frozen=True, slots=True)
class Timing:
"""High/low signal timing."""
high_us: int
low_us: int
class InfraredCommand(abc.ABC):
"""Base class for IR commands."""
repeat_count: int
modulation: int
def __init__(self, *, modulation: int, repeat_count: int = 0) -> None:
"""Initialize the IR command."""
self.modulation = modulation
self.repeat_count = repeat_count
@abc.abstractmethod
def get_raw_timings(self) -> list[Timing]:
"""Get raw timings for the command."""
class NECInfraredCommand(InfraredCommand):
"""NEC IR command."""
address: int
command: int
def __init__(
self,
*,
address: int,
command: int,
modulation: int = 38000,
repeat_count: int = 0,
) -> None:
"""Initialize the NEC IR command."""
super().__init__(modulation=modulation, repeat_count=repeat_count)
self.address = address
self.command = command
@override
def get_raw_timings(self) -> list[Timing]:
"""Get raw timings for the NEC command.
NEC protocol timing (in microseconds):
- Leader pulse: 9000µs high, 4500µs low
- Logical '0': 562µs high, 562µs low
- Logical '1': 562µs high, 1687µs low
- End pulse: 562µs high
- Repeat code: 9000µs high, 2250µs low, 562µs end pulse
- Frame gap: ~96ms between end pulse and next frame (total frame ~108ms)
Data format (32 bits, LSB first):
- Standard NEC: address (8-bit) + ~address (8-bit) + command (8-bit) + ~command (8-bit)
- Extended NEC: address_low (8-bit) + address_high (8-bit) + command (8-bit) + ~command (8-bit)
"""
# NEC timing constants (microseconds)
leader_high = 9000
leader_low = 4500
bit_high = 562
zero_low = 562
one_low = 1687
repeat_low = 2250
frame_gap = 96000 # Gap to make total frame ~108ms
timings: list[Timing] = [Timing(high_us=leader_high, low_us=leader_low)]
# Determine if standard (8-bit) or extended (16-bit) address
if self.address <= 0xFF:
# Standard NEC: address + inverted address
address_low = self.address & 0xFF
address_high = (~self.address) & 0xFF
else:
# Extended NEC: 16-bit address (no inversion)
address_low = self.address & 0xFF
address_high = (self.address >> 8) & 0xFF
command_byte = self.command & 0xFF
command_inverted = (~self.command) & 0xFF
# Build 32-bit command data (LSB first in transmission)
data = (
address_low
| (address_high << 8)
| (command_byte << 16)
| (command_inverted << 24)
)
for _ in range(32):
bit = data & 1
if bit:
timings.append(Timing(high_us=bit_high, low_us=one_low))
else:
timings.append(Timing(high_us=bit_high, low_us=zero_low))
data >>= 1
# End pulse
timings.append(Timing(high_us=bit_high, low_us=0))
# Add repeat codes if requested
for _ in range(self.repeat_count):
# Replace the last timing's low_us with the frame gap
last_timing = timings[-1]
timings[-1] = Timing(high_us=last_timing.high_us, low_us=frame_gap)
# Repeat code: leader burst + shorter space + end pulse
timings.extend(
[
Timing(high_us=leader_high, low_us=repeat_low),
Timing(high_us=bit_high, low_us=0),
]
)
return timings

View File

@@ -0,0 +1,10 @@
{
"exceptions": {
"component_not_loaded": {
"message": "Infrared component not loaded"
},
"entity_not_found": {
"message": "Infrared entity `{entity_id}` not found"
}
}
}

View File

@@ -150,7 +150,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
self._attr_state = state
self._attr_is_volume_muted = volume_muted
self._attr_volume_level = volume_level
# Only update volume_level if the API provides it, otherwise preserve current value
if volume_level is not None:
self._attr_volume_level = volume_level
self._attr_media_content_type = media_content_type
self._attr_media_content_id = media_content_id
self._attr_media_title = media_title
@@ -190,7 +192,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
)
features = MediaPlayerEntityFeature(0)
if "PlayMediaSource" in commands:
if "PlayMediaSource" in commands or self.capabilities.get(
"SupportsMediaControl", False
):
features |= (
MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -201,10 +205,10 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.SEARCH_MEDIA
)
if "Mute" in commands:
if "Mute" in commands and "Unmute" in commands:
features |= MediaPlayerEntityFeature.VOLUME_MUTE
if "VolumeSet" in commands:
if "VolumeSet" in commands or "SetVolume" in commands:
features |= MediaPlayerEntityFeature.VOLUME_SET
return features
@@ -219,11 +223,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
"""Send pause command."""
self.coordinator.api_client.jellyfin.remote_pause(self.session_id)
self._attr_state = MediaPlayerState.PAUSED
self.schedule_update_ha_state()
def media_play(self) -> None:
"""Send play command."""
self.coordinator.api_client.jellyfin.remote_unpause(self.session_id)
self._attr_state = MediaPlayerState.PLAYING
self.schedule_update_ha_state()
def media_play_pause(self) -> None:
"""Send the PlayPause command to the session."""
@@ -233,6 +239,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
"""Send stop command."""
self.coordinator.api_client.jellyfin.remote_stop(self.session_id)
self._attr_state = MediaPlayerState.IDLE
self.schedule_update_ha_state()
def play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
@@ -247,6 +254,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
self.coordinator.api_client.jellyfin.remote_set_volume(
self.session_id, int(volume * 100)
)
self._attr_volume_level = volume
self.schedule_update_ha_state()
def mute_volume(self, mute: bool) -> None:
"""Mute the volume."""
@@ -254,6 +263,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
self.coordinator.api_client.jellyfin.remote_mute(self.session_id)
else:
self.coordinator.api_client.jellyfin.remote_unmute(self.session_id)
self._attr_is_volume_muted = mute
self.schedule_update_ha_state()
async def async_browse_media(
self,

View File

@@ -17,11 +17,16 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_WS_PORT
from .const import CONF_WS_PORT, DOMAIN
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER]
type KodiConfigEntry = ConfigEntry[KodiRuntimeData]
@@ -35,6 +40,12 @@ class KodiRuntimeData:
kodi: Kodi
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: KodiConfigEntry) -> bool:
"""Set up Kodi from a config entry."""
conn = get_kodi_connection(

View File

@@ -11,7 +11,6 @@ from typing import Any, Concatenate
from jsonrpc_base.jsonrpc import ProtocolError, TransportError
from pykodi import CannotConnectError
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -31,16 +30,11 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED,
)
from homeassistant.core import CoreState, HomeAssistant, callback
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_platform,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.network import is_internal_request
from homeassistant.helpers.typing import VolDictType
from homeassistant.util import dt as dt_util
from . import KodiConfigEntry
@@ -85,42 +79,12 @@ MAP_KODI_MEDIA_TYPES: dict[MediaType | str, str] = {
}
SERVICE_ADD_MEDIA = "add_to_playlist"
SERVICE_CALL_METHOD = "call_method"
ATTR_MEDIA_TYPE = "media_type"
ATTR_MEDIA_NAME = "media_name"
ATTR_MEDIA_ARTIST_NAME = "artist_name"
ATTR_MEDIA_ID = "media_id"
ATTR_METHOD = "method"
KODI_ADD_MEDIA_SCHEMA: VolDictType = {
vol.Required(ATTR_MEDIA_TYPE): cv.string,
vol.Optional(ATTR_MEDIA_ID): cv.string,
vol.Optional(ATTR_MEDIA_NAME): cv.string,
vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string,
}
KODI_CALL_METHOD_SCHEMA = cv.make_entity_service_schema(
{vol.Required(ATTR_METHOD): cv.string}, extra=vol.ALLOW_EXTRA
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: KodiConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Kodi media player platform."""
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_ADD_MEDIA, KODI_ADD_MEDIA_SCHEMA, "async_add_media_to_playlist"
)
platform.async_register_entity_service(
SERVICE_CALL_METHOD, KODI_CALL_METHOD_SCHEMA, "async_call_method"
)
data = config_entry.runtime_data
name = config_entry.data[CONF_NAME]
if (uid := config_entry.unique_id) is None:

View File

@@ -0,0 +1,54 @@
"""Support for interfacing with the XBMC/Kodi JSON-RPC API."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.typing import VolDictType
from .const import DOMAIN
SERVICE_ADD_MEDIA = "add_to_playlist"
SERVICE_CALL_METHOD = "call_method"
ATTR_MEDIA_TYPE = "media_type"
ATTR_MEDIA_NAME = "media_name"
ATTR_MEDIA_ARTIST_NAME = "artist_name"
ATTR_MEDIA_ID = "media_id"
ATTR_METHOD = "method"
KODI_ADD_MEDIA_SCHEMA: VolDictType = {
vol.Required(ATTR_MEDIA_TYPE): cv.string,
vol.Optional(ATTR_MEDIA_ID): cv.string,
vol.Optional(ATTR_MEDIA_NAME): cv.string,
vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string,
}
KODI_CALL_METHOD_SCHEMA = cv.make_entity_service_schema(
{vol.Required(ATTR_METHOD): cv.string}, extra=vol.ALLOW_EXTRA
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_ADD_MEDIA,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=KODI_ADD_MEDIA_SCHEMA,
func="async_add_media_to_playlist",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_CALL_METHOD,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=KODI_CALL_METHOD_SCHEMA,
func="async_call_method",
)

View File

@@ -0,0 +1,20 @@
"""LG IR Remote integration for Home Assistant."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up LG IR from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a LG IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,256 @@
"""Button platform for LG IR integration."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.components.infrared import NECInfraredCommand, async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import (
CONF_DEVICE_TYPE,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
LG_ADDRESS,
LGDeviceType,
LGTVCommand,
)
@dataclass(frozen=True, kw_only=True)
class LgIrButtonEntityDescription(ButtonEntityDescription):
"""Describes LG IR button entity."""
command_code: int
TV_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = (
LgIrButtonEntityDescription(
key="power_on",
translation_key="power_on",
command_code=LGTVCommand.POWER_ON,
),
LgIrButtonEntityDescription(
key="power_off",
translation_key="power_off",
command_code=LGTVCommand.POWER_OFF,
),
LgIrButtonEntityDescription(
key="hdmi_1",
translation_key="hdmi_1",
command_code=LGTVCommand.HDMI_1,
),
LgIrButtonEntityDescription(
key="hdmi_2",
translation_key="hdmi_2",
command_code=LGTVCommand.HDMI_2,
),
LgIrButtonEntityDescription(
key="hdmi_3",
translation_key="hdmi_3",
command_code=LGTVCommand.HDMI_3,
),
LgIrButtonEntityDescription(
key="hdmi_4",
translation_key="hdmi_4",
command_code=LGTVCommand.HDMI_4,
),
LgIrButtonEntityDescription(
key="exit",
translation_key="exit",
command_code=LGTVCommand.EXIT,
),
LgIrButtonEntityDescription(
key="info",
translation_key="info",
command_code=LGTVCommand.INFO,
),
LgIrButtonEntityDescription(
key="guide",
translation_key="guide",
command_code=LGTVCommand.GUIDE,
),
LgIrButtonEntityDescription(
key="up",
translation_key="up",
command_code=LGTVCommand.NAV_UP,
),
LgIrButtonEntityDescription(
key="down",
translation_key="down",
command_code=LGTVCommand.NAV_DOWN,
),
LgIrButtonEntityDescription(
key="left",
translation_key="left",
command_code=LGTVCommand.NAV_LEFT,
),
LgIrButtonEntityDescription(
key="right",
translation_key="right",
command_code=LGTVCommand.NAV_RIGHT,
),
LgIrButtonEntityDescription(
key="ok",
translation_key="ok",
command_code=LGTVCommand.OK,
),
LgIrButtonEntityDescription(
key="back",
translation_key="back",
command_code=LGTVCommand.BACK,
),
LgIrButtonEntityDescription(
key="home",
translation_key="home",
command_code=LGTVCommand.HOME,
),
LgIrButtonEntityDescription(
key="menu",
translation_key="menu",
command_code=LGTVCommand.MENU,
),
LgIrButtonEntityDescription(
key="input",
translation_key="input",
command_code=LGTVCommand.INPUT,
),
LgIrButtonEntityDescription(
key="num_0",
translation_key="num_0",
command_code=LGTVCommand.NUM_0,
),
LgIrButtonEntityDescription(
key="num_1",
translation_key="num_1",
command_code=LGTVCommand.NUM_1,
),
LgIrButtonEntityDescription(
key="num_2",
translation_key="num_2",
command_code=LGTVCommand.NUM_2,
),
LgIrButtonEntityDescription(
key="num_3",
translation_key="num_3",
command_code=LGTVCommand.NUM_3,
),
LgIrButtonEntityDescription(
key="num_4",
translation_key="num_4",
command_code=LGTVCommand.NUM_4,
),
LgIrButtonEntityDescription(
key="num_5",
translation_key="num_5",
command_code=LGTVCommand.NUM_5,
),
LgIrButtonEntityDescription(
key="num_6",
translation_key="num_6",
command_code=LGTVCommand.NUM_6,
),
LgIrButtonEntityDescription(
key="num_7",
translation_key="num_7",
command_code=LGTVCommand.NUM_7,
),
LgIrButtonEntityDescription(
key="num_8",
translation_key="num_8",
command_code=LGTVCommand.NUM_8,
),
LgIrButtonEntityDescription(
key="num_9",
translation_key="num_9",
command_code=LGTVCommand.NUM_9,
),
)
HIFI_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = (
LgIrButtonEntityDescription(
key="power_on",
translation_key="power_on",
command_code=LGTVCommand.POWER_ON,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LG IR buttons from config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
device_type = entry.data.get(CONF_DEVICE_TYPE, LGDeviceType.TV)
if device_type == LGDeviceType.TV:
async_add_entities(
LgIrButton(entry, infrared_entity_id, description)
for description in TV_BUTTON_DESCRIPTIONS
)
class LgIrButton(ButtonEntity):
"""LG IR button entity."""
_attr_has_entity_name = True
_description: LgIrButtonEntityDescription
def __init__(
self,
entry: ConfigEntry,
infrared_entity_id: str,
description: LgIrButtonEntityDescription,
) -> None:
"""Initialize LG IR button."""
self._entry = entry
self._infrared_entity_id = infrared_entity_id
self._description = description
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG"
)
async def async_added_to_hass(self) -> None:
"""Subscribe to infrared entity state changes."""
await super().async_added_to_hass()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
new_state = event.data["new_state"]
self._attr_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._infrared_entity_id], _async_ir_state_changed
)
)
# Set initial availability based on current infrared entity state
ir_state = self.hass.states.get(self._infrared_entity_id)
self._attr_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
async def async_press(self) -> None:
"""Press the button."""
command = NECInfraredCommand(
address=LG_ADDRESS,
command=self._description.command_code,
repeat_count=1,
)
await async_send_command(
self.hass, self._infrared_entity_id, command, context=self._context
)

View File

@@ -0,0 +1,82 @@
"""Config flow for LG IR integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LGDeviceType
DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = {
LGDeviceType.TV: "TV",
}
class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for LG IR."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
entities = async_get_emitters(self.hass)
if not entities:
return self.async_abort(reason="no_emitters")
valid_entity_ids = [entity.entity_id for entity in entities]
if user_input is not None:
entity_id = user_input[CONF_INFRARED_ENTITY_ID]
device_type = user_input[CONF_DEVICE_TYPE]
await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}")
self._abort_if_unique_id_configured()
# Get entity name for the title
entity_name = next(
(
entity.name or entity.entity_id
for entity in entities
if entity.entity_id == entity_id
),
entity_id,
)
device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)]
title = f"LG {device_type_name} via {entity_name}"
return self.async_create_entry(title=title, data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_DEVICE_TYPE): SelectSelector(
SelectSelectorConfig(
options=[device_type.value for device_type in LGDeviceType],
translation_key=CONF_DEVICE_TYPE,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=valid_entity_ids,
)
),
}
),
)

View File

@@ -0,0 +1,59 @@
"""Constants for the LG IR integration."""
from enum import StrEnum
DOMAIN = "lg_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_DEVICE_TYPE = "device_type"
LG_ADDRESS = 0xFB04
class LGDeviceType(StrEnum):
"""LG device types."""
TV = "tv"
class LGTVCommand:
"""LG TV IR command codes."""
BACK = 0xD728
CHANNEL_DOWN = 0xFE01
CHANNEL_UP = 0xFF00
EXIT = 0xA45B
FAST_FORWARD = 0x718E
GUIDE = 0x56A9
HDMI_1 = 0x31CE
HDMI_2 = 0x33CC
HDMI_3 = 0x16E9
HDMI_4 = 0x25DA
HOME = 0x837C
INFO = 0x55AA
INPUT = 0xF40B
MENU = 0xBC43
MUTE = 0xF609
NAV_DOWN = 0xBE41
NAV_LEFT = 0xF807
NAV_RIGHT = 0xF906
NAV_UP = 0xBF40
NUM_0 = 0xEF10
NUM_1 = 0xEE11
NUM_2 = 0xED12
NUM_3 = 0xEC13
NUM_4 = 0xEB14
NUM_5 = 0xEA15
NUM_6 = 0xE916
NUM_7 = 0xE817
NUM_8 = 0xE718
NUM_9 = 0xE619
OK = 0xBB44
PAUSE = 0x45BA
PLAY = 0x4FB0
POWER = 0xF708
POWER_ON = 0x3BC4
POWER_OFF = 0x3AC5
REWIND = 0x708F
STOP = 0x4EB1
VOLUME_DOWN = 0xFC03
VOLUME_UP = 0xFD02

View File

@@ -0,0 +1,11 @@
{
"domain": "lg_infrared",
"name": "LG Infrared",
"codeowners": [],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/lg_infrared",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze"
}

View File

@@ -0,0 +1,142 @@
"""Media player platform for LG IR integration."""
from __future__ import annotations
from homeassistant.components.infrared import NECInfraredCommand, async_send_command
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import (
CONF_DEVICE_TYPE,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
LG_ADDRESS,
LGDeviceType,
LGTVCommand,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LG IR media player from config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
device_type = entry.data.get(CONF_DEVICE_TYPE, LGDeviceType.TV)
if device_type == LGDeviceType.TV:
async_add_entities([LgIrTvMediaPlayer(entry, infrared_entity_id)])
class LgIrTvMediaPlayer(MediaPlayerEntity):
"""LG IR media player entity."""
_attr_has_entity_name = True
_attr_name = None
_attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
)
def __init__(self, entry: ConfigEntry, infrared_entity_id: str) -> None:
"""Initialize LG IR media player."""
self._entry = entry
self._infrared_entity_id = infrared_entity_id
self._attr_unique_id = f"{entry.entry_id}_media_player"
self._attr_state = MediaPlayerState.ON
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG"
)
async def async_added_to_hass(self) -> None:
"""Subscribe to infrared entity state changes."""
await super().async_added_to_hass()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
new_state = event.data["new_state"]
self._attr_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._infrared_entity_id], _async_ir_state_changed
)
)
# Set initial availability based on current infrared entity state
ir_state = self.hass.states.get(self._infrared_entity_id)
self._attr_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
async def _send_command(self, command_code: int, repeat_count: int = 1) -> None:
"""Send an IR command using the LG protocol."""
command = NECInfraredCommand(
address=LG_ADDRESS, command=command_code, repeat_count=repeat_count
)
await async_send_command(
self.hass, self._infrared_entity_id, command, context=self._context
)
async def async_turn_on(self) -> None:
"""Turn on the TV."""
await self._send_command(LGTVCommand.POWER)
async def async_turn_off(self) -> None:
"""Turn off the TV."""
await self._send_command(LGTVCommand.POWER)
async def async_volume_up(self) -> None:
"""Send volume up command."""
await self._send_command(LGTVCommand.VOLUME_UP)
async def async_volume_down(self) -> None:
"""Send volume down command."""
await self._send_command(LGTVCommand.VOLUME_DOWN)
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self._send_command(LGTVCommand.MUTE)
async def async_media_next_track(self) -> None:
"""Send channel up command."""
await self._send_command(LGTVCommand.CHANNEL_UP)
async def async_media_previous_track(self) -> None:
"""Send channel down command."""
await self._send_command(LGTVCommand.CHANNEL_DOWN)
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_command(LGTVCommand.PLAY)
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_command(LGTVCommand.PAUSE)
async def async_media_stop(self) -> None:
"""Send stop command."""
await self._send_command(LGTVCommand.STOP)

View File

@@ -0,0 +1,127 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands:
status: exempt
comment: |
This is a proof of concept integration, brand assets will be added later.
common-modules:
status: exempt
comment: |
This integration is simple and does not share patterns with others.
config-flow-test-coverage:
status: exempt
comment: |
This is a proof of concept integration, config flow tests will be added later.
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description:
status: exempt
comment: |
This is a proof of concept integration, documentation will be added later.
docs-installation-instructions:
status: exempt
comment: |
This is a proof of concept integration, documentation will be added later.
docs-removal-instructions:
status: exempt
comment: |
This is a proof of concept integration, documentation will be added later.
entity-event-setup:
status: exempt
comment: |
This integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data:
status: exempt
comment: |
This integration does not store runtime data.
test-before-configure: done
test-before-setup:
status: exempt
comment: |
Setup validation is handled by checking emitter existence in remote.py.
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: todo
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
This integration is configured manually via config flow.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category:
status: exempt
comment: |
The remote entity is the primary entity and does not need a category.
entity-device-class:
status: exempt
comment: |
Remote entities do not have a device class.
entity-disabled-by-default:
status: exempt
comment: |
The remote entity is the primary entity and should be enabled by default.
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration only depends on ir_proxy which is part of Home Assistant.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: todo

View File

@@ -0,0 +1,114 @@
{
"config": {
"abort": {
"already_configured": "This LG device has already been configured with this transmitter.",
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"step": {
"user": {
"data": {
"device_type": "Device type",
"infrared_entity_id": "Infrared transmitter"
},
"description": "Select the device type and the infrared transmitter entity to use for controlling your LG device.",
"title": "Set up LG IR Remote"
}
}
},
"entity": {
"button": {
"back": {
"name": "Back"
},
"down": {
"name": "Down"
},
"exit": {
"name": "Exit"
},
"guide": {
"name": "Guide"
},
"hdmi_1": {
"name": "HDMI 1"
},
"hdmi_2": {
"name": "HDMI 2"
},
"hdmi_3": {
"name": "HDMI 3"
},
"hdmi_4": {
"name": "HDMI 4"
},
"home": {
"name": "Home"
},
"info": {
"name": "Info"
},
"input": {
"name": "Input"
},
"left": {
"name": "Left"
},
"menu": {
"name": "Menu"
},
"num_0": {
"name": "0"
},
"num_1": {
"name": "1"
},
"num_2": {
"name": "2"
},
"num_3": {
"name": "3"
},
"num_4": {
"name": "4"
},
"num_5": {
"name": "5"
},
"num_6": {
"name": "6"
},
"num_7": {
"name": "7"
},
"num_8": {
"name": "8"
},
"num_9": {
"name": "9"
},
"ok": {
"name": "OK"
},
"power_off": {
"name": "Power off"
},
"power_on": {
"name": "Power on"
},
"right": {
"name": "Right"
},
"up": {
"name": "Up"
}
}
},
"selector": {
"device_type": {
"options": {
"hifi": "Hi-Fi",
"tv": "TV"
}
}
}
}

View File

@@ -17,7 +17,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> bool:

View File

@@ -0,0 +1,164 @@
"""Number platform for Liebherr integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pyliebherrhomeapi import (
LiebherrConnectionError,
LiebherrTimeoutError,
TemperatureControl,
TemperatureUnit,
)
from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import LiebherrZoneEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class LiebherrNumberEntityDescription(NumberEntityDescription):
"""Describes Liebherr number entity."""
value_fn: Callable[[TemperatureControl], float | None]
min_fn: Callable[[TemperatureControl], float | None]
max_fn: Callable[[TemperatureControl], float | None]
unit_fn: Callable[[TemperatureControl], str]
NUMBER_TYPES: tuple[LiebherrNumberEntityDescription, ...] = (
LiebherrNumberEntityDescription(
key="setpoint_temperature",
translation_key="setpoint_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
native_step=1,
value_fn=lambda control: control.target,
min_fn=lambda control: control.min,
max_fn=lambda control: control.max,
unit_fn=lambda control: (
UnitOfTemperature.FAHRENHEIT
if control.unit == TemperatureUnit.FAHRENHEIT
else UnitOfTemperature.CELSIUS
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr number entities."""
coordinators = entry.runtime_data
async_add_entities(
LiebherrNumber(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
)
for coordinator in coordinators.values()
for temp_control in coordinator.data.get_temperature_controls().values()
for description in NUMBER_TYPES
)
class LiebherrNumber(LiebherrZoneEntity, NumberEntity):
"""Representation of a Liebherr number entity."""
entity_description: LiebherrNumberEntityDescription
def __init__(
self,
coordinator: LiebherrCoordinator,
zone_id: int,
description: LiebherrNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator, zone_id)
self.entity_description = description
self._attr_unique_id = f"{coordinator.device_id}_{description.key}_{zone_id}"
# If device has only one zone, use translation key without zone suffix
temp_controls = coordinator.data.get_temperature_controls()
if len(temp_controls) > 1 and (zone_key := self._get_zone_translation_key()):
self._attr_translation_key = f"{description.translation_key}_{zone_key}"
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
if (temp_control := self.temperature_control) is None:
return None
return self.entity_description.unit_fn(temp_control)
@property
def native_value(self) -> float | None:
"""Return the current value."""
# temperature_control is guaranteed to exist when entity is available
return self.entity_description.value_fn(
self.temperature_control # type: ignore[arg-type]
)
@property
def native_min_value(self) -> float:
"""Return the minimum value."""
if (temp_control := self.temperature_control) is None:
return DEFAULT_MIN_VALUE
if (min_val := self.entity_description.min_fn(temp_control)) is None:
return DEFAULT_MIN_VALUE
return min_val
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
if (temp_control := self.temperature_control) is None:
return DEFAULT_MAX_VALUE
if (max_val := self.entity_description.max_fn(temp_control)) is None:
return DEFAULT_MAX_VALUE
return max_val
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.temperature_control is not None
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
# temperature_control is guaranteed to exist when entity is available
temp_control = self.temperature_control
unit = (
TemperatureUnit.FAHRENHEIT
if temp_control.unit == TemperatureUnit.FAHRENHEIT # type: ignore[union-attr]
else TemperatureUnit.CELSIUS
)
try:
await self.coordinator.client.set_temperature(
device_id=self.coordinator.device_id,
zone_id=self._zone_id,
target=int(value),
unit=unit,
)
except (LiebherrConnectionError, LiebherrTimeoutError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature_failed",
) from err
await self.coordinator.async_request_refresh()

View File

@@ -55,23 +55,16 @@ async def async_setup_entry(
) -> None:
"""Set up Liebherr sensor entities."""
coordinators = entry.runtime_data
entities: list[LiebherrSensor] = []
for coordinator in coordinators.values():
# Get all temperature controls for this device
temp_controls = coordinator.data.get_temperature_controls()
for temp_control in temp_controls.values():
entities.extend(
LiebherrSensor(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
)
for description in SENSOR_TYPES
)
async_add_entities(entities)
async_add_entities(
LiebherrSensor(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
)
for coordinator in coordinators.values()
for temp_control in coordinator.data.get_temperature_controls().values()
for description in SENSOR_TYPES
)
class LiebherrSensor(LiebherrZoneEntity, SensorEntity):
@@ -108,9 +101,9 @@ class LiebherrSensor(LiebherrZoneEntity, SensorEntity):
@property
def native_value(self) -> StateType:
"""Return the current value."""
if (temp_control := self.temperature_control) is None:
return None
return self.entity_description.value_fn(temp_control)
# temperature_control is guaranteed to exist when entity is available
assert self.temperature_control is not None
return self.entity_description.value_fn(self.temperature_control)
@property
def available(self) -> bool:

View File

@@ -33,6 +33,20 @@
}
},
"entity": {
"number": {
"setpoint_temperature": {
"name": "Setpoint"
},
"setpoint_temperature_bottom_zone": {
"name": "Bottom zone setpoint"
},
"setpoint_temperature_middle_zone": {
"name": "Middle zone setpoint"
},
"setpoint_temperature_top_zone": {
"name": "Top zone setpoint"
}
},
"sensor": {
"bottom_zone": {
"name": "Bottom zone"
@@ -44,5 +58,10 @@
"name": "Top zone"
}
}
},
"exceptions": {
"set_temperature_failed": {
"message": "Failed to set temperature"
}
}
}

View File

@@ -82,26 +82,6 @@ ATTR_COLOR_MODE = "color_mode"
# List of color modes supported by the light
ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes"
# These COLOR_MODE_* constants are deprecated as of Home Assistant 2022.5.
# Please use the LightEntityFeature enum instead.
_DEPRECATED_COLOR_MODE_UNKNOWN: Final = DeprecatedConstantEnum(
ColorMode.UNKNOWN, "2026.1"
)
_DEPRECATED_COLOR_MODE_ONOFF: Final = DeprecatedConstantEnum(ColorMode.ONOFF, "2026.1")
_DEPRECATED_COLOR_MODE_BRIGHTNESS: Final = DeprecatedConstantEnum(
ColorMode.BRIGHTNESS, "2026.1"
)
_DEPRECATED_COLOR_MODE_COLOR_TEMP: Final = DeprecatedConstantEnum(
ColorMode.COLOR_TEMP, "2026.1"
)
_DEPRECATED_COLOR_MODE_HS: Final = DeprecatedConstantEnum(ColorMode.HS, "2026.1")
_DEPRECATED_COLOR_MODE_XY: Final = DeprecatedConstantEnum(ColorMode.XY, "2026.1")
_DEPRECATED_COLOR_MODE_RGB: Final = DeprecatedConstantEnum(ColorMode.RGB, "2026.1")
_DEPRECATED_COLOR_MODE_RGBW: Final = DeprecatedConstantEnum(ColorMode.RGBW, "2026.1")
_DEPRECATED_COLOR_MODE_RGBWW: Final = DeprecatedConstantEnum(ColorMode.RGBWW, "2026.1")
_DEPRECATED_COLOR_MODE_WHITE: Final = DeprecatedConstantEnum(ColorMode.WHITE, "2026.1")
# mypy: disallow-any-generics
@@ -192,20 +172,6 @@ ATTR_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin"
ATTR_COLOR_NAME = "color_name"
ATTR_WHITE = "white"
# Deprecated in HA Core 2022.11
_DEPRECATED_ATTR_COLOR_TEMP: Final = DeprecatedConstant(
"color_temp", "kelvin equivalent (ATTR_COLOR_TEMP_KELVIN)", "2026.1"
)
_DEPRECATED_ATTR_KELVIN: Final = DeprecatedConstant(
"kelvin", "ATTR_COLOR_TEMP_KELVIN", "2026.1"
)
_DEPRECATED_ATTR_MIN_MIREDS: Final = DeprecatedConstant(
"min_mireds", "kelvin equivalent (ATTR_MAX_COLOR_TEMP_KELVIN)", "2026.1"
)
_DEPRECATED_ATTR_MAX_MIREDS: Final = DeprecatedConstant(
"max_mireds", "kelvin equivalent (ATTR_MIN_COLOR_TEMP_KELVIN)", "2026.1"
)
# Brightness of the light, 0..255 or percentage
ATTR_BRIGHTNESS = "brightness"
ATTR_BRIGHTNESS_PCT = "brightness_pct"
@@ -250,11 +216,7 @@ LIGHT_TURN_ON_SCHEMA: VolDictType = {
vol.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP,
vol.Exclusive(ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP_PCT,
vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string,
vol.Exclusive(_DEPRECATED_ATTR_COLOR_TEMP.value, COLOR_GROUP): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): cv.positive_int,
vol.Exclusive(_DEPRECATED_ATTR_KELVIN.value, COLOR_GROUP): cv.positive_int,
vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All(
vol.Coerce(tuple),
vol.ExactSequence(
@@ -317,31 +279,6 @@ def preprocess_turn_on_alternatives(
_LOGGER.warning("Got unknown color %s, falling back to white", color_name)
params[ATTR_RGB_COLOR] = (255, 255, 255)
if (mired := params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None)) is not None:
_LOGGER.warning(
"Got `color_temp` argument in `turn_on` service, which is deprecated "
"and will break in Home Assistant 2026.1, please use "
"`color_temp_kelvin` argument"
)
kelvin = color_util.color_temperature_mired_to_kelvin(mired)
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired)
params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin)
if (kelvin := params.pop(_DEPRECATED_ATTR_KELVIN.value, None)) is not None:
_LOGGER.warning(
"Got `kelvin` argument in `turn_on` service, which is deprecated "
"and will break in Home Assistant 2026.1, please use "
"`color_temp_kelvin` argument"
)
mired = color_util.color_temperature_kelvin_to_mired(kelvin)
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired)
params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin)
if (kelvin := params.pop(ATTR_COLOR_TEMP_KELVIN, None)) is not None:
mired = color_util.color_temperature_kelvin_to_mired(kelvin)
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired)
params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin)
brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None)
if brightness_pct is not None:
params[ATTR_BRIGHTNESS] = round(255 * brightness_pct / 100)
@@ -381,7 +318,6 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
if not brightness_supported(supported_color_modes):
params.pop(ATTR_BRIGHTNESS, None)
if ColorMode.COLOR_TEMP not in supported_color_modes:
params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None)
params.pop(ATTR_COLOR_TEMP_KELVIN, None)
if ColorMode.HS not in supported_color_modes:
params.pop(ATTR_HS_COLOR, None)
@@ -466,7 +402,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
and ColorMode.COLOR_TEMP not in supported_color_modes
and ColorMode.RGBWW in supported_color_modes
):
params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value)
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
@@ -476,7 +411,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
light.max_color_temp_kelvin,
)
elif ColorMode.COLOR_TEMP not in legacy_supported_color_modes:
params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value)
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
if color_supported(legacy_supported_color_modes):
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(
@@ -523,11 +457,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
color_util.color_temperature_kelvin_to_mired(
params[ATTR_COLOR_TEMP_KELVIN]
)
)
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
rgb_color = params.pop(ATTR_RGB_COLOR)
assert rgb_color is not None
@@ -550,11 +479,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
color_util.color_temperature_kelvin_to_mired(
params[ATTR_COLOR_TEMP_KELVIN]
)
)
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
xy_color = params.pop(ATTR_XY_COLOR)
if ColorMode.HS in supported_color_modes:
@@ -573,11 +497,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
color_util.color_temperature_kelvin_to_mired(
params[ATTR_COLOR_TEMP_KELVIN]
)
)
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
rgbw_color = params.pop(ATTR_RGBW_COLOR)
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
@@ -596,11 +515,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
color_util.color_temperature_kelvin_to_mired(
params[ATTR_COLOR_TEMP_KELVIN]
)
)
elif (
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
):
@@ -624,11 +538,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
color_util.color_temperature_kelvin_to_mired(
params[ATTR_COLOR_TEMP_KELVIN]
)
)
# If white is set to True, set it to the light's brightness
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
@@ -835,7 +744,7 @@ class Profiles:
color_attributes = (
ATTR_COLOR_NAME,
_DEPRECATED_ATTR_COLOR_TEMP.value,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
@@ -866,9 +775,9 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"rgb_color",
"rgbw_color",
"rgbww_color",
"color_temp",
"min_mireds",
"max_mireds",
"color_temp_kelvin",
"min_color_temp_kelvin",
"max_color_temp_kelvin",
"effect_list",
"effect",
"supported_color_modes",
@@ -883,13 +792,10 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
{
ATTR_SUPPORTED_COLOR_MODES,
ATTR_EFFECT_LIST,
_DEPRECATED_ATTR_MIN_MIREDS.value,
_DEPRECATED_ATTR_MAX_MIREDS.value,
ATTR_MIN_COLOR_TEMP_KELVIN,
ATTR_MAX_COLOR_TEMP_KELVIN,
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
_DEPRECATED_ATTR_COLOR_TEMP.value,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_HS_COLOR,
@@ -907,11 +813,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_effect_list: list[str] | None = None
_attr_effect: str | None = None
_attr_hs_color: tuple[float, float] | None = None
# We cannot set defaults without causing breaking changes until mireds
# are fully removed. Until then, developers can explicitly
# use DEFAULT_MIN_KELVIN and DEFAULT_MAX_KELVIN
_attr_max_color_temp_kelvin: int | None = None
_attr_min_color_temp_kelvin: int | None = None
_attr_max_color_temp_kelvin: int = DEFAULT_MAX_KELVIN
_attr_min_color_temp_kelvin: int = DEFAULT_MIN_KELVIN
_attr_rgb_color: tuple[int, int, int] | None = None
_attr_rgbw_color: tuple[int, int, int, int] | None = None
_attr_rgbww_color: tuple[int, int, int, int, int] | None = None
@@ -919,11 +822,6 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_supported_features: LightEntityFeature = LightEntityFeature(0)
_attr_xy_color: tuple[float, float] | None = None
# Deprecated, see https://github.com/home-assistant/core/pull/79591
_attr_color_temp: Final[int | None] = None
_attr_max_mireds: Final[int] = 500 # = 2000 K
_attr_min_mireds: Final[int] = 153 # = 6535.94 K (~ 6500 K)
__color_mode_reported = False
@cached_property
@@ -999,91 +897,49 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the rgbww color value [int, int, int, int, int]."""
return self._attr_rgbww_color
@final
@cached_property
def color_temp(self) -> int | None:
"""Return the CT color value in mireds.
Deprecated, see https://github.com/home-assistant/core/pull/79591
"""
return self._attr_color_temp
@property
def color_temp_kelvin(self) -> int | None:
"""Return the CT color value in Kelvin."""
if self._attr_color_temp_kelvin is None and (color_temp := self.color_temp):
report_usage(
"is using mireds for current light color temperature, when "
"it should be adjusted to use the kelvin attribute "
"`_attr_color_temp_kelvin` or override the kelvin property "
"`color_temp_kelvin` (see "
"https://github.com/home-assistant/core/pull/79591)",
breaks_in_ha_version="2026.1",
core_behavior=ReportBehavior.LOG,
integration_domain=self.platform.platform_name
if self.platform
else None,
exclude_integrations={DOMAIN},
)
return color_util.color_temperature_mired_to_kelvin(color_temp)
return self._attr_color_temp_kelvin
@final
@cached_property
def min_mireds(self) -> int:
"""Return the coldest color_temp that this light supports.
Deprecated, see https://github.com/home-assistant/core/pull/79591
"""
return self._attr_min_mireds
@final
@cached_property
def max_mireds(self) -> int:
"""Return the warmest color_temp that this light supports.
Deprecated, see https://github.com/home-assistant/core/pull/79591
"""
return self._attr_max_mireds
@property
def min_color_temp_kelvin(self) -> int:
"""Return the warmest color_temp_kelvin that this light supports."""
if self._attr_min_color_temp_kelvin is None:
report_usage(
"is using mireds for warmest light color temperature, when "
"it should be adjusted to use the kelvin attribute "
"`_attr_min_color_temp_kelvin` or override the kelvin property "
"`min_color_temp_kelvin`, possibly with default DEFAULT_MIN_KELVIN "
"(see https://github.com/home-assistant/core/pull/79591)",
breaks_in_ha_version="2026.1",
# For historical reason when both Mired and Kelvin were supported,
# integrations may have set this explicitly to None.
# Fallback to DEFAULT to ensure compatibility.
report_usage( # type: ignore[unreachable]
"is explicitly setting `_attr_min_color_temp_kelvin` to `None`, when "
"it should be setting a valid integer, possibly DEFAULT_MIN_KELVIN ",
breaks_in_ha_version="2026.8",
core_behavior=ReportBehavior.LOG,
integration_domain=self.platform.platform_name
if self.platform
else None,
exclude_integrations={DOMAIN},
)
return color_util.color_temperature_mired_to_kelvin(self.max_mireds)
return DEFAULT_MIN_KELVIN
return self._attr_min_color_temp_kelvin
@property
def max_color_temp_kelvin(self) -> int:
"""Return the coldest color_temp_kelvin that this light supports."""
if self._attr_max_color_temp_kelvin is None:
report_usage(
"is using mireds for coldest light color temperature, when "
"it should be adjusted to use the kelvin attribute "
"`_attr_max_color_temp_kelvin` or override the kelvin property "
"`max_color_temp_kelvin`, possibly with default DEFAULT_MAX_KELVIN "
"(see https://github.com/home-assistant/core/pull/79591)",
breaks_in_ha_version="2026.1",
# For historical reason when both Mired and Kelvin were supported,
# integrations may have set this explicitly to None.
# Fallback to DEFAULT to ensure compatibility.
report_usage( # type: ignore[unreachable]
"is explicitly setting `_attr_max_color_temp_kelvin` to `None`, when "
"it should be setting a valid integer, possibly DEFAULT_MAX_KELVIN ",
breaks_in_ha_version="2026.8",
core_behavior=ReportBehavior.LOG,
integration_domain=self.platform.platform_name
if self.platform
else None,
exclude_integrations={DOMAIN},
)
return color_util.color_temperature_mired_to_kelvin(self.min_mireds)
return DEFAULT_MAX_KELVIN
return self._attr_max_color_temp_kelvin
@cached_property
@@ -1108,18 +964,6 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
max_color_temp_kelvin = self.max_color_temp_kelvin
data[ATTR_MIN_COLOR_TEMP_KELVIN] = min_color_temp_kelvin
data[ATTR_MAX_COLOR_TEMP_KELVIN] = max_color_temp_kelvin
if not max_color_temp_kelvin:
data[_DEPRECATED_ATTR_MIN_MIREDS.value] = None
else:
data[_DEPRECATED_ATTR_MIN_MIREDS.value] = (
color_util.color_temperature_kelvin_to_mired(max_color_temp_kelvin)
)
if not min_color_temp_kelvin:
data[_DEPRECATED_ATTR_MAX_MIREDS.value] = None
else:
data[_DEPRECATED_ATTR_MAX_MIREDS.value] = (
color_util.color_temperature_kelvin_to_mired(min_color_temp_kelvin)
)
if LightEntityFeature.EFFECT in supported_features:
data[ATTR_EFFECT_LIST] = self.effect_list
@@ -1296,32 +1140,16 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if color_temp_supported(supported_color_modes):
if color_mode == ColorMode.COLOR_TEMP:
color_temp_kelvin = self.color_temp_kelvin
data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin
if color_temp_kelvin:
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)
)
else:
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin
else:
data[ATTR_COLOR_TEMP_KELVIN] = None
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
elif supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value:
# Backwards compatibility
# Warning is printed by supported_features_compat, remove in 2025.1
if _is_on:
color_temp_kelvin = self.color_temp_kelvin
data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin
if color_temp_kelvin:
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)
)
else:
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin
else:
data[ATTR_COLOR_TEMP_KELVIN] = None
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
if color_supported(legacy_supported_color_modes) or color_temp_supported(
legacy_supported_color_modes

View File

@@ -15,10 +15,8 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import Context, HomeAssistant, State
from homeassistant.util import color as color_util
from . import (
_DEPRECATED_ATTR_COLOR_TEMP,
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
ATTR_COLOR_TEMP_KELVIN,
@@ -41,7 +39,6 @@ ATTR_GROUP = [ATTR_BRIGHTNESS, ATTR_EFFECT]
COLOR_GROUP = [
ATTR_HS_COLOR,
_DEPRECATED_ATTR_COLOR_TEMP.value,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
@@ -127,30 +124,13 @@ async def _async_reproduce_state(
color_mode = state.attributes[ATTR_COLOR_MODE]
if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode):
if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None:
if (
color_mode != ColorMode.COLOR_TEMP
or (
mireds := state.attributes.get(
_DEPRECATED_ATTR_COLOR_TEMP.value
)
)
is None
):
_LOGGER.warning(
"Color mode %s specified but attribute %s missing for: %s",
color_mode,
cm_attr.state_attr,
state.entity_id,
)
return
_LOGGER.warning(
"Color mode %s specified but attribute %s missing for: %s, "
"using color_temp (mireds) as fallback",
"Color mode %s specified but attribute %s missing for: %s",
color_mode,
cm_attr.state_attr,
state.entity_id,
)
cm_attr_state = color_util.color_temperature_mired_to_kelvin(mireds)
return
service_data[cm_attr.parameter] = cm_attr_state
else:
# Fall back to Choosing the first color that is specified

View File

@@ -255,13 +255,6 @@ turn_on:
example: "[0.52, 0.43]"
selector:
object:
color_temp: &color_temp
filter: *color_temp_support
selector:
color_temp:
unit: "mired"
min: 153
max: 500
brightness: &brightness
filter: *brightness_support
selector:
@@ -327,7 +320,6 @@ toggle:
color_name: *color_name
hs_color: *hs_color
xy_color: *xy_color
color_temp: *color_temp
brightness: *brightness
white: *white
profile: *profile

View File

@@ -12,10 +12,8 @@
"field_brightness_step_pct_name": "Brightness step",
"field_color_name_description": "A human-readable color name.",
"field_color_name_name": "Color name",
"field_color_temp_description": "Color temperature in mireds.",
"field_color_temp_kelvin_description": "Color temperature in Kelvin.",
"field_color_temp_kelvin_name": "Color temperature",
"field_color_temp_name": "Color temperature",
"field_effect_description": "Light effect.",
"field_effect_name": "Effect",
"field_flash_description": "Tell light to flash, can be either value short or long.",
@@ -112,9 +110,6 @@
"xy": "XY"
}
},
"color_temp": {
"name": "Color temperature (mireds)"
},
"color_temp_kelvin": {
"name": "Color temperature (Kelvin)"
},
@@ -130,15 +125,9 @@
"max_color_temp_kelvin": {
"name": "Maximum color temperature (Kelvin)"
},
"max_mireds": {
"name": "Maximum color temperature (mireds)"
},
"min_color_temp_kelvin": {
"name": "Minimum color temperature (Kelvin)"
},
"min_mireds": {
"name": "Minimum color temperature (mireds)"
},
"supported_color_modes": {
"name": "Available color modes",
"state": {
@@ -366,10 +355,6 @@
"description": "[%key:component::light::common::field_color_name_description%]",
"name": "[%key:component::light::common::field_color_name_name%]"
},
"color_temp": {
"description": "[%key:component::light::common::field_color_temp_description%]",
"name": "[%key:component::light::common::field_color_temp_name%]"
},
"color_temp_kelvin": {
"description": "[%key:component::light::common::field_color_temp_kelvin_description%]",
"name": "[%key:component::light::common::field_color_temp_kelvin_name%]"
@@ -464,10 +449,6 @@
"description": "[%key:component::light::common::field_color_name_description%]",
"name": "[%key:component::light::common::field_color_name_name%]"
},
"color_temp": {
"description": "[%key:component::light::common::field_color_temp_description%]",
"name": "[%key:component::light::common::field_color_temp_name%]"
},
"color_temp_kelvin": {
"description": "[%key:component::light::common::field_color_temp_kelvin_description%]",
"name": "[%key:component::light::common::field_color_temp_kelvin_name%]"

Some files were not shown because too many files have changed in this diff Show More