Compare commits

...

53 Commits

Author SHA1 Message Date
Joost Lekkerkerker
b955cf6f3d Merge branch 'dev' into strings/make-trigger_behavior-selector-common 2026-01-29 21:50:47 +01:00
Thomas55555
b1be3fe0da Introduce common string for data description of verify_ssl (#160703) 2026-01-29 20:27:37 +00:00
Brett Adams
97a7ab011b Add quality scale to Teslemetry (#159589) 2026-01-29 20:23:09 +00:00
SamareshSingh
694a3050b9 Add device_class inheritance to min_max sensor (#157602)
Signed-off-by: Samaresh Sahoo <ssamaresh01@gmail.com>
Co-authored-by: Samaresh Kumar Singh <ssam18@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-29 21:15:41 +01:00
Erwin Douna
8164e65188 Fix small typo in Portainer strings (#161889) 2026-01-29 20:58:07 +01:00
Marc Mueller
9af0d1eed4 Update fritzconnection to 1.15.1 (#161887) 2026-01-29 20:57:52 +01:00
Jan Bouwhuis
72e6ca55ba Fix use of ambiguous units for reactive power and energy (#161810) 2026-01-29 20:34:09 +01:00
Jeremiah Paige
0fb62a7e97 Add wsdot code-owner (#160807) 2026-01-29 19:52:41 +01:00
Erwin Douna
930eb70a8b Add prune images service to Portainer (#161009)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-29 19:39:17 +01:00
Norbert Rittel
462104fa68 Clarify action descriptions for input numbers (#161847) 2026-01-29 18:43:26 +01:00
mettolen
d0c77d8a7e Delete unused Liebherr snapshot (#161879) 2026-01-29 17:38:56 +01:00
Björn Dalfors
606780b20f Bump nibe to 2.22.0 (#161873) 2026-01-29 17:06:38 +01:00
Tucker Kern
8f465cf2ca Remove deprecated Snapcast group entities and custom grouping services (#160945) 2026-01-29 16:44:50 +01:00
epenet
4e29476dd9 Cleanup deprecated YAML import from datadog (#161870) 2026-01-29 15:33:14 +01:00
epenet
b4328083be Fix incorrect entity_description class in radarr (#161856) 2026-01-29 15:09:06 +01:00
epenet
72ba59f559 Remove outdated device registry cleanup in utility_meter (#161868) 2026-01-29 15:01:41 +01:00
epenet
826168b601 Remove outdated device registry cleanup in integration (#161863) 2026-01-29 15:01:22 +01:00
Sebastiaan Speck
66f181992c Bump renault-api to 0.5.3 (#161857) 2026-01-29 14:02:22 +01:00
epenet
336ef4c37b Remove outdated device registry cleanup in derivative (#161858) 2026-01-29 13:55:49 +01:00
mettolen
72e7bf7f9c Add new Liebherr integration (#161197)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-29 13:49:09 +01:00
Gage Benne
acbdbc9be7 Bump pydexcom to 0.5.1 (#161549) 2026-01-29 12:47:05 +01:00
Steve Easley
3551382f8d Add additional JVC Projector entities (#161134) 2026-01-29 12:45:19 +01:00
Mattia Monga
95014d7e6d Make viaggiatreno work by fixing some critical bugs (#160093)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-29 12:41:47 +01:00
Retha Runolfsson
dfe1990484 Add service for switchbot keypad vision (#160659)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-29 12:23:38 +01:00
epenet
15ff5d0f74 Modernize tasmota light tests (#161830) 2026-01-29 12:05:03 +01:00
epenet
1407f61a9c Modernize abode light tests (#161829) 2026-01-29 12:01:32 +01:00
epenet
6107b794d6 Modernize hue light tests (#161828) 2026-01-29 12:01:07 +01:00
epenet
7ab8ceab7e Modernize zha light tests (#161826) 2026-01-29 12:00:52 +01:00
epenet
a4db6a9ebc Modernize template light tests (#161833) 2026-01-29 11:59:55 +01:00
Colin
12a2650b6b Add quality scale to openesve (#161651) 2026-01-29 11:55:54 +01:00
Markus Jacobsen
23da7ecedd Bump mozart_api to 5.3.1.108.2 (#161846) 2026-01-29 11:54:11 +01:00
wollew
8d9e7b0b26 Do not use base class of pyvlx in velux light platform (#161837) 2026-01-29 11:52:22 +01:00
epenet
9664047345 Modernize homekit_controller light tests (#161844) 2026-01-29 11:51:59 +01:00
epenet
804fbf9cef Modernize govee_light_local light tests (#161845) 2026-01-29 11:51:22 +01:00
epenet
e10fe074c9 Cleanup deprecated color_temp support in lifx (#161848) 2026-01-29 11:50:53 +01:00
Norbert Rittel
7b0e21da74 Fix action descriptions of alarm_control_panel (#161852) 2026-01-29 11:50:22 +01:00
epenet
29e142cf1e Modernize matter light tests (#161850) 2026-01-29 11:49:51 +01:00
epenet
6b765ebabb Modernize tradfri light tests (#161849) 2026-01-29 11:49:18 +01:00
epenet
899aa62697 Modernize knx light tests (#161851) 2026-01-29 11:42:18 +01:00
dependabot[bot]
a11efba405 Bump docker/login-action from 3.6.0 to 3.7.0 (#161825) 2026-01-29 07:43:41 +01:00
Manu
78280dfc5a Fix string in Namecheap DynamicDNS integration (#161821) 2026-01-29 03:10:09 +01:00
Glenn de Haan
4220bab08a Improve quality scale to gold HDFury integration (#161800) 2026-01-29 00:25:00 +01:00
Marc Mueller
f7dcf8de15 Switch back to mypy 1.19.1 (#161817) 2026-01-29 00:12:46 +01:00
Aaron Godfrey
7e32b50fee Update todoist-api-python to 3.1.0 (#161811)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-29 00:00:53 +01:00
Robert Resch
c875b75272 Use Python 3.14 as default one (#161426)
Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-01-28 23:48:27 +01:00
John Hillery
7368b9ca1d Add sensor for energy remaining to tessie integration (#161796) 2026-01-28 23:41:29 +01:00
Michael Jones
493e8c1a22 Append ID to flood monitoring station name in EAFM (#161794) 2026-01-28 22:18:35 +00:00
Michael Hansen
1b16b24550 Bump intents to 2026.1.28 (#161813) 2026-01-28 23:14:36 +01:00
Franck Nijhof
7637300632 Bump version to 2026.3.0dev0 (#161809) 2026-01-28 23:12:34 +01:00
victorigualada
bdbce57217 Use OpenAI schema dataclasses for cloud stream responses (#161663) 2026-01-28 20:59:03 +01:00
mib1185
a7cc4e1282 adjust switch platform 2025-12-12 20:36:17 +00:00
mib1185
c6aed73d2b Merge branch 'dev' into strings/make-trigger_behavior-selector-common 2025-12-12 20:35:33 +00:00
mib1185
c019331de1 make trigger_behavior selector translations common 2025-12-11 17:36:54 +00:00
185 changed files with 4106 additions and 1995 deletions

View File

@@ -10,12 +10,12 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.13"
DEFAULT_PYTHON: "3.14.2"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2025.12.0"
BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]'
jobs:
@@ -184,7 +184,7 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -287,7 +287,7 @@ jobs:
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -358,13 +358,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -522,7 +522,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

View File

@@ -40,9 +40,9 @@ env:
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.2"
DEFAULT_PYTHON: "3.13.11"
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
HA_SHORT_VERSION: "2026.3"
DEFAULT_PYTHON: "3.14.2"
ALL_PYTHON_VERSIONS: "['3.14.2']"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support

View File

@@ -10,7 +10,7 @@ on:
- "**strings.json"
env:
DEFAULT_PYTHON: "3.13"
DEFAULT_PYTHON: "3.14.2"
jobs:
upload:

View File

@@ -17,7 +17,7 @@ on:
- "script/gen_requirements_all.py"
env:
DEFAULT_PYTHON: "3.13"
DEFAULT_PYTHON: "3.14.2"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}

View File

@@ -1 +1 @@
3.13
3.14

4
CODEOWNERS generated
View File

@@ -921,6 +921,8 @@ build.json @home-assistant/supervisor
/tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob
/homeassistant/components/liebherr/ @mettolen
/tests/components/liebherr/ @mettolen
/homeassistant/components/lifx/ @Djelibeybi
/tests/components/lifx/ @Djelibeybi
/homeassistant/components/light/ @home-assistant/core
@@ -1878,6 +1880,8 @@ build.json @home-assistant/supervisor
/tests/components/worldclock/ @fabaff
/homeassistant/components/ws66i/ @ssaenger
/tests/components/ws66i/ @ssaenger
/homeassistant/components/wsdot/ @ucodery
/tests/components/wsdot/ @ucodery
/homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @hunterjm @tr4nt0r

View File

@@ -158,15 +158,15 @@
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},
"services": {
"alarm_arm_away": {
"description": "Arms the alarm in the away mode.",
"description": "Arms an alarm in the away mode.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
@@ -176,7 +176,7 @@
"name": "Arm away"
},
"alarm_arm_custom_bypass": {
"description": "Arms the alarm while allowing to bypass a custom area.",
"description": "Arms an alarm while allowing to bypass a custom area.",
"fields": {
"code": {
"description": "Code to arm the alarm.",
@@ -186,7 +186,7 @@
"name": "Arm with custom bypass"
},
"alarm_arm_home": {
"description": "Arms the alarm in the home mode.",
"description": "Arms an alarm in the home mode.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
@@ -196,7 +196,7 @@
"name": "Arm home"
},
"alarm_arm_night": {
"description": "Arms the alarm in the night mode.",
"description": "Arms an alarm in the night mode.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
@@ -206,7 +206,7 @@
"name": "Arm night"
},
"alarm_arm_vacation": {
"description": "Arms the alarm in the vacation mode.",
"description": "Arms an alarm in the vacation mode.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",
@@ -216,7 +216,7 @@
"name": "Arm vacation"
},
"alarm_disarm": {
"description": "Disarms the alarm.",
"description": "Disarms an alarm.",
"fields": {
"code": {
"description": "Code to disarm the alarm.",
@@ -226,7 +226,7 @@
"name": "Disarm"
},
"alarm_trigger": {
"description": "Triggers the alarm manually.",
"description": "Triggers an alarm manually.",
"fields": {
"code": {
"description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]",

View File

@@ -73,9 +73,9 @@
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["mozart-api==5.3.1.108.0"],
"requirements": ["mozart-api==5.3.1.108.2"],
"zeroconf": ["_bangolufsen._tcp.local."]
}

View File

@@ -8,6 +8,7 @@ from datetime import timedelta
import json
import logging
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from aiohttp import ClientConnectorError
from mozart_api import __version__ as MOZART_API_VERSION
@@ -735,7 +736,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
await self._client.set_active_source(source_id=key)
else:
# Video
await self._client.post_remote_trigger(id=key)
await self._client.post_remote_trigger(id=UUID(key))
async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Select a sound mode."""
@@ -894,7 +895,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
translation_key="play_media_error",
translation_placeholders={
"media_type": media_type,
"error_message": json.loads(error.body)["message"],
"error_message": json.loads(cast(str, error.body))["message"],
},
) from error

View File

@@ -324,9 +324,9 @@
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},

View File

@@ -260,9 +260,9 @@
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
},
"trigger_threshold_type": {

View File

@@ -12,14 +12,25 @@ from hass_nabucasa import Cloud, NabuCasaBaseError
from hass_nabucasa.llm import (
LLMAuthenticationError,
LLMRateLimitError,
LLMResponseCompletedEvent,
LLMResponseError,
LLMResponseErrorEvent,
LLMResponseFailedEvent,
LLMResponseFunctionCallArgumentsDeltaEvent,
LLMResponseFunctionCallArgumentsDoneEvent,
LLMResponseFunctionCallOutputItem,
LLMResponseImageOutputItem,
LLMResponseIncompleteEvent,
LLMResponseMessageOutputItem,
LLMResponseOutputItemAddedEvent,
LLMResponseOutputItemDoneEvent,
LLMResponseOutputTextDeltaEvent,
LLMResponseReasoningOutputItem,
LLMResponseReasoningSummaryTextDeltaEvent,
LLMResponseWebSearchCallOutputItem,
LLMResponseWebSearchCallSearchingEvent,
LLMServiceError,
)
from litellm import (
ResponseFunctionToolCall,
ResponseInputParam,
ResponsesAPIStreamEvents,
)
from openai.types.responses import (
FunctionToolParam,
ResponseInputItemParam,
@@ -60,9 +71,9 @@ class ResponseItemType(str, Enum):
def _convert_content_to_param(
chat_content: Iterable[conversation.Content],
) -> ResponseInputParam:
) -> list[ResponseInputItemParam]:
"""Convert any native chat message for this agent to the native format."""
messages: ResponseInputParam = []
messages: list[ResponseInputItemParam] = []
reasoning_summary: list[str] = []
web_search_calls: dict[str, dict[str, Any]] = {}
@@ -238,7 +249,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
"""Transform stream result into HA format."""
last_summary_index = None
last_role: Literal["assistant", "tool_result"] | None = None
current_tool_call: ResponseFunctionToolCall | None = None
current_tool_call: LLMResponseFunctionCallOutputItem | None = None
# Non-reasoning models don't follow our request to remove citations, so we remove
# them manually here. They always follow the same pattern: the citation is always
@@ -248,19 +259,10 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
citation_regexp = re.compile(r"\(\[([^\]]+)\]\((https?:\/\/[^\)]+)\)")
async for event in stream:
event_type = getattr(event, "type", None)
event_item = getattr(event, "item", None)
event_item_type = getattr(event_item, "type", None) if event_item else None
_LOGGER.debug("Event[%s]", getattr(event, "type", None))
_LOGGER.debug(
"Event[%s] | item: %s",
event_type,
event_item_type,
)
if event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED:
# Detect function_call even when it's a BaseLiteLLMOpenAIResponseObject
if event_item_type == ResponseItemType.FUNCTION_CALL:
if isinstance(event, LLMResponseOutputItemAddedEvent):
if isinstance(event.item, LLMResponseFunctionCallOutputItem):
# OpenAI has tool calls as individual events
# while HA puts tool calls inside the assistant message.
# We turn them into individual assistant content for HA
@@ -268,11 +270,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = None
current_tool_call = cast(ResponseFunctionToolCall, event.item)
current_tool_call = event.item
elif (
event_item_type == ResponseItemType.MESSAGE
isinstance(event.item, LLMResponseMessageOutputItem)
or (
event_item_type == ResponseItemType.REASONING
isinstance(event.item, LLMResponseReasoningOutputItem)
and last_summary_index is not None
) # Subsequent ResponseReasoningItem
or last_role != "assistant"
@@ -281,14 +283,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
last_role = "assistant"
last_summary_index = None
elif event_type == ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE:
if event_item_type == ResponseItemType.REASONING:
encrypted_content = getattr(event.item, "encrypted_content", None)
summary = getattr(event.item, "summary", []) or []
elif isinstance(event, LLMResponseOutputItemDoneEvent):
if isinstance(event.item, LLMResponseReasoningOutputItem):
encrypted_content = event.item.encrypted_content
summary = event.item.summary
yield {
"native": ResponseReasoningItem(
type="reasoning",
"native": LLMResponseReasoningOutputItem(
type=event.item.type,
id=event.item.id,
summary=[],
encrypted_content=encrypted_content,
@@ -296,14 +298,8 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
}
last_summary_index = len(summary) - 1 if summary else None
elif event_item_type == ResponseItemType.WEB_SEARCH_CALL:
action = getattr(event.item, "action", None)
if isinstance(action, dict):
action_dict = action
elif action is not None:
action_dict = action.to_dict()
else:
action_dict = {}
elif isinstance(event.item, LLMResponseWebSearchCallOutputItem):
action_dict = event.item.action
yield {
"tool_calls": [
llm.ToolInput(
@@ -321,11 +317,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
"tool_result": {"status": event.item.status},
}
last_role = "tool_result"
elif event_item_type == ResponseItemType.IMAGE:
yield {"native": event.item}
elif isinstance(event.item, LLMResponseImageOutputItem):
yield {"native": event.item.raw}
last_summary_index = -1 # Trigger new assistant message on next turn
elif event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA:
elif isinstance(event, LLMResponseOutputTextDeltaEvent):
data = event.delta
if remove_parentheses:
data = data.removeprefix(")")
@@ -344,7 +340,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
if data:
yield {"content": data}
elif event_type == ResponsesAPIStreamEvents.REASONING_SUMMARY_TEXT_DELTA:
elif isinstance(event, LLMResponseReasoningSummaryTextDeltaEvent):
# OpenAI can output several reasoning summaries
# in a single ResponseReasoningItem. We split them as separate
# AssistantContent messages. Only last of them will have
@@ -358,14 +354,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
last_summary_index = event.summary_index
yield {"thinking_content": event.delta}
elif event_type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA:
elif isinstance(event, LLMResponseFunctionCallArgumentsDeltaEvent):
if current_tool_call is not None:
current_tool_call.arguments += event.delta
elif event_type == ResponsesAPIStreamEvents.WEB_SEARCH_CALL_SEARCHING:
elif isinstance(event, LLMResponseWebSearchCallSearchingEvent):
yield {"role": "assistant"}
elif event_type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DONE:
elif isinstance(event, LLMResponseFunctionCallArgumentsDoneEvent):
if current_tool_call is not None:
current_tool_call.status = "completed"
@@ -385,35 +381,36 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
]
}
elif event_type == ResponsesAPIStreamEvents.RESPONSE_COMPLETED:
if event.response.usage is not None:
elif isinstance(event, LLMResponseCompletedEvent):
response = event.response
if response and "usage" in response:
usage = response["usage"]
chat_log.async_trace(
{
"stats": {
"input_tokens": event.response.usage.input_tokens,
"output_tokens": event.response.usage.output_tokens,
"input_tokens": usage.get("input_tokens"),
"output_tokens": usage.get("output_tokens"),
}
}
)
elif event_type == ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE:
if event.response.usage is not None:
elif isinstance(event, LLMResponseIncompleteEvent):
response = event.response
if response and "usage" in response:
usage = response["usage"]
chat_log.async_trace(
{
"stats": {
"input_tokens": event.response.usage.input_tokens,
"output_tokens": event.response.usage.output_tokens,
"input_tokens": usage.get("input_tokens"),
"output_tokens": usage.get("output_tokens"),
}
}
)
if (
event.response.incomplete_details
and event.response.incomplete_details.reason
):
reason: str = event.response.incomplete_details.reason
else:
reason = "unknown reason"
incomplete_details = response.get("incomplete_details")
reason = "unknown reason"
if incomplete_details is not None and incomplete_details.get("reason"):
reason = incomplete_details["reason"]
if reason == "max_output_tokens":
reason = "max output tokens reached"
@@ -422,22 +419,24 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
elif event_type == ResponsesAPIStreamEvents.RESPONSE_FAILED:
if event.response.usage is not None:
elif isinstance(event, LLMResponseFailedEvent):
response = event.response
if response and "usage" in response:
usage = response["usage"]
chat_log.async_trace(
{
"stats": {
"input_tokens": event.response.usage.input_tokens,
"output_tokens": event.response.usage.output_tokens,
"input_tokens": usage.get("input_tokens"),
"output_tokens": usage.get("output_tokens"),
}
}
)
reason = "unknown reason"
if event.response.error is not None:
reason = event.response.error.message
if isinstance(error := response.get("error"), dict):
reason = error.get("message") or reason
raise HomeAssistantError(f"OpenAI response failed: {reason}")
elif event_type == ResponsesAPIStreamEvents.ERROR:
elif isinstance(event, LLMResponseErrorEvent):
raise HomeAssistantError(f"OpenAI response error: {event.message}")
@@ -452,7 +451,7 @@ class BaseCloudLLMEntity(Entity):
async def _prepare_chat_for_generation(
self,
chat_log: conversation.ChatLog,
messages: ResponseInputParam,
messages: list[ResponseInputItemParam],
response_format: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Prepare kwargs for Cloud LLM from the chat log."""

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.11.0"],
"requirements": ["hass-nabucasa==1.12.0", "openai==2.15.0"],
"single_config_entry": true
}

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.6"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
}

View File

@@ -3,9 +3,8 @@
import logging
from datadog import DogStatsd, initialize
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PORT,
@@ -16,53 +15,15 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
from . import config_flow as config_flow
from .const import (
CONF_RATE,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_PREFIX,
DEFAULT_RATE,
DOMAIN,
)
from .const import CONF_RATE, DOMAIN
_LOGGER = logging.getLogger(__name__)
type DatadogConfigEntry = ConfigEntry[DogStatsd]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string,
vol.Optional(CONF_RATE, default=DEFAULT_RATE): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Datadog integration from YAML, initiating config flow import."""
if DOMAIN not in config:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config[DOMAIN],
)
)
return True
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool:

View File

@@ -12,8 +12,7 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.core import HomeAssistant, callback
from .const import (
CONF_RATE,
@@ -71,22 +70,6 @@ class DatadogConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from configuration.yaml."""
# Check for duplicates
self._async_abort_entries_match(
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
)
result = await self.async_step_user(user_input)
if errors := result.get("errors"):
await deprecate_yaml_issue(self.hass, False)
return self.async_abort(reason=errors["base"])
await deprecate_yaml_issue(self.hass, True)
return result
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
@@ -163,41 +146,3 @@ async def validate_datadog_connection(
return False
else:
return True
async def deprecate_yaml_issue(
hass: HomeAssistant,
import_success: bool,
) -> None:
"""Create an issue to deprecate YAML config."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.2.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Datadog",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_connection_error",
breaks_in_ha_version="2026.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_connection_error",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Datadog",
"url": f"/config/integrations/dashboard/add?domain={DOMAIN}",
},
)

View File

@@ -25,12 +25,6 @@
}
}
},
"issues": {
"deprecated_yaml_import_connection_error": {
"description": "There was an error connecting to the Datadog Agent when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the {domain} configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "{domain} YAML configuration import failed"
}
},
"options": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",

View File

@@ -7,10 +7,7 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SOURCE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.device import async_entity_id_to_device_id
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
async_remove_helper_config_entry_from_source_device,
@@ -22,11 +19,6 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Derivative from a config entry."""
# This can be removed in HA Core 2026.2
async_remove_stale_devices_links_keep_entity_device(
hass, entry.entry_id, entry.options[CONF_SOURCE]
)
def set_source_entity_id_or_uuid(source_entity_id: str) -> None:
hass.config_entries.async_update_entry(
entry,

View File

@@ -1,6 +1,7 @@
"""The Dexcom integration."""
from pydexcom import AccountError, Dexcom, SessionError
from pydexcom import Dexcom, Region
from pydexcom.errors import AccountError, SessionError
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
@@ -14,10 +15,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: DexcomConfigEntry) -> bo
"""Set up Dexcom from a config entry."""
try:
dexcom = await hass.async_add_executor_job(
Dexcom,
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
entry.data[CONF_SERVER] == SERVER_OUS,
lambda: Dexcom(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
region=Region.OUS
if entry.data[CONF_SERVER] == SERVER_OUS
else Region.US,
)
)
except AccountError:
return False

View File

@@ -5,7 +5,8 @@ from __future__ import annotations
import logging
from typing import Any
from pydexcom import AccountError, Dexcom, SessionError
from pydexcom import Dexcom, Region
from pydexcom.errors import AccountError, SessionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -37,10 +38,13 @@ class DexcomConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
await self.hass.async_add_executor_job(
Dexcom,
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
user_input[CONF_SERVER] == SERVER_OUS,
lambda: Dexcom(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
region=Region.OUS
if user_input[CONF_SERVER] == SERVER_OUS
else Region.US,
)
)
except SessionError:
errors["base"] = "cannot_connect"

View File

@@ -18,7 +18,7 @@ _SCAN_INTERVAL = timedelta(seconds=180)
type DexcomConfigEntry = ConfigEntry[DexcomCoordinator]
class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading]):
class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading | None]):
"""Dexcom Coordinator."""
def __init__(
@@ -37,7 +37,7 @@ class DexcomCoordinator(DataUpdateCoordinator[GlucoseReading]):
)
self.dexcom = dexcom
async def _async_update_data(self) -> GlucoseReading:
async def _async_update_data(self) -> GlucoseReading | None:
"""Fetch data from API endpoint."""
return await self.hass.async_add_executor_job(
self.dexcom.get_current_glucose_reading

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pydexcom"],
"requirements": ["pydexcom==0.2.3"]
"requirements": ["pydexcom==0.5.1"]
}

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime
import logging
import urllib
import urllib.error
from pyW215.pyW215 import SmartPlug

View File

@@ -41,13 +41,20 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
self.stations = {}
for station in stations:
label = station["label"]
rloId = station["RLOIid"]
# API annoyingly sometimes returns a list and some times returns a string
# E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level']
if isinstance(label, list):
label = label[-1]
self.stations[label] = station["stationReference"]
# Similar for RLOIid
# E.g. 0018 has an RLOIid of ['10427', '9154']
if isinstance(rloId, list):
rloId = rloId[-1]
fullName = label + " - " + rloId
self.stations[fullName] = station["stationReference"]
if not self.stations:
return self.async_abort(reason="no_stations")

View File

@@ -103,9 +103,9 @@
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},

View File

@@ -9,7 +9,7 @@
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
"quality_scale": "bronze",
"requirements": ["fritzconnection[qr]==1.15.0", "xmltodict==1.0.2"],
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
"requirements": ["fritzconnection[qr]==1.15.0"]
"requirements": ["fritzconnection[qr]==1.15.1"]
}

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/hdfury",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"quality_scale": "gold",
"requirements": ["hdfury==1.4.2"],
"zeroconf": [
{ "name": "diva-*", "type": "_http._tcp.local." },

View File

@@ -46,24 +46,26 @@ rules:
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Device type integration.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues: todo
repair-issues:
status: exempt
comment: The integration doesn't have any repair cases.
stale-devices:
status: exempt
comment: Device type integration.

View File

@@ -35,11 +35,11 @@
},
"services": {
"decrement": {
"description": "Decrements the current value by 1 step.",
"description": "Decrements the value of an input number by 1 step.",
"name": "Decrement"
},
"increment": {
"description": "Increments the current value by 1 step.",
"description": "Increments the value of an input number by 1 step.",
"name": "Increment"
},
"reload": {
@@ -47,7 +47,7 @@
"name": "[%key:common::action::reload%]"
},
"set_value": {
"description": "Sets the value.",
"description": "Sets the value of an input number.",
"fields": {
"value": {
"description": "The target value.",

View File

@@ -7,10 +7,7 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.device import async_entity_id_to_device_id
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
async_remove_helper_config_entry_from_source_device,
@@ -24,13 +21,6 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Integration from a config entry."""
# This can be removed in HA Core 2026.2
async_remove_stale_devices_links_keep_entity_device(
hass,
entry.entry_id,
entry.options[CONF_SOURCE_SENSOR],
)
def set_source_entity_id_or_uuid(source_entity_id: str) -> None:
hass.config_entries.async_update_entry(
entry,

View File

@@ -76,7 +76,7 @@ async def async_migrate_entities(
def _update_entry(entry: RegistryEntry) -> dict[str, str] | None:
"""Fix unique_id of power binary_sensor entry."""
if entry.domain == Platform.BINARY_SENSOR and ":" not in entry.unique_id:
if "_power" in entry.unique_id:
if entry.unique_id.endswith("_power"):
return {"new_unique_id": f"{coordinator.unique_id}_power"}
return None

View File

@@ -8,7 +8,6 @@ from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import POWER
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
@@ -41,4 +40,4 @@ class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return true if the JVC Projector is on."""
return self.coordinator.data[POWER] in ON_STATUS
return self.coordinator.data[cmd.Power.name] in ON_STATUS

View File

@@ -3,7 +3,3 @@
NAME = "JVC Projector"
DOMAIN = "jvc_projector"
MANUFACTURER = "JVC"
POWER = "power"
INPUT = "input"
SOURCE = "source"

View File

@@ -2,29 +2,40 @@
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
from jvcprojector import (
JvcProjector,
JvcProjectorAuthError,
JvcProjectorTimeoutError,
command as cmd,
)
from jvcprojector import JvcProjector, JvcProjectorTimeoutError, command as cmd
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import INPUT, NAME, POWER
from .const import NAME
if TYPE_CHECKING:
from jvcprojector import Command
_LOGGER = logging.getLogger(__name__)
INTERVAL_SLOW = timedelta(seconds=10)
INTERVAL_FAST = timedelta(seconds=5)
CORE_COMMANDS: tuple[type[Command], ...] = (
cmd.Power,
cmd.Signal,
cmd.Input,
cmd.LightTime,
)
TRANSLATIONS = str.maketrans({"+": "p", "%": "p", ":": "x"})
TIMEOUT_RETRIES = 12
TIMEOUT_SLEEP = 1
type JVCConfigEntry = ConfigEntry[JvcProjectorDataUpdateCoordinator]
@@ -51,27 +62,108 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
assert config_entry.unique_id is not None
self.unique_id = config_entry.unique_id
self.capabilities = self.device.capabilities()
self.state: dict[type[Command], str] = {}
async def _async_update_data(self) -> dict[str, Any]:
"""Get the latest state data."""
state: dict[str, str | None] = {
POWER: None,
INPUT: None,
}
"""Update state with the current value of a command."""
commands: set[type[Command]] = set(self.async_contexts())
commands = commands.difference(CORE_COMMANDS)
try:
state[POWER] = await self.device.get(cmd.Power)
last_timeout: JvcProjectorTimeoutError | None = None
if state[POWER] == cmd.Power.ON:
state[INPUT] = await self.device.get(cmd.Input)
for _ in range(TIMEOUT_RETRIES):
try:
new_state = await self._get_device_state(commands)
break
except JvcProjectorTimeoutError as err:
# Timeouts are expected when the projector loses signal and ignores commands for a brief time.
last_timeout = err
await asyncio.sleep(TIMEOUT_SLEEP)
else:
raise UpdateFailed(str(last_timeout)) from last_timeout
except JvcProjectorTimeoutError as err:
raise UpdateFailed(f"Unable to connect to {self.device.host}") from err
except JvcProjectorAuthError as err:
raise ConfigEntryAuthFailed("Password authentication failed") from err
# Clear state on signal loss
if (
new_state.get(cmd.Signal) == cmd.Signal.NONE
and self.state.get(cmd.Signal) != cmd.Signal.NONE
):
self.state = {k: v for k, v in self.state.items() if k in CORE_COMMANDS}
if state[POWER] != cmd.Power.STANDBY:
# Update state with new values
for k, v in new_state.items():
self.state[k] = v
if self.state[cmd.Power] != cmd.Power.STANDBY:
self.update_interval = INTERVAL_FAST
else:
self.update_interval = INTERVAL_SLOW
return state
return {k.name: v for k, v in self.state.items()}
async def _get_device_state(
self, commands: set[type[Command]]
) -> dict[type[Command], str]:
"""Get the current state of the device."""
new_state: dict[type[Command], str] = {}
deferred_commands: list[type[Command]] = []
power = await self._update_command_state(cmd.Power, new_state)
if power == cmd.Power.ON:
signal = await self._update_command_state(cmd.Signal, new_state)
await self._update_command_state(cmd.Input, new_state)
await self._update_command_state(cmd.LightTime, new_state)
if signal == cmd.Signal.SIGNAL:
for command in commands:
if command.depends:
# Command has dependencies so defer until below
deferred_commands.append(command)
else:
await self._update_command_state(command, new_state)
# Deferred commands should have had dependencies met above
for command in deferred_commands:
depend_command, depend_values = next(iter(command.depends.items()))
value: str | None = None
if depend_command in new_state:
value = new_state[depend_command]
elif depend_command in self.state:
value = self.state[depend_command]
if value and value in depend_values:
await self._update_command_state(command, new_state)
elif self.state.get(cmd.Signal) != cmd.Signal.NONE:
new_state[cmd.Signal] = cmd.Signal.NONE
return new_state
async def _update_command_state(
self, command: type[Command], new_state: dict[type[Command], str]
) -> str | None:
"""Update state with the current value of a command."""
value = await self.device.get(command)
if value != self.state.get(command):
new_state[command] = value
return value
def get_options_map(self, command: str) -> dict[str, str]:
"""Get the available options for a command."""
capabilities = self.capabilities.get(command, {})
if TYPE_CHECKING:
assert isinstance(capabilities, dict)
assert isinstance(capabilities.get("parameter", {}), dict)
assert isinstance(capabilities.get("parameter", {}).get("read", {}), dict)
values = list(capabilities.get("parameter", {}).get("read", {}).values())
return {v: v.translate(TRANSLATIONS) for v in values}
def supports(self, command: type[Command]) -> bool:
"""Check if the device supports a command."""
return self.device.supports(command)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from jvcprojector import JvcProjector
from jvcprojector import Command, JvcProjector
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -20,9 +20,13 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
_attr_has_entity_name = True
def __init__(self, coordinator: JvcProjectorDataUpdateCoordinator) -> None:
def __init__(
self,
coordinator: JvcProjectorDataUpdateCoordinator,
command: type[Command] | None = None,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
super().__init__(coordinator, command)
self._attr_unique_id = coordinator.unique_id
self._attr_device_info = DeviceInfo(

View File

@@ -1,7 +1,7 @@
{
"entity": {
"binary_sensor": {
"jvc_power": {
"power": {
"default": "mdi:projector-off",
"state": {
"on": "mdi:projector"
@@ -9,17 +9,47 @@
}
},
"select": {
"anamorphic": {
"default": "mdi:fit-to-screen-outline"
},
"clear_motion_drive": {
"default": "mdi:blur"
},
"dynamic_control": {
"default": "mdi:lightbulb-on-outline"
},
"input": {
"default": "mdi:hdmi-port"
},
"installation_mode": {
"default": "mdi:aspect-ratio"
},
"light_power": {
"default": "mdi:lightbulb-on-outline"
}
},
"sensor": {
"jvc_power_status": {
"default": "mdi:power-plug-off",
"color_depth": {
"default": "mdi:palette-outline"
},
"color_space": {
"default": "mdi:palette-outline"
},
"hdr": {
"default": "mdi:image-filter-hdr-outline"
},
"hdr_processing": {
"default": "mdi:image-filter-hdr-outline"
},
"picture_mode": {
"default": "mdi:movie-roll"
},
"power": {
"default": "mdi:power",
"state": {
"cooling": "mdi:snowflake",
"error": "mdi:alert-circle",
"on": "mdi:power-plug",
"on": "mdi:power",
"warming": "mdi:heat-wave"
}
}

View File

@@ -14,7 +14,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import POWER
from .coordinator import JVCConfigEntry
from .entity import JvcProjectorEntity
@@ -65,6 +64,8 @@ RENAMED_COMMANDS: dict[str, str] = {
"hdmi2": cmd.Remote.HDMI2,
}
ON_STATUS = (cmd.Power.ON, cmd.Power.WARMING)
_LOGGER = logging.getLogger(__name__)
@@ -86,7 +87,7 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity):
@property
def is_on(self) -> bool:
"""Return True if the entity is on."""
return self.coordinator.data[POWER] in (cmd.Power.ON, cmd.Power.WARMING)
return self.coordinator.data.get(cmd.Power.name) in ON_STATUS
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""

View File

@@ -2,11 +2,10 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Final
from jvcprojector import JvcProjector, command as cmd
from jvcprojector import Command, command as cmd
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
@@ -20,17 +19,37 @@ from .entity import JvcProjectorEntity
class JvcProjectorSelectDescription(SelectEntityDescription):
"""Describes JVC Projector select entities."""
command: Callable[[JvcProjector, str], Awaitable[None]]
command: type[Command]
SELECTS: Final[list[JvcProjectorSelectDescription]] = [
SELECTS: Final[tuple[JvcProjectorSelectDescription, ...]] = (
JvcProjectorSelectDescription(key="input", command=cmd.Input),
JvcProjectorSelectDescription(
key="input",
translation_key="input",
options=[cmd.Input.HDMI1, cmd.Input.HDMI2],
command=lambda device, option: device.set(cmd.Input, option),
)
]
key="installation_mode",
command=cmd.InstallationMode,
entity_registry_enabled_default=False,
),
JvcProjectorSelectDescription(
key="light_power",
command=cmd.LightPower,
entity_registry_enabled_default=False,
),
JvcProjectorSelectDescription(
key="dynamic_control",
command=cmd.DynamicControl,
entity_registry_enabled_default=False,
),
JvcProjectorSelectDescription(
key="clear_motion_drive",
command=cmd.ClearMotionDrive,
entity_registry_enabled_default=False,
),
JvcProjectorSelectDescription(
key="anamorphic",
command=cmd.Anamorphic,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
@@ -42,30 +61,45 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
JvcProjectorSelectEntity(coordinator, description) for description in SELECTS
JvcProjectorSelectEntity(coordinator, description)
for description in SELECTS
if coordinator.supports(description.command)
)
class JvcProjectorSelectEntity(JvcProjectorEntity, SelectEntity):
"""Representation of a JVC Projector select entity."""
entity_description: JvcProjectorSelectDescription
def __init__(
self,
coordinator: JvcProjectorDataUpdateCoordinator,
description: JvcProjectorSelectDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
super().__init__(coordinator, description.command)
self.command: type[Command] = description.command
self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_id}_{description.key}"
self._attr_translation_key = description.key
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
self._options_map: dict[str, str] = coordinator.get_options_map(
self.command.name
)
@property
def options(self) -> list[str]:
"""Return a list of selectable options."""
return list(self._options_map.values())
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
return self.coordinator.data[self.entity_description.key]
if value := self.coordinator.data.get(self.command.name):
return self._options_map.get(value)
return None
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.command(self.coordinator.device, option)
value = next((k for k, v in self._options_map.items() if v == option), None)
await self.coordinator.device.set(self.command, value)

View File

@@ -2,33 +2,77 @@
from __future__ import annotations
from jvcprojector import command as cmd
from dataclasses import dataclass
from jvcprojector import Command, command as cmd
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
JVC_SENSORS = (
SensorEntityDescription(
@dataclass(frozen=True, kw_only=True)
class JvcProjectorSensorDescription(SensorEntityDescription):
"""Describes JVC Projector sensor entities."""
command: type[Command]
SENSORS: tuple[JvcProjectorSensorDescription, ...] = (
JvcProjectorSensorDescription(
key="power",
translation_key="jvc_power_status",
command=cmd.Power,
device_class=SensorDeviceClass.ENUM,
),
JvcProjectorSensorDescription(
key="light_time",
command=cmd.LightTime,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfTime.HOURS,
),
JvcProjectorSensorDescription(
key="color_depth",
command=cmd.ColorDepth,
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=[
cmd.Power.STANDBY,
cmd.Power.ON,
cmd.Power.WARMING,
cmd.Power.COOLING,
cmd.Power.ERROR,
],
entity_registry_enabled_default=False,
),
JvcProjectorSensorDescription(
key="color_space",
command=cmd.ColorSpace,
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
JvcProjectorSensorDescription(
key="hdr",
command=cmd.Hdr,
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
JvcProjectorSensorDescription(
key="hdr_processing",
command=cmd.HdrProcessing,
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
JvcProjectorSensorDescription(
key="picture_mode",
command=cmd.PictureMode,
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
@@ -42,24 +86,48 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
JvcSensor(coordinator, description) for description in JVC_SENSORS
JvcProjectorSensorEntity(coordinator, description)
for description in SENSORS
if coordinator.supports(description.command)
)
class JvcSensor(JvcProjectorEntity, SensorEntity):
class JvcProjectorSensorEntity(JvcProjectorEntity, SensorEntity):
"""The entity class for JVC Projector integration."""
def __init__(
self,
coordinator: JvcProjectorDataUpdateCoordinator,
description: SensorEntityDescription,
description: JvcProjectorSensorDescription,
) -> None:
"""Initialize the JVC Projector sensor."""
super().__init__(coordinator)
super().__init__(coordinator, description.command)
self.command: type[Command] = description.command
self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_id}_{description.key}"
self._attr_translation_key = description.key
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
self._options_map: dict[str, str] = {}
if self.device_class == SensorDeviceClass.ENUM:
self._options_map = coordinator.get_options_map(self.command.name)
@property
def options(self) -> list[str] | None:
"""Return a set of possible options."""
if self.device_class == SensorDeviceClass.ENUM:
return list(self._options_map.values())
return None
@property
def native_value(self) -> str | None:
"""Return the native value."""
return self.coordinator.data[self.entity_description.key]
value = self.coordinator.data.get(self.command.name)
if value is None:
return None
if self.device_class == SensorDeviceClass.ENUM:
return self._options_map.get(value)
return value

View File

@@ -36,20 +36,134 @@
"entity": {
"binary_sensor": {
"power": {
"name": "[%key:component::binary_sensor::entity_component::power::name%]"
"name": "Power"
}
},
"select": {
"anamorphic": {
"name": "Anamorphic",
"state": {
"a": "A",
"b": "B",
"c": "C",
"d": "D",
"off": "[%key:common::state::off%]"
}
},
"clear_motion_drive": {
"name": "Clear Motion Drive",
"state": {
"high": "[%key:common::state::high%]",
"inverse-telecine": "Inverse Telecine",
"low": "[%key:common::state::low%]",
"off": "[%key:common::state::off%]"
}
},
"dynamic_control": {
"name": "Dynamic Control",
"state": {
"balanced": "Balanced",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"mode-1": "Mode 1",
"mode-2": "Mode 2",
"mode-3": "Mode 3",
"off": "[%key:common::state::off%]"
}
},
"input": {
"name": "Input",
"state": {
"hdmi1": "HDMI 1",
"hdmi2": "HDMI 2"
}
},
"installation_mode": {
"name": "Installation Mode",
"state": {
"memory-1": "Memory 1",
"memory-10": "Memory 10",
"memory-2": "Memory 2",
"memory-3": "Memory 3",
"memory-4": "Memory 4",
"memory-5": "Memory 5",
"memory-6": "Memory 6",
"memory-7": "Memory 7",
"memory-8": "Memory 8",
"memory-9": "Memory 9"
}
},
"light_power": {
"name": "Light Power",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"mid": "[%key:common::state::medium%]",
"normal": "[%key:common::state::normal%]"
}
}
},
"sensor": {
"jvc_power_status": {
"color_depth": {
"name": "Color Depth",
"state": {
"8-bit": "8-bit",
"10-bit": "10-bit",
"12-bit": "12-bit"
}
},
"color_space": {
"name": "Color Space",
"state": {
"rgb": "RGB",
"xv-color": "XV Color",
"ycbcr-420": "YCbCr 4:2:0",
"ycbcr-422": "YCbCr 4:2:2",
"ycbcr-444": "YCbCr 4:4:4",
"yuv": "YUV"
}
},
"hdr": {
"name": "HDR",
"state": {
"hdr": "HDR",
"hdr10p": "HDR10+",
"hybrid-log": "Hybrid Log",
"none": "None",
"sdr": "SDR",
"smpte-st-2084": "SMPTE ST 2084"
}
},
"hdr_processing": {
"name": "HDR Processing",
"state": {
"frame-by-frame": "Frame-by-Frame",
"hdr10p": "HDR10+",
"scene-by-scene": "Scene-by-Scene",
"static": "Static"
}
},
"light_time": {
"name": "Light Time"
},
"picture_mode": {
"name": "Picture Mode",
"state": {
"frame-adapt-hdr": "Frame Adapt HDR",
"frame-adapt-hdr2": "Frame Adapt HDR2",
"frame-adapt-hdr3": "Frame Adapt HDR3",
"hdr1": "HDR1",
"hdr10": "HDR10",
"hdr10-ll": "HDR10 LL",
"hdr2": "HDR2",
"last-setting": "Last Setting",
"pana-pq": "Pana PQ",
"user-4": "User 4",
"user-5": "User 5",
"user-6": "User 6"
}
},
"power": {
"name": "Status",
"state": {
"cooling": "Cooling",

View File

@@ -78,9 +78,9 @@
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},

View File

@@ -0,0 +1,67 @@
"""The liebherr integration."""
from __future__ import annotations
import asyncio
from pyliebherrhomeapi import LiebherrClient
from pyliebherrhomeapi.exceptions import (
LiebherrAuthenticationError,
LiebherrConnectionError,
)
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> bool:
"""Set up Liebherr from a config entry."""
# Create shared API client
client = LiebherrClient(
api_key=entry.data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
# Fetch device list to create coordinators
try:
devices = await client.get_devices()
except LiebherrAuthenticationError as err:
raise ConfigEntryError("Invalid API key") from err
except LiebherrConnectionError as err:
raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err
# Create a coordinator for each device (may be empty if no devices)
coordinators: dict[str, LiebherrCoordinator] = {}
for device in devices:
coordinator = LiebherrCoordinator(
hass=hass,
config_entry=entry,
client=client,
device_id=device.device_id,
)
coordinators[device.device_id] = coordinator
await asyncio.gather(
*(
coordinator.async_config_entry_first_refresh()
for coordinator in coordinators.values()
)
)
# Store coordinators in runtime data
entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,68 @@
"""Config flow for the liebherr integration."""
from __future__ import annotations
import logging
from typing import Any
from pyliebherrhomeapi import LiebherrClient
from pyliebherrhomeapi.exceptions import (
LiebherrAuthenticationError,
LiebherrConnectionError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
)
class LiebherrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for liebherr."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
user_input[CONF_API_KEY] = user_input[CONF_API_KEY].strip()
self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]})
try:
# Create a client and test the connection
client = LiebherrClient(
api_key=user_input[CONF_API_KEY],
session=async_get_clientsession(self.hass),
)
devices = await client.get_devices()
except LiebherrAuthenticationError:
errors["base"] = "invalid_auth"
except LiebherrConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if not devices:
return self.async_abort(reason="no_devices")
return self.async_create_entry(
title="Liebherr",
data=user_input,
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -0,0 +1,6 @@
"""Constants for the liebherr integration."""
from typing import Final
DOMAIN: Final = "liebherr"
MANUFACTURER: Final = "Liebherr"

View File

@@ -0,0 +1,75 @@
"""DataUpdateCoordinator for Liebherr integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from pyliebherrhomeapi import (
DeviceState,
LiebherrAuthenticationError,
LiebherrClient,
LiebherrConnectionError,
LiebherrTimeoutError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
type LiebherrConfigEntry = ConfigEntry[dict[str, LiebherrCoordinator]]
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60)
class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
"""Class to manage fetching Liebherr data from the API for a single device."""
def __init__(
self,
hass: HomeAssistant,
config_entry: LiebherrConfigEntry,
client: LiebherrClient,
device_id: str,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=f"{DOMAIN}_{device_id}",
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
self.client = client
self.device_id = device_id
async def _async_setup(self) -> None:
"""Set up the coordinator by validating device access."""
try:
await self.client.get_device(self.device_id)
except LiebherrAuthenticationError as err:
raise ConfigEntryError("Invalid API key") from err
except LiebherrConnectionError as err:
raise ConfigEntryNotReady(
f"Failed to connect to device {self.device_id}: {err}"
) from err
async def _async_update_data(self) -> DeviceState:
"""Fetch data from API for this device."""
try:
return await self.client.get_device_state(self.device_id)
except LiebherrAuthenticationError as err:
raise ConfigEntryError("API key is no longer valid") from err
except LiebherrTimeoutError as err:
raise UpdateFailed(
f"Timeout communicating with device {self.device_id}"
) from err
except LiebherrConnectionError as err:
raise UpdateFailed(
f"Error communicating with device {self.device_id}"
) from err

View File

@@ -0,0 +1,75 @@
"""Base entity for Liebherr integration."""
from __future__ import annotations
from pyliebherrhomeapi import TemperatureControl, ZonePosition
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import LiebherrCoordinator
# Zone position to translation key mapping
ZONE_POSITION_MAP = {
ZonePosition.TOP: "top_zone",
ZonePosition.MIDDLE: "middle_zone",
ZonePosition.BOTTOM: "bottom_zone",
}
class LiebherrEntity(CoordinatorEntity[LiebherrCoordinator]):
"""Base entity for Liebherr devices."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: LiebherrCoordinator,
) -> None:
"""Initialize the Liebherr entity."""
super().__init__(coordinator)
device = coordinator.data.device
model = None
if device.device_type:
model = device.device_type.title()
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device_id)},
name=device.nickname or device.device_name,
manufacturer=MANUFACTURER,
model=model,
model_id=device.device_name,
)
class LiebherrZoneEntity(LiebherrEntity):
"""Base entity for zone-based Liebherr entities.
This class should be used for entities that are associated with a specific
temperature control zone (e.g., climate, zone sensors).
"""
def __init__(
self,
coordinator: LiebherrCoordinator,
zone_id: int,
) -> None:
"""Initialize the zone entity."""
super().__init__(coordinator)
self._zone_id = zone_id
@property
def temperature_control(self) -> TemperatureControl | None:
"""Get the temperature control for this zone."""
return self.coordinator.data.get_temperature_controls().get(self._zone_id)
def _get_zone_translation_key(self) -> str | None:
"""Get the translation key for this zone."""
control = self.temperature_control
if control and isinstance(control.zone_position, ZonePosition):
return ZONE_POSITION_MAP.get(control.zone_position)
# Fallback to None to use device model name
return None

View File

@@ -0,0 +1,18 @@
{
"domain": "liebherr",
"name": "Liebherr",
"codeowners": ["@mettolen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/liebherr",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyliebherrhomeapi"],
"quality_scale": "bronze",
"requirements": ["pyliebherrhomeapi==0.2.1"],
"zeroconf": [
{
"name": "liebherr*",
"type": "_http._tcp.local."
}
]
}

View File

@@ -0,0 +1,72 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no configurable parameters after initial setup.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Cloud API does not require updating entry data from network discovery.
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No repair issues to implement at this time.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,118 @@
"""Sensor platform for Liebherr integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pyliebherrhomeapi import TemperatureControl, TemperatureUnit
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import LiebherrZoneEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class LiebherrSensorEntityDescription(SensorEntityDescription):
"""Describes Liebherr sensor entity."""
value_fn: Callable[[TemperatureControl], StateType]
unit_fn: Callable[[TemperatureControl], str]
SENSOR_TYPES: tuple[LiebherrSensorEntityDescription, ...] = (
LiebherrSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda control: control.value,
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 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)
class LiebherrSensor(LiebherrZoneEntity, SensorEntity):
"""Representation of a Liebherr sensor."""
entity_description: LiebherrSensorEntityDescription
def __init__(
self,
coordinator: LiebherrCoordinator,
zone_id: int,
description: LiebherrSensorEntityDescription,
) -> None:
"""Initialize the sensor 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 model name instead of zone name
temp_controls = coordinator.data.get_temperature_controls()
if len(temp_controls) == 1:
self._attr_name = None
else:
# Set translation key based on zone position for multi-zone devices
self._attr_translation_key = self._get_zone_translation_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) -> StateType:
"""Return the current value."""
if (temp_control := self.temperature_control) is None:
return None
return self.entity_description.value_fn(temp_control)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.temperature_control is not None

View File

@@ -0,0 +1,38 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices": "No devices found for this API key"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_devices": "No devices found for this API key",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The API key from the Liebherr SmartDevice app. Note: The API key can only be copied once from the app."
},
"description": "Enter your Liebherr HomeAPI key. You can find it in the Liebherr SmartDevice app under Settings → Become a beta tester."
}
}
},
"entity": {
"sensor": {
"bottom_zone": {
"name": "Bottom zone"
},
"middle_zone": {
"name": "Middle zone"
},
"top_zone": {
"name": "Top zone"
}
}
}
}

View File

@@ -73,6 +73,3 @@ LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202}
LIFX_128ZONE_CEILING_PRODUCT_IDS = {201, 202}
_LOGGER = logging.getLogger(__package__)
# _ATTR_COLOR_TEMP deprecated - to be removed in 2026.1
_ATTR_COLOR_TEMP = "color_temp"

View File

@@ -33,7 +33,7 @@ from homeassistant.helpers.target import (
async_extract_referenced_entity_ids,
)
from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DOMAIN
from .const import ATTR_THEME, DOMAIN
from .coordinator import LIFXUpdateCoordinator
from .util import convert_8_to_16, find_hsbk
@@ -135,8 +135,6 @@ LIFX_EFFECT_PULSE_SCHEMA = cv.make_entity_service_schema(
vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): vol.All(
vol.Coerce(int), vol.Range(min=1500, max=9000)
),
# _ATTR_COLOR_TEMP deprecated - to be removed in 2026.1
vol.Exclusive(_ATTR_COLOR_TEMP, COLOR_GROUP): cv.positive_int,
ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)),
ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)),
ATTR_MODE: vol.In(PULSE_MODES),

View File

@@ -26,7 +26,6 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.util import color as color_util
from .const import (
_ATTR_COLOR_TEMP,
_LOGGER,
DEFAULT_ATTEMPTS,
DOMAIN,
@@ -115,17 +114,6 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] |
saturation = int(saturation / 100 * 65535)
kelvin = 3500
if ATTR_COLOR_TEMP_KELVIN not in kwargs and _ATTR_COLOR_TEMP in kwargs:
# added in 2025.1, can be removed in 2026.1
_LOGGER.warning(
"The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for"
" all service calls"
)
kelvin = color_util.color_temperature_mired_to_kelvin(
kwargs.pop(_ATTR_COLOR_TEMP)
)
saturation = 0
if ATTR_COLOR_TEMP_KELVIN in kwargs:
kelvin = kwargs.pop(ATTR_COLOR_TEMP_KELVIN)
saturation = 0

View File

@@ -336,9 +336,9 @@
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
},
"trigger_threshold_type": {

View File

@@ -244,9 +244,9 @@
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},

View File

@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
@@ -25,7 +26,9 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -259,6 +262,7 @@ class MinMaxSensor(SensorEntity):
)
self._async_min_max_sensor_state_listener(state_event, update_state=False)
self._update_device_class()
self._calc_values()
@property
@@ -345,6 +349,32 @@ class MinMaxSensor(SensorEntity):
self._calc_values()
self.async_write_ha_state()
@callback
def _update_device_class(self) -> None:
"""Update device_class based on source entities.
If all source entities have the same device_class, inherit it.
Otherwise, leave device_class as None.
"""
device_classes: list[SensorDeviceClass | None] = []
for entity_id in self._entity_ids:
try:
device_class = get_device_class(self.hass, entity_id)
if device_class:
device_classes.append(SensorDeviceClass(device_class))
else:
device_classes.append(None)
except (HomeAssistantError, ValueError):
# If we can't get device class for any entity, don't set it
device_classes.append(None)
# Only inherit device_class if all entities have the same non-None device_class
if device_classes and all(
dc is not None and dc == device_classes[0] for dc in device_classes
):
self._attr_device_class = device_classes[0]
@callback
def _calc_values(self) -> None:
"""Calculate the values."""

View File

@@ -34,7 +34,7 @@
},
"user": {
"data": {
"domain": "[%key:common::config_flow::data::username%]",
"domain": "Domain",
"host": "[%key:common::config_flow::data::host%]",
"password": "Dynamic DNS password"
},

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nibe_heatpump",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["nibe==2.21.0"]
"requirements": ["nibe==2.22.0"]
}

View File

@@ -594,7 +594,8 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = {
}
# We translate units that were using using the legacy coding of μ \u00b5
# to units using recommended coding of μ \u03bc
# to units using recommended coding of μ \u03bc and
# we convert alternative accepted units to the preferred unit.
AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5Sv/h": "μSv/h", # aranet: radiation rate
"\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM,
@@ -604,4 +605,9 @@ AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
"\u00b5g": UnitOfMass.MICROGRAMS,
"\u00b5s": UnitOfTime.MICROSECONDS,
"mVAr": UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE,
"VAr": UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
"kVAr": UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
"VArh": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR,
"kVArh": UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR,
}

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["openevsehttp"],
"quality_scale": "legacy",
"quality_scale": "bronze",
"requirements": ["python-openevse-http==0.2.1"],
"zeroconf": ["_openevse._tcp.local."]
}

View File

@@ -25,6 +25,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OpenEVSENumberDescription(NumberEntityDescription):

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options flow.
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery: done
discovery-update-info: done
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: Integration supports a single device per config entry.
entity-category: todo
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: done
comment: Integration creates repair issues for YAML deprecation.
stale-devices:
status: exempt
comment: Integration supports a single device per config entry.
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo

View File

@@ -15,8 +15,12 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import PortainerCoordinator
from .services import async_setup_services
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -25,6 +29,7 @@ _PLATFORMS: list[Platform] = [
Platform.BUTTON,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
@@ -49,6 +54,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
return True
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Portainer integration."""
await async_setup_services(hass)
return True
async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -3,6 +3,7 @@
DOMAIN = "portainer"
DEFAULT_NAME = "Portainer"
ENDPOINT_STATUS_DOWN = 2
CONTAINER_STATE_RUNNING = "running"

View File

@@ -67,5 +67,10 @@
}
}
}
},
"services": {
"prune_images": {
"service": "mdi:delete-sweep"
}
}
}

View File

@@ -7,10 +7,7 @@ rules:
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
@@ -33,10 +30,7 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: exempt
comment: |
No explicit parallel updates are defined.
parallel-updates: todo
reauthentication-flow:
status: todo
comment: |

View File

@@ -0,0 +1,115 @@
"""Services for the Portainer integration."""
from datetime import timedelta
from pyportainer import (
PortainerAuthenticationError,
PortainerConnectionError,
PortainerTimeoutError,
)
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.service import async_extract_config_entry_ids
from .const import DOMAIN
from .coordinator import PortainerConfigEntry
ATTR_DATE_UNTIL = "until"
ATTR_DANGLING = "dangling"
SERVICE_PRUNE_IMAGES = "prune_images"
SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): cv.string,
vol.Optional(ATTR_DATE_UNTIL): vol.All(
cv.time_period, vol.Range(min=timedelta(minutes=1))
),
vol.Optional(ATTR_DANGLING): cv.boolean,
},
)
async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry:
"""Extract config entry from the service call."""
target_entry_ids = await async_extract_config_entry_ids(service_call)
target_entries: list[PortainerConfigEntry] = [
loaded_entry
for loaded_entry in service_call.hass.config_entries.async_loaded_entries(
DOMAIN
)
if loaded_entry.entry_id in target_entry_ids
]
if not target_entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_target",
)
return target_entries[0]
async def _get_endpoint_id(
call: ServiceCall,
config_entry: PortainerConfigEntry,
) -> int:
"""Get endpoint data from device ID."""
device_reg = dr.async_get(call.hass)
device_id = call.data[ATTR_DEVICE_ID]
device = device_reg.async_get(device_id)
assert device
coordinator = config_entry.runtime_data
endpoint_data = None
for data in coordinator.data.values():
if (
DOMAIN,
f"{config_entry.entry_id}_{data.endpoint.id}",
) in device.identifiers:
endpoint_data = data
break
assert endpoint_data
return endpoint_data.endpoint.id
async def prune_images(call: ServiceCall) -> None:
"""Prune unused images in Portainer, with more controls."""
config_entry = await _extract_config_entry(call)
coordinator = config_entry.runtime_data
endpoint_id = await _get_endpoint_id(call, config_entry)
try:
await coordinator.portainer.images_prune(
endpoint_id=endpoint_id,
until=call.data.get(ATTR_DATE_UNTIL),
dangling=call.data.get(ATTR_DANGLING, False),
)
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth_no_details",
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_no_details",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect_no_details",
) from err
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
hass.services.async_register(
DOMAIN,
SERVICE_PRUNE_IMAGES,
prune_images,
SERVICE_PRUNE_IMAGES_SCHEMA,
)

View File

@@ -0,0 +1,18 @@
# Services for Portainer
prune_images:
fields:
device_id:
required: true
selector:
device:
integration: portainer
model: Endpoint
until:
required: false
selector:
duration:
dangling:
required: false
selector:
boolean: {}

View File

@@ -155,11 +155,34 @@
"invalid_auth_no_details": {
"message": "An error occurred while trying to authenticate."
},
"invalid_target": {
"message": "Invalid device targeted."
},
"timeout_connect": {
"message": "A timeout occurred while trying to connect to the Portainer instance: {error}"
},
"timeout_connect_no_details": {
"message": "A timeout occurred while trying to connect to the Portainer instance."
}
},
"services": {
"prune_images": {
"description": "Prunes unused images on a Portainer endpoint.",
"fields": {
"dangling": {
"description": "If true, only prune dangling images.",
"name": "Dangling"
},
"device_id": {
"description": "The endpoint to prune images on.",
"name": "Endpoint"
},
"until": {
"description": "Prune images unused for at least this time duration in the past. If not provided, all unused images will be pruned.",
"name": "Until"
}
},
"name": "Prune unused images"
}
}
}

View File

@@ -384,11 +384,7 @@ class PrometheusMetrics:
if event.data["action"] != "update" or "area_id" not in event.data["changes"]:
return
device_id = event.data.get("device_id")
if device_id is None:
return
device_id = event.data["device_id"]
_LOGGER.debug("Handling device update for %s", device_id)
device = self.device_registry.async_get(device_id)

View File

@@ -4,15 +4,18 @@ from __future__ import annotations
from datetime import datetime
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.components.calendar import (
CalendarEntity,
CalendarEntityDescription,
CalendarEvent,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CalendarUpdateCoordinator, RadarrConfigEntry, RadarrEvent
from .entity import RadarrEntity
CALENDAR_TYPE = EntityDescription(
CALENDAR_TYPE = CalendarEntityDescription(
key="calendar",
name=None,
)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from concurrent.futures.thread import _threads_queues, _worker
import sys
import threading
from typing import Any
import weakref
@@ -54,17 +53,10 @@ class DBInterruptibleThreadPoolExecutor(InterruptibleThreadPoolExecutor):
) -> None:
q.put(None)
if sys.version_info >= (3, 14):
additional_args = (
self._create_worker_context(),
self._work_queue,
)
else:
additional_args = (
self._work_queue,
self._initializer,
self._initargs,
)
additional_args = (
self._create_worker_context(),
self._work_queue,
)
num_threads = len(self._threads)
if num_threads < self._max_workers:

View File

@@ -19,7 +19,7 @@
"data_description": {
"calendar_name": "The name of the calendar shown in the UI.",
"url": "The URL of the remote calendar.",
"verify_ssl": "Enable SSL certificate verification for secure connections."
"verify_ssl": "[%key:common::config_flow::description::verify_ssl%]"
},
"description": "Please choose a name for the calendar to be imported"
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.5.2"]
"requirements": ["renault-api==0.5.3"]
}

View File

@@ -840,7 +840,8 @@ STATE_CLASS_UNITS: dict[SensorStateClass | str, set[type[StrEnum] | str | None]]
}
# We translate units that were using using the legacy coding of μ \u00b5
# to units using recommended coding of μ \u03bc
# to units using recommended coding of μ \u03bc and
# we convert alternative accepted units to the preferred unit.
AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5Sv/h": "μSv/h", # aranet: radiation rate
"\u00b5S/cm": UnitOfConductivity.MICROSIEMENS_PER_CM,
@@ -850,4 +851,9 @@ AMBIGUOUS_UNITS: dict[str | None, str] = {
"\u00b5mol/s⋅m²": "μmol/s⋅m²", # fyta: light
"\u00b5g": UnitOfMass.MICROGRAMS,
"\u00b5s": UnitOfTime.MICROSECONDS,
"mVAr": UnitOfReactivePower.MILLIVOLT_AMPERE_REACTIVE,
"VAr": UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
"kVAr": UnitOfReactivePower.KILO_VOLT_AMPERE_REACTIVE,
"VArh": UnitOfReactiveEnergy.VOLT_AMPERE_REACTIVE_HOUR,
"kVArh": UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR,
}

View File

@@ -260,7 +260,7 @@ async def get_coap_context(hass: HomeAssistant) -> COAP:
ipv4: list[IPv4Address] = []
if not network.async_only_default_interface_enabled(adapters):
ipv4.extend(
address
cast(IPv4Address, address)
for address in await network.async_get_enabled_source_ips(hass)
if address.version == 4
and not (

View File

@@ -4,18 +4,13 @@ from homeassistant.const import Platform
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
GROUP_PREFIX = "snapcast_group_"
GROUP_SUFFIX = "Snapcast Group"
CLIENT_PREFIX = "snapcast_client_"
CLIENT_SUFFIX = "Snapcast Client"
SERVICE_SNAPSHOT = "snapshot"
SERVICE_RESTORE = "restore"
SERVICE_JOIN = "join"
SERVICE_UNJOIN = "unjoin"
SERVICE_SET_LATENCY = "set_latency"
ATTR_MASTER = "master"
ATTR_LATENCY = "latency"
DOMAIN = "snapcast"

View File

@@ -1,8 +1,5 @@
{
"services": {
"join": {
"service": "mdi:music-note-plus"
},
"restore": {
"service": "mdi:camera-retake"
},
@@ -11,9 +8,6 @@
},
"snapshot": {
"service": "mdi:camera"
},
"unjoin": {
"service": "mdi:music-note-minus"
}
}
}

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from collections.abc import Mapping
import logging
from typing import Any
@@ -25,23 +25,17 @@ from homeassistant.helpers import (
config_validation as cv,
entity_platform,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_LATENCY,
ATTR_MASTER,
CLIENT_PREFIX,
CLIENT_SUFFIX,
DOMAIN,
GROUP_PREFIX,
GROUP_SUFFIX,
SERVICE_JOIN,
SERVICE_RESTORE,
SERVICE_SET_LATENCY,
SERVICE_SNAPSHOT,
SERVICE_UNJOIN,
)
from .coordinator import SnapcastUpdateCoordinator
from .entity import SnapcastCoordinatorEntity
@@ -52,12 +46,6 @@ STREAM_STATUS = {
"unknown": None,
}
_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.SELECT_SOURCE
)
_LOGGER = logging.getLogger(__name__)
@@ -67,10 +55,6 @@ def register_services() -> None:
platform.async_register_entity_service(SERVICE_SNAPSHOT, None, "async_snapshot")
platform.async_register_entity_service(SERVICE_RESTORE, None, "async_restore")
platform.async_register_entity_service(
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join"
)
platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
platform.async_register_entity_service(
SERVICE_SET_LATENCY,
{vol.Required(ATTR_LATENCY): cv.positive_int},
@@ -90,45 +74,39 @@ async def async_setup_entry(
register_services()
_known_group_ids: set[str] = set()
_known_client_ids: set[str] = set()
@callback
def _update_entities(
entity_class: type[SnapcastClientDevice | SnapcastGroupDevice],
known_ids: set[str],
get_device: Callable[[str], Snapclient | Snapgroup],
get_devices: Callable[[], list[Snapclient] | list[Snapgroup]],
) -> None:
# Get IDs of current devices on server
snapcast_ids = {d.identifier for d in get_devices()}
def _update_clients() -> None:
# Get IDs of current clients on server
snapcast_ids = {d.identifier for d in coordinator.server.clients}
# Update known IDs
ids_to_add = snapcast_ids - known_ids
ids_to_remove = known_ids - snapcast_ids
ids_to_add = snapcast_ids - _known_client_ids
ids_to_remove = _known_client_ids - snapcast_ids
known_ids.difference_update(ids_to_remove)
known_ids.update(ids_to_add)
_known_client_ids.difference_update(ids_to_remove)
_known_client_ids.update(ids_to_add)
# Exit early if no changes
if not (ids_to_add | ids_to_remove):
return
_LOGGER.debug(
"New %s: %s",
entity_class,
str([get_device(d).friendly_name for d in ids_to_add]),
"New snapcast client: %s",
str([coordinator.server.client(d).friendly_name for d in ids_to_add]),
)
_LOGGER.debug(
"Remove %s IDs: %s",
entity_class,
"Remove snapcast client IDs: %s",
str([list(ids_to_remove)]),
)
# Add new entities
async_add_entities(
[
entity_class(coordinator, get_device(snapcast_id))
SnapcastClientDevice(
coordinator, coordinator.server.client(snapcast_id)
)
for snapcast_id in ids_to_add
]
)
@@ -139,47 +117,33 @@ async def async_setup_entry(
if entity_id := entity_registry.async_get_entity_id(
MEDIA_PLAYER_DOMAIN,
DOMAIN,
entity_class.get_unique_id(coordinator.host_id, snapcast_id),
SnapcastClientDevice.get_unique_id(coordinator.host_id, snapcast_id),
):
entity_registry.async_remove(entity_id)
def _update_clients() -> None:
_update_entities(
SnapcastClientDevice,
_known_client_ids,
coordinator.server.client,
lambda: coordinator.server.clients,
)
# Create client entities and add listener to update clients on server update
_update_clients()
coordinator.async_add_listener(_update_clients)
def _update_groups() -> None:
_update_entities(
SnapcastGroupDevice,
_known_group_ids,
coordinator.server.group,
lambda: coordinator.server.groups,
)
# Create group entities and add listener to update groups on server update
_update_groups()
coordinator.async_add_listener(_update_groups)
class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
"""Base class representing a Snapcast device."""
class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
"""Representation of a Snapcast client device."""
_attr_should_poll = False
_attr_supported_features = _SUPPORTED_FEATURES
_attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.GROUPING
)
_attr_media_content_type = MediaType.MUSIC
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_device: Snapclient
def __init__(
self,
coordinator: SnapcastUpdateCoordinator,
device: Snapgroup | Snapclient,
device: Snapclient,
) -> None:
"""Initialize the base device."""
super().__init__(coordinator)
@@ -191,13 +155,13 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
@classmethod
def get_unique_id(cls, host, id) -> str:
"""Build a unique ID."""
raise NotImplementedError
"""Get a unique ID for a client."""
return f"{CLIENT_PREFIX}{host}_{id}"
@property
def _current_group(self) -> Snapgroup:
"""Return the group."""
raise NotImplementedError
"""Return the group the client is associated with."""
return self._device.group
async def async_added_to_hass(self) -> None:
"""Subscribe to events."""
@@ -213,6 +177,33 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
"""Return the snapcast identifier."""
return self._device.identifier
@property
def name(self) -> str:
"""Return the name of the device."""
return f"{self._device.friendly_name} {CLIENT_SUFFIX}"
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the player."""
if self._device.connected:
if self.is_volume_muted or self._current_group.muted:
return MediaPlayerState.IDLE
return STREAM_STATUS.get(self._current_group.stream_status)
return MediaPlayerState.OFF
@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the state attributes."""
state_attrs = {}
if self.latency is not None:
state_attrs["latency"] = self.latency
return state_attrs
@property
def latency(self) -> float | None:
"""Return current latency."""
return self._device.latency
@property
def source(self) -> str | None:
"""Return the current input source."""
@@ -260,29 +251,54 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
self.async_write_ha_state()
async def async_set_latency(self, latency) -> None:
"""Handle the set_latency service."""
raise NotImplementedError
"""Set the latency of the client."""
await self._device.set_latency(latency)
self.async_write_ha_state()
async def async_join(self, master) -> None:
"""Handle the join service."""
raise NotImplementedError
@property
def group_members(self) -> list[str] | None:
"""List of player entities which are currently grouped together for synchronous playback."""
entity_registry = er.async_get(self.hass)
return [
entity_id
for client_id in self._current_group.clients
if (
entity_id := entity_registry.async_get_entity_id(
MEDIA_PLAYER_DOMAIN,
DOMAIN,
self.get_unique_id(self.coordinator.host_id, client_id),
)
)
]
async def async_unjoin(self) -> None:
"""Handle the unjoin service."""
raise NotImplementedError
async def async_join_players(self, group_members: list[str]) -> None:
"""Add `group_members` to this client's current group."""
# Get the client entity for each group member excluding self
entity_registry = er.async_get(self.hass)
clients = [
entity
for entity_id in group_members
if (entity := entity_registry.async_get(entity_id))
and entity.unique_id != self.unique_id
]
def _async_create_grouping_deprecation_issue(self) -> None:
"""Create an issue for deprecated grouping actions."""
ir.async_create_issue(
self.hass,
DOMAIN,
"deprecated_grouping_actions",
breaks_in_ha_version="2026.2.0",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_grouping_actions",
)
for client in clients:
# Valid entity is a snapcast client
if not client.unique_id.startswith(CLIENT_PREFIX):
raise ServiceValidationError(
f"Entity '{client.entity_id}' is not a Snapcast client device."
)
# Extract client ID and join it to the current group
identifier = client.unique_id.split("_")[-1]
await self._current_group.add_client(identifier)
self.async_write_ha_state()
async def async_unjoin_player(self) -> None:
"""Remove this client from it's current group."""
await self._current_group.remove_client(self._device.identifier)
self.async_write_ha_state()
@property
def metadata(self) -> Mapping[str, Any]:
@@ -353,222 +369,3 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
return int(value)
return None
class SnapcastGroupDevice(SnapcastBaseDevice):
"""Representation of a Snapcast group device."""
_device: Snapgroup
@classmethod
def get_unique_id(cls, host, id) -> str:
"""Get a unique ID for a group."""
return f"{GROUP_PREFIX}{host}_{id}"
@property
def _current_group(self) -> Snapgroup:
"""Return the group."""
return self._device
@property
def name(self) -> str:
"""Return the name of the device."""
return f"{self._device.friendly_name} {GROUP_SUFFIX}"
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the player."""
if self.is_volume_muted:
return MediaPlayerState.IDLE
return STREAM_STATUS.get(self._device.stream_status)
async def async_set_latency(self, latency) -> None:
"""Handle the set_latency service."""
raise ServiceValidationError("Latency can only be set for a Snapcast client.")
async def async_join(self, master) -> None:
"""Handle the join service."""
raise ServiceValidationError("Entity is not a client. Can only join clients.")
async def async_unjoin(self) -> None:
"""Handle the unjoin service."""
raise ServiceValidationError("Entity is not a client. Can only unjoin clients.")
def _async_create_group_deprecation_issue(self) -> None:
"""Create an issue for deprecated group entities."""
ir.async_create_issue(
self.hass,
DOMAIN,
"deprecated_group_entities",
breaks_in_ha_version="2026.2.0",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_group_entities",
)
async def async_select_source(self, source: str) -> None:
"""Set input source."""
# Groups are deprecated, create an issue when used
self._async_create_group_deprecation_issue()
await super().async_select_source(source)
async def async_mute_volume(self, mute: bool) -> None:
"""Send the mute command."""
# Groups are deprecated, create an issue when used
self._async_create_group_deprecation_issue()
await super().async_mute_volume(mute)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level."""
# Groups are deprecated, create an issue when used
self._async_create_group_deprecation_issue()
await super().async_set_volume_level(volume)
async def async_snapshot(self) -> None:
"""Snapshot the group state."""
# Groups are deprecated, create an issue when used
self._async_create_group_deprecation_issue()
await super().async_snapshot()
async def async_restore(self) -> None:
"""Restore the group state."""
# Groups are deprecated, create an issue when used
self._async_create_group_deprecation_issue()
await super().async_restore()
class SnapcastClientDevice(SnapcastBaseDevice):
"""Representation of a Snapcast client device."""
_device: Snapclient
_attr_supported_features = (
_SUPPORTED_FEATURES | MediaPlayerEntityFeature.GROUPING
) # Clients support grouping
@classmethod
def get_unique_id(cls, host, id) -> str:
"""Get a unique ID for a client."""
return f"{CLIENT_PREFIX}{host}_{id}"
@property
def _current_group(self) -> Snapgroup:
"""Return the group the client is associated with."""
return self._device.group
@property
def name(self) -> str:
"""Return the name of the device."""
return f"{self._device.friendly_name} {CLIENT_SUFFIX}"
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the player."""
if self._device.connected:
if self.is_volume_muted or self._current_group.muted:
return MediaPlayerState.IDLE
return STREAM_STATUS.get(self._current_group.stream_status)
return MediaPlayerState.OFF
@property
def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the state attributes."""
state_attrs = {}
if self.latency is not None:
state_attrs["latency"] = self.latency
return state_attrs
@property
def latency(self) -> float | None:
"""Latency for Client."""
return self._device.latency
async def async_set_latency(self, latency) -> None:
"""Set the latency of the client."""
await self._device.set_latency(latency)
self.async_write_ha_state()
async def async_join(self, master) -> None:
"""Join the group of the master player."""
# Action is deprecated, create an issue
self._async_create_grouping_deprecation_issue()
entity_registry = er.async_get(self.hass)
master_entity = entity_registry.async_get(master)
if master_entity is None:
raise ServiceValidationError(f"Master entity '{master}' not found.")
# Validate master entity is a client
unique_id = master_entity.unique_id
if not unique_id.startswith(CLIENT_PREFIX):
raise ServiceValidationError(
"Master is not a client device. Can only join clients."
)
# Extract the client ID and locate it's group
identifier = unique_id.split("_")[-1]
master_group = next(
group
for group in self._device.groups_available()
if identifier in group.clients
)
await master_group.add_client(self._device.identifier)
self.async_write_ha_state()
async def async_unjoin(self) -> None:
"""Unjoin the group the player is currently in."""
# Action is deprecated, create an issue
self._async_create_grouping_deprecation_issue()
await self._current_group.remove_client(self._device.identifier)
self.async_write_ha_state()
@property
def group_members(self) -> list[str] | None:
"""List of player entities which are currently grouped together for synchronous playback."""
entity_registry = er.async_get(self.hass)
return [
entity_id
for client_id in self._current_group.clients
if (
entity_id := entity_registry.async_get_entity_id(
MEDIA_PLAYER_DOMAIN,
DOMAIN,
self.get_unique_id(self.coordinator.host_id, client_id),
)
)
]
async def async_join_players(self, group_members: list[str]) -> None:
"""Add `group_members` to this client's current group."""
# Get the client entity for each group member excluding self
entity_registry = er.async_get(self.hass)
clients = [
entity
for entity_id in group_members
if (entity := entity_registry.async_get(entity_id))
and entity.unique_id != self.unique_id
]
for client in clients:
# Valid entity is a snapcast client
if not client.unique_id.startswith(CLIENT_PREFIX):
raise ServiceValidationError(
f"Entity '{client.entity_id}' is not a Snapcast client device."
)
# Extract client ID and join it to the current group
identifier = client.unique_id.split("_")[-1]
await self._current_group.add_client(identifier)
self.async_write_ha_state()
async def async_unjoin_player(self) -> None:
"""Remove this client from it's current group."""
await self._current_group.remove_client(self._device.identifier)
self.async_write_ha_state()

View File

@@ -1,24 +1,3 @@
join:
fields:
master:
required: true
selector:
entity:
integration: snapcast
domain: media_player
entity_id:
selector:
target:
entity:
integration: snapcast
domain: media_player
unjoin:
target:
entity:
integration: snapcast
domain: media_player
snapshot:
target:
entity:

View File

@@ -21,31 +21,7 @@
}
}
},
"issues": {
"deprecated_group_entities": {
"description": "Snapcast group entities are deprecated and will be removed in 2026.2. Please use the 'media_player.join' and 'media_player.unjoin' actions instead.",
"title": "Snapcast Groups Entities Deprecated"
},
"deprecated_grouping_actions": {
"description": "Actions 'snapcast.join' and 'snapcast.unjoin' are deprecated and will be removed in 2026.2. Use the 'media_player.join' and 'media_player.unjoin' actions instead.",
"title": "Snapcast Actions Deprecated"
}
},
"services": {
"join": {
"description": "Groups players together in a single group.",
"fields": {
"entity_id": {
"description": "The players to join to the \"master\".",
"name": "Entity"
},
"master": {
"description": "Entity ID of the player to synchronize to.",
"name": "Master"
}
},
"name": "Join"
},
"restore": {
"description": "Restores a previously taken snapshot of a media player.",
"name": "Restore"
@@ -63,10 +39,6 @@
"snapshot": {
"description": "Takes a snapshot of what is currently playing on a media player.",
"name": "Snapshot"
},
"unjoin": {
"description": "Removes one or more players from a group.",
"name": "Unjoin"
}
}
}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from ipaddress import IPv4Address, IPv6Address
from typing import cast
from homeassistant.components import network
from homeassistant.core import HomeAssistant
@@ -15,5 +16,8 @@ async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6A
for source_ip in await network.async_get_enabled_source_ips(hass)
if not source_ip.is_loopback
and not source_ip.is_global
and ((source_ip.version == 6 and source_ip.scope_id) or source_ip.version == 4)
and (
(source_ip.version == 6 and cast(IPv6Address, source_ip).scope_id)
or source_ip.version == 4
)
}

View File

@@ -6,9 +6,9 @@ import asyncio
from collections.abc import Callable, Coroutine, Mapping
from datetime import timedelta
from enum import Enum
from ipaddress import IPv4Address
from ipaddress import IPv4Address, IPv6Address
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from async_upnp_client.aiohttp import AiohttpSessionRequester
from async_upnp_client.const import AddressTupleVXType, DeviceOrServiceType, SsdpSource
@@ -260,6 +260,7 @@ class Scanner:
for source_ip in await async_build_source_set(self.hass):
source_ip_str = str(source_ip)
if source_ip.version == 6:
source_ip = cast(IPv6Address, source_ip)
assert source_ip.scope_id is not None
source_tuple: AddressTupleVXType = (
source_ip_str,

View File

@@ -4,10 +4,11 @@ from __future__ import annotations
import asyncio
from contextlib import ExitStack
from ipaddress import IPv6Address
import logging
import socket
from time import time
from typing import Any
from typing import Any, cast
from urllib.parse import urljoin
import xml.etree.ElementTree as ET
@@ -171,6 +172,7 @@ class Server:
for source_ip in await async_build_source_set(self.hass):
source_ip_str = str(source_ip)
if source_ip.version == 6:
source_ip = cast(IPv6Address, source_ip)
assert source_ip.scope_id is not None
source_tuple: AddressTupleVXType = (
source_ip_str,

View File

@@ -78,9 +78,9 @@
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},

View File

@@ -5,6 +5,7 @@ import logging
import switchbot
from homeassistant.components import bluetooth
from homeassistant.components.sensor import ConfigType
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ADDRESS,
@@ -16,7 +17,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
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 .const import (
CONF_ENCRYPTION_KEY,
@@ -30,6 +31,10 @@ from .const import (
SupportedModels,
)
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS_BY_TYPE = {
SupportedModels.BULB.value: [Platform.SENSOR, Platform.LIGHT],
@@ -113,6 +118,8 @@ PLATFORMS_BY_TYPE = {
Platform.BINARY_SENSOR,
Platform.BUTTON,
],
SupportedModels.KEYPAD_VISION.value: [Platform.SENSOR, Platform.BINARY_SENSOR],
SupportedModels.KEYPAD_VISION_PRO.value: [Platform.SENSOR, Platform.BINARY_SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -150,12 +157,20 @@ CLASS_BY_DEVICE = {
SupportedModels.GARAGE_DOOR_OPENER.value: switchbot.SwitchbotGarageDoorOpener,
SupportedModels.SMART_THERMOSTAT_RADIATOR.value: switchbot.SwitchbotSmartThermostatRadiator,
SupportedModels.ART_FRAME.value: switchbot.SwitchbotArtFrame,
SupportedModels.KEYPAD_VISION.value: switchbot.SwitchbotKeypadVision,
SupportedModels.KEYPAD_VISION_PRO.value: switchbot.SwitchbotKeypadVision,
}
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Switchbot Devices component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) -> bool:
"""Set up Switchbot from a config entry."""
assert entry.unique_id is not None

View File

@@ -62,6 +62,8 @@ class SupportedModels(StrEnum):
SMART_THERMOSTAT_RADIATOR = "smart_thermostat_radiator"
S20_VACUUM = "s20_vacuum"
ART_FRAME = "art_frame"
KEYPAD_VISION = "keypad_vision"
KEYPAD_VISION_PRO = "keypad_vision_pro"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -102,6 +104,8 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: SupportedModels.SMART_THERMOSTAT_RADIATOR,
SwitchbotModel.ART_FRAME: SupportedModels.ART_FRAME,
SwitchbotModel.KEYPAD_VISION: SupportedModels.KEYPAD_VISION,
SwitchbotModel.KEYPAD_VISION_PRO: SupportedModels.KEYPAD_VISION_PRO,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -142,6 +146,8 @@ ENCRYPTED_MODELS = {
SwitchbotModel.GARAGE_DOOR_OPENER,
SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
SwitchbotModel.ART_FRAME,
SwitchbotModel.KEYPAD_VISION,
SwitchbotModel.KEYPAD_VISION_PRO,
}
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
@@ -165,6 +171,8 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch,
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: switchbot.SwitchbotSmartThermostatRadiator,
SwitchbotModel.ART_FRAME: switchbot.SwitchbotArtFrame,
SwitchbotModel.KEYPAD_VISION: switchbot.SwitchbotKeypadVision,
SwitchbotModel.KEYPAD_VISION_PRO: switchbot.SwitchbotKeypadVision,
}
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {

View File

@@ -141,5 +141,10 @@
}
}
}
},
"services": {
"add_password": {
"service": "mdi:key-plus"
}
}
}

View File

@@ -0,0 +1,119 @@
"""Services for the SwitchBot integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID, CONF_SENSOR_TYPE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN, SupportedModels
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
SERVICE_ADD_PASSWORD = "add_password"
ATTR_PASSWORD = "password"
_PASSWORD_VALIDATOR = vol.All(cv.string, cv.matches_regex(r"^\d{6,12}$"))
SCHEMA_ADD_PASSWORD_SERVICE = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): cv.string,
vol.Required(ATTR_PASSWORD): _PASSWORD_VALIDATOR,
},
extra=vol.ALLOW_EXTRA,
)
@callback
def _async_get_switchbot_entry_for_device_id(
hass: HomeAssistant, device_id: str
) -> SwitchbotConfigEntry:
"""Return the loaded SwitchBot config entry for a device id."""
device_registry = dr.async_get(hass)
if not (device_entry := device_registry.async_get(device_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device_id",
translation_placeholders={"device_id": device_id},
)
entries = [
hass.config_entries.async_get_entry(entry_id)
for entry_id in device_entry.config_entries
]
switchbot_entries = [
entry for entry in entries if entry is not None and entry.domain == DOMAIN
]
if not switchbot_entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_belonging",
translation_placeholders={"device_id": device_id},
)
if not (
loaded_entry := next(
(
entry
for entry in switchbot_entries
if entry.state is ConfigEntryState.LOADED
),
None,
)
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_entry_not_loaded",
translation_placeholders={"device_id": device_id},
)
return loaded_entry
def _is_supported_keypad(entry: SwitchbotConfigEntry) -> bool:
"""Return if the entry is a supported keypad model."""
allowed_sensor_types = {
SupportedModels.KEYPAD_VISION.value,
SupportedModels.KEYPAD_VISION_PRO.value,
}
return entry.data.get(CONF_SENSOR_TYPE) in allowed_sensor_types
@callback
def _async_target(
hass: HomeAssistant, device_id: str
) -> SwitchbotDataUpdateCoordinator:
"""Return coordinator for a single target device."""
entry = _async_get_switchbot_entry_for_device_id(hass, device_id)
if not _is_supported_keypad(entry):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_keypad_vision_device",
)
return entry.runtime_data
async def async_add_password(call: ServiceCall) -> None:
"""Add a password to a SwitchBot keypad device."""
password: str = call.data[ATTR_PASSWORD]
device_id = call.data[ATTR_DEVICE_ID]
coordinator = _async_target(call.hass, device_id)
await coordinator.device.add_password(password)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the SwitchBot integration."""
hass.services.async_register(
DOMAIN,
SERVICE_ADD_PASSWORD,
async_add_password,
schema=SCHEMA_ADD_PASSWORD_SERVICE,
)

View File

@@ -0,0 +1,14 @@
add_password:
fields:
device_id:
required: true
example: "c2d01328efd261f586e56d914e3af07e"
selector:
device:
integration: switchbot
password:
required: true
example: "123456"
selector:
text:
type: password

View File

@@ -329,9 +329,24 @@
"advertising_state_error": {
"message": "{address} is not advertising state"
},
"device_entry_not_loaded": {
"message": "The device ID {device_id} is not loaded."
},
"device_not_belonging": {
"message": "The device ID {device_id} does not belong to SwitchBot integration."
},
"device_not_found_error": {
"message": "Could not find Switchbot {sensor_type} with address {address}"
},
"device_without_config_entry": {
"message": "The device ID {device_id} is not associated with a config entry."
},
"invalid_device_id": {
"message": "The device ID {device_id} is not a valid device ID."
},
"not_keypad_vision_device": {
"message": "This service is only supported for SwitchBot Keypad Vision devices."
},
"operation_error": {
"message": "An error occurred while performing the action: {error}"
},
@@ -352,5 +367,21 @@
}
}
}
},
"services": {
"add_password": {
"description": "Add a password to your keypad vision device.",
"fields": {
"device_id": {
"description": "The device ID of the keypad vision device",
"name": "Device ID"
},
"password": {
"description": "A 6 to 12 digit password",
"name": "Password"
}
},
"name": "Add password"
}
}
}

View File

@@ -165,7 +165,7 @@ def number(
attribute: str,
minimum: float | None = None,
maximum: float | None = None,
return_type: type[float] | type[int] = float,
return_type: type[float | int] = float,
**kwargs: Any,
) -> Callable[[Any], float | int | None]:
"""Convert the result to a number (float or int).

View File

@@ -0,0 +1,109 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules:
status: todo
comment: |
Multiline lambdas should be wrapped in parentheses for readability (e.g. streaming_listener).
Use chained comparison: "if 1 < x < 100" instead of "if x > 1 and x < 100".
config-flow: done
config-flow-test-coverage:
status: todo
comment: Use mock_setup_entry fixture instead of inline patch
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading:
status: todo
comment: |
async_unload_entry must clean up: (1) close TeslemetryStream websocket via
stream.close(), (2) call remove_listener() for each vehicle to unsubscribe
from stream events, (3) consider using entry.async_on_unload() during setup
to register cleanup callbacks automatically.
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: todo
comment: |
Discourage snapshot testing for state verification (e.g. test_binary_sensors_connectivity);
use concrete assertions instead. Patch devices where they're used. Use entity_registry as
test fixture. Clarify _alt and _noscope fixture purposes. Test error messages in
test_service_validation_errors.
# Gold
devices:
status: todo
comment: |
Add model id to device info. VIN sensor may be redundant (already serial number in device).
Version sensor should be sw_version in device info instead.
diagnostics: done
discovery:
status: exempt
comment: Cloud polling integration
discovery-update-info:
status: exempt
comment: Cloud polling integration
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: todo
comment: |
New vehicles/energy sites added to user's Tesla account after initial setup
are not detected. Need to periodically poll teslemetry.products() and add
new TeslemetryVehicleData/TeslemetryEnergyData to runtime_data, then trigger
entity creation via coordinator listeners in each platform.
entity-category: done
entity-device-class:
status: todo
comment: |
DRIVE_INVERTER_STATES has "unavailable" as a state value which conflicts with HA's
unavailable state - shows duplicate in state trigger UI.
entity-disabled-by-default: done
entity-translations: done
exception-translations:
status: todo
comment: |
ConfigEntryAuthFailed and UpdateFailed exceptions can have translated messages.
Also one "unknown error" that cannot be translated.
icon-translations:
status: todo
comment: |
number.py:299 uses _attr_icon = icon_for_battery_level() instead of
range-based icons in icons.json. Affects backup_reserve_percent and
off_grid_vehicle_charging_reserve_percent entities. Remove the dynamic
icon assignment and add range-based icon entries to icons.json.
reconfiguration-flow:
status: todo
comment: |
Reconfiguring has value even with OAuth - allows user to trigger reauth themselves
(e.g. after logging out of all devices).
repair-issues:
status: exempt
comment: No issues to repair
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -181,6 +181,9 @@
"charge_state_charging_state": {
"default": "mdi:ev-station"
},
"charge_state_energy_remaining": {
"default": "mdi:battery-medium"
},
"charge_state_minutes_to_full_charge": {
"default": "mdi:clock-end"
},

View File

@@ -131,6 +131,14 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
suggested_display_precision=1,
entity_registry_enabled_default=False,
),
TessieSensorEntityDescription(
key="charge_state_energy_remaining",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
),
TessieSensorEntityDescription(
key="drive_state_speed",
state_class=SensorStateClass.MEASUREMENT,

View File

@@ -360,6 +360,9 @@
"stopped": "[%key:common::state::stopped%]"
}
},
"charge_state_energy_remaining": {
"name": "Energy remaining"
},
"charge_state_est_battery_range": {
"name": "Battery range estimate"
},

View File

@@ -8,9 +8,7 @@ from typing import Any
import uuid
from todoist_api_python.api_async import TodoistAPIAsync
from todoist_api_python.endpoints import get_sync_url
from todoist_api_python.headers import create_headers
from todoist_api_python.models import Due, Label, Task
from todoist_api_python.models import Label, Project, Task
import voluptuous as vol
from homeassistant.components.calendar import (
@@ -62,8 +60,9 @@ from .const import (
START,
SUMMARY,
)
from .coordinator import TodoistCoordinator
from .coordinator import TodoistCoordinator, flatten_async_pages
from .types import CalData, CustomProject, ProjectData, TodoistEvent
from .util import parse_due_date
_LOGGER = logging.getLogger(__name__)
@@ -157,18 +156,22 @@ async def async_setup_platform(
# Setup devices:
# Grab all projects.
projects = await api.get_projects()
projects_result = await api.get_projects()
all_projects: list[Project] = await flatten_async_pages(projects_result)
# Grab all labels
labels = await api.get_labels()
labels_result = await api.get_labels()
all_labels: list[Label] = await flatten_async_pages(labels_result)
# Add all Todoist-defined projects.
project_devices = []
for project in projects:
for project in all_projects:
# Project is an object, not a dict!
# Because of that, we convert what we need to a dict.
project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id}
project_devices.append(TodoistProjectEntity(coordinator, project_data, labels))
project_devices.append(
TodoistProjectEntity(coordinator, project_data, all_labels)
)
# Cache the names so we can easily look up name->ID.
project_id_lookup[project.name.lower()] = project.id
@@ -196,7 +199,7 @@ async def async_setup_platform(
TodoistProjectEntity(
coordinator,
{"id": None, "name": extra_project["name"]},
labels,
all_labels,
due_date_days=project_due_date,
whitelisted_labels=project_label_filter,
whitelisted_projects=project_id_filter,
@@ -218,7 +221,7 @@ def async_register_services( # noqa: C901
session = async_get_clientsession(hass)
async def handle_new_task(call: ServiceCall) -> None: # noqa: C901
async def handle_new_task(call: ServiceCall) -> None:
"""Call when a user creates a new Todoist Task from Home Assistant."""
project_name = call.data[PROJECT_NAME]
projects = await coordinator.async_get_projects()
@@ -269,9 +272,10 @@ def async_register_services( # noqa: C901
data["labels"] = task_labels
if ASSIGNEE in call.data:
collaborators = await coordinator.api.get_collaborators(project_id)
collaborators_result = await coordinator.api.get_collaborators(project_id)
all_collaborators = await flatten_async_pages(collaborators_result)
collaborator_id_lookup = {
collab.name.lower(): collab.id for collab in collaborators
collab.name.lower(): collab.id for collab in all_collaborators
}
task_assignee = call.data[ASSIGNEE].lower()
if task_assignee in collaborator_id_lookup:
@@ -297,17 +301,14 @@ def async_register_services( # noqa: C901
if due is None:
raise ValueError(f"Invalid due_date: {call.data[DUE_DATE]}")
due_date = datetime(due.year, due.month, due.day)
# Format it in the manner Todoist expects
due_date = dt_util.as_utc(due_date)
date_format = "%Y-%m-%dT%H:%M:%S"
data["due_datetime"] = datetime.strftime(due_date, date_format)
# Pass the datetime object directly - the library handles formatting
data["due_datetime"] = dt_util.as_utc(due_date)
api_task = await coordinator.api.add_task(content, **data)
# @NOTE: The rest-api doesn't support reminders, this works manually using
# the sync api, in order to keep functional parity with the component.
# https://developer.todoist.com/sync/v9/#reminders
sync_url = get_sync_url("sync")
# The REST API doesn't support reminders, so we use the Sync API directly
# to maintain functional parity with the component.
# https://developer.todoist.com/api/v1/#tag/Sync/Reminders/Add-a-reminder
_reminder_due: dict = {}
if REMINDER_DATE_STRING in call.data:
_reminder_due["string"] = call.data[REMINDER_DATE_STRING]
@@ -316,20 +317,21 @@ def async_register_services( # noqa: C901
_reminder_due["lang"] = call.data[REMINDER_DATE_LANG]
if REMINDER_DATE in call.data:
due_date = dt_util.parse_datetime(call.data[REMINDER_DATE])
if due_date is None:
due = dt_util.parse_date(call.data[REMINDER_DATE])
if due is None:
reminder_date = dt_util.parse_datetime(call.data[REMINDER_DATE])
if reminder_date is None:
reminder = dt_util.parse_date(call.data[REMINDER_DATE])
if reminder is None:
raise ValueError(
f"Invalid reminder_date: {call.data[REMINDER_DATE]}"
)
due_date = datetime(due.year, due.month, due.day)
# Format it in the manner Todoist expects
due_date = dt_util.as_utc(due_date)
date_format = "%Y-%m-%dT%H:%M:%S"
_reminder_due["date"] = datetime.strftime(due_date, date_format)
reminder_date = datetime(reminder.year, reminder.month, reminder.day)
# Format it in the manner Todoist expects (UTC with Z suffix)
reminder_date = dt_util.as_utc(reminder_date)
date_format = "%Y-%m-%dT%H:%M:%S.000000Z"
_reminder_due["date"] = datetime.strftime(reminder_date, date_format)
async def add_reminder(reminder_due: dict):
if _reminder_due:
sync_url = "https://api.todoist.com/api/v1/sync"
reminder_data = {
"commands": [
{
@@ -339,16 +341,16 @@ def async_register_services( # noqa: C901
"args": {
"item_id": api_task.id,
"type": "absolute",
"due": reminder_due,
"due": _reminder_due,
},
}
]
}
headers = create_headers(token=coordinator.token, with_content=True)
return await session.post(sync_url, headers=headers, json=reminder_data)
if _reminder_due:
await add_reminder(_reminder_due)
headers = {
"Authorization": f"Bearer {coordinator.token}",
"Content-Type": "application/json",
}
await session.post(sync_url, headers=headers, json=reminder_data)
_LOGGER.debug("Created Todoist task: %s", call.data[CONTENT])
@@ -527,7 +529,7 @@ class TodoistProjectData:
"""
task: TodoistEvent = {
ALL_DAY: False,
COMPLETED: data.is_completed,
COMPLETED: data.completed_at is not None,
DESCRIPTION: f"https://todoist.com/showTask?id={data.id}",
DUE_TODAY: False,
END: None,
@@ -561,22 +563,26 @@ class TodoistProjectData:
# complete the task.
# Generally speaking, that means right now.
if data.due is not None:
end = dt_util.parse_datetime(
data.due.datetime if data.due.datetime else data.due.date
)
task[END] = dt_util.as_local(end) if end is not None else end
if task[END] is not None:
if self._due_date_days is not None and (
task[END] > dt_util.now() + self._due_date_days
):
# This task is out of range of our due date;
# it shouldn't be counted.
return None
due_date = data.due.date
# The API returns date or datetime objects when deserialized via from_dict()
if isinstance(due_date, datetime):
task[END] = dt_util.as_local(due_date)
elif isinstance(due_date, date):
task[END] = dt_util.start_of_local_day(due_date)
task[DUE_TODAY] = task[END].date() == dt_util.now().date()
if (end_dt := task[END]) is not None:
if self._due_date_days is not None:
# For comparison with now, use datetime
if end_dt > dt_util.now() + self._due_date_days:
# This task is out of range of our due date;
# it shouldn't be counted.
return None
task[DUE_TODAY] = end_dt.date() == dt_util.now().date()
# Special case: Task is overdue.
if task[END] <= task[START]:
if end_dt <= task[START]:
task[OVERDUE] = True
# Set end time to the current time plus 1 hour.
# We're pretty much guaranteed to update within that 1 hour,
@@ -681,7 +687,7 @@ class TodoistProjectData:
for task in project_task_data:
if task.due is None:
continue
start = get_start(task.due)
start = parse_due_date(task.due)
if start is None:
continue
event = CalendarEvent(
@@ -689,9 +695,15 @@ class TodoistProjectData:
start=start,
end=start + timedelta(days=1),
)
if event.start_datetime_local >= end_date:
if (
event.start_datetime_local is not None
and event.start_datetime_local >= end_date
):
continue
if event.end_datetime_local < start_date:
if (
event.end_datetime_local is not None
and event.end_datetime_local < start_date
):
continue
events.append(event)
return events
@@ -748,15 +760,3 @@ class TodoistProjectData:
return
self.event = event
_LOGGER.debug("Updated %s", self._name)
def get_start(due: Due) -> datetime | date | None:
"""Return the task due date as a start date or date time."""
if due.datetime:
start = dt_util.parse_datetime(due.datetime)
if not start:
return None
return dt_util.as_local(start)
if due.date:
return dt_util.parse_date(due.date)
return None

View File

@@ -1,7 +1,9 @@
"""DataUpdateCoordinator for the Todoist component."""
from collections.abc import AsyncGenerator
from datetime import timedelta
import logging
from typing import TypeVar
from todoist_api_python.api_async import TodoistAPIAsync
from todoist_api_python.models import Label, Project, Section, Task
@@ -10,6 +12,18 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
T = TypeVar("T")
async def flatten_async_pages(
pages: AsyncGenerator[list[T]],
) -> list[T]:
"""Flatten paginated results from an async generator."""
all_items: list[T] = []
async for page in pages:
all_items.extend(page)
return all_items
class TodoistCoordinator(DataUpdateCoordinator[list[Task]]):
"""Coordinator for updating task data from Todoist."""
@@ -39,22 +53,26 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]):
async def _async_update_data(self) -> list[Task]:
"""Fetch tasks from the Todoist API."""
try:
return await self.api.get_tasks()
tasks_async = await self.api.get_tasks()
except Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
return await flatten_async_pages(tasks_async)
async def async_get_projects(self) -> list[Project]:
"""Return todoist projects fetched at most once."""
if self._projects is None:
self._projects = await self.api.get_projects()
projects_async = await self.api.get_projects()
self._projects = await flatten_async_pages(projects_async)
return self._projects
async def async_get_sections(self, project_id: str) -> list[Section]:
"""Return todoist sections for a given project ID."""
return await self.api.get_sections(project_id=project_id)
sections_async = await self.api.get_sections(project_id=project_id)
return await flatten_async_pages(sections_async)
async def async_get_labels(self) -> list[Label]:
"""Return todoist labels fetched at most once."""
if self._labels is None:
self._labels = await self.api.get_labels()
labels_async = await self.api.get_labels()
self._labels = await flatten_async_pages(labels_async)
return self._labels

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