mirror of
https://github.com/home-assistant/core.git
synced 2026-04-08 15:52:28 +02:00
Compare commits
136 Commits
noisy_ha_s
...
edenhaus-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d20568e83c | ||
|
|
0871bf13a4 | ||
|
|
4c44d2f4d9 | ||
|
|
833a8be2d1 | ||
|
|
f8113ae80b | ||
|
|
feff5355c8 | ||
|
|
6fbee5c2e3 | ||
|
|
8200c234dd | ||
|
|
dfd86d56ec | ||
|
|
93162f6b65 | ||
|
|
93ea88f3de | ||
|
|
ca48b07858 | ||
|
|
795e01512a | ||
|
|
36857b4b20 | ||
|
|
8432b6a790 | ||
|
|
e02a6f2f19 | ||
|
|
6b45b0f522 | ||
|
|
c35ec1f12b | ||
|
|
bb7e1d4723 | ||
|
|
2305cb0131 | ||
|
|
253293c986 | ||
|
|
1040fe50ec | ||
|
|
6a012498a5 | ||
|
|
74c2060c49 | ||
|
|
177fff3ff0 | ||
|
|
e7fadcda7b | ||
|
|
91c53e9c52 | ||
|
|
bd1c66984f | ||
|
|
704777444c | ||
|
|
c28a6a867d | ||
|
|
4bfc96c02b | ||
|
|
faac51d219 | ||
|
|
d9cd62bf65 | ||
|
|
6007629293 | ||
|
|
426e9846d9 | ||
|
|
935db1308f | ||
|
|
597540b611 | ||
|
|
e0b030c892 | ||
|
|
da9b3dc68b | ||
|
|
23b79b2f39 | ||
|
|
b9a0d553ab | ||
|
|
c4f0d9d2fa | ||
|
|
78338f161f | ||
|
|
aaafdee56f | ||
|
|
7068986c14 | ||
|
|
32ee31b8c7 | ||
|
|
50c12d4487 | ||
|
|
2427b77363 | ||
|
|
fa9613a879 | ||
|
|
145e02769c | ||
|
|
c151696357 | ||
|
|
cbcd1929dd | ||
|
|
7a9836064d | ||
|
|
3155c1cd4f | ||
|
|
28c38e92d4 | ||
|
|
9c3b9eee2a | ||
|
|
def50b255d | ||
|
|
aa7694e81c | ||
|
|
a722912e05 | ||
|
|
a09213bce8 | ||
|
|
0abaaa0a06 | ||
|
|
363b88407c | ||
|
|
4a4458ec5b | ||
|
|
b3379e1921 | ||
|
|
09e5fbb258 | ||
|
|
b758dc202f | ||
|
|
c5f75bc135 | ||
|
|
a904df5bc2 | ||
|
|
1978e94aaa | ||
|
|
28dbf6e3dc | ||
|
|
ef989160af | ||
|
|
4071eb76c7 | ||
|
|
ac723161c1 | ||
|
|
94884d33db | ||
|
|
64994277b1 | ||
|
|
8abf822d92 | ||
|
|
6e6f10c085 | ||
|
|
1c0768dd78 | ||
|
|
c888502671 | ||
|
|
58af3545f4 | ||
|
|
d669dd45cf | ||
|
|
05a5b8cdf0 | ||
|
|
33b6d0a45f | ||
|
|
fba11d8016 | ||
|
|
314834b4eb | ||
|
|
46a8325556 | ||
|
|
86622cd29d | ||
|
|
c91a1d0fce | ||
|
|
778a2891ce | ||
|
|
560c719b0f | ||
|
|
d5ab86edbf | ||
|
|
6aeb7f36f6 | ||
|
|
f6308368b0 | ||
|
|
c0e8f14745 | ||
|
|
0488012c77 | ||
|
|
f247183e11 | ||
|
|
c662b94d06 | ||
|
|
ee4bf165b5 | ||
|
|
92ac396d19 | ||
|
|
03366038ce | ||
|
|
0b91aa9202 | ||
|
|
ffc4fa1c2a | ||
|
|
15e03957a9 | ||
|
|
0be881bca6 | ||
|
|
e88b321741 | ||
|
|
0c4cb27fe9 | ||
|
|
1639163c2e | ||
|
|
f043404cd9 | ||
|
|
018651ff1d | ||
|
|
704d7a037c | ||
|
|
7336178e03 | ||
|
|
1c16fb8e42 | ||
|
|
3ab2cd3fb7 | ||
|
|
5057343b6a | ||
|
|
6c3e85fd5e | ||
|
|
f046456445 | ||
|
|
e81a08916a | ||
|
|
85d2e3d006 | ||
|
|
936b0b32ed | ||
|
|
0d511c697c | ||
|
|
5bfe034b4d | ||
|
|
cf786b3b04 | ||
|
|
0f9f090db2 | ||
|
|
302eea7418 | ||
|
|
b5e1f7e03e | ||
|
|
02397a8d2d | ||
|
|
ea9437eab2 | ||
|
|
aaea30bee0 | ||
|
|
9c869fa701 | ||
|
|
5106548f2c | ||
|
|
506d485c0d | ||
|
|
da190ec96f | ||
|
|
9567929484 | ||
|
|
dc16494332 | ||
|
|
933f422588 | ||
|
|
663d0691a7 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -653,7 +653,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4.5.0
|
||||
uses: actions/dependency-review-action@v4.6.0
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
|
||||
@@ -364,6 +364,7 @@ homeassistant.components.notify.*
|
||||
homeassistant.components.notion.*
|
||||
homeassistant.components.number.*
|
||||
homeassistant.components.nut.*
|
||||
homeassistant.components.ohme.*
|
||||
homeassistant.components.onboarding.*
|
||||
homeassistant.components.oncue.*
|
||||
homeassistant.components.onedrive.*
|
||||
|
||||
15
Dockerfile
generated
15
Dockerfile
generated
@@ -14,21 +14,8 @@ ARG QEMU_CPU
|
||||
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
# Needs to be redefined inside the FROM statement to be set for RUN commands
|
||||
ARG BUILD_ARCH
|
||||
# Get go2rtc binary
|
||||
RUN \
|
||||
case "${BUILD_ARCH}" in \
|
||||
"aarch64") go2rtc_suffix='arm64' ;; \
|
||||
"armhf") go2rtc_suffix='armv6' ;; \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
COPY --from=ghcr.io/alexxit/go2rtc:1.9.9 /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.6.10
|
||||
|
||||
5
homeassistant/brands/eve.json
Normal file
5
homeassistant/brands/eve.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "eve",
|
||||
"name": "Eve",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
||||
@@ -16,8 +16,8 @@
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"city": "City",
|
||||
"country": "Country",
|
||||
"state": "State"
|
||||
"state": "State",
|
||||
"country": "[%key:common::config_flow::data::country%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -56,12 +56,12 @@
|
||||
"sensor": {
|
||||
"pollutant_label": {
|
||||
"state": {
|
||||
"co": "Carbon monoxide",
|
||||
"n2": "Nitrogen dioxide",
|
||||
"o3": "Ozone",
|
||||
"p1": "PM10",
|
||||
"p2": "PM2.5",
|
||||
"s2": "Sulfur dioxide"
|
||||
"co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
|
||||
"n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
|
||||
"o3": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"p1": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"p2": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
|
||||
}
|
||||
},
|
||||
"pollutant_level": {
|
||||
|
||||
@@ -60,7 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Optional("message"): str,
|
||||
vol.Optional("media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): vol.Any(str, None),
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("message", "media_id"),
|
||||
@@ -75,7 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Optional("start_message"): str,
|
||||
vol.Optional("start_media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): vol.Any(str, None),
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("extra_system_prompt"): str,
|
||||
}
|
||||
),
|
||||
|
||||
@@ -180,7 +180,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self,
|
||||
message: str | None = None,
|
||||
media_id: str | None = None,
|
||||
preannounce_media_id: str | None = PREANNOUNCE_URL,
|
||||
preannounce: bool = True,
|
||||
preannounce_media_id: str = PREANNOUNCE_URL,
|
||||
) -> None:
|
||||
"""Play and show an announcement on the satellite.
|
||||
|
||||
@@ -190,8 +191,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
If media_id is provided, it is played directly. It is possible
|
||||
to omit the message and the satellite will not show any text.
|
||||
|
||||
If preannounce is True, a sound is played before the announcement.
|
||||
If preannounce_media_id is provided, it overrides the default sound.
|
||||
If preannounce_media_id is None, no sound is played.
|
||||
|
||||
Calls async_announce with message and media id.
|
||||
"""
|
||||
@@ -201,7 +202,9 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
message = ""
|
||||
|
||||
announcement = await self._resolve_announcement_media_id(
|
||||
message, media_id, preannounce_media_id
|
||||
message,
|
||||
media_id,
|
||||
preannounce_media_id=preannounce_media_id if preannounce else None,
|
||||
)
|
||||
|
||||
if self._is_announcing:
|
||||
@@ -229,7 +232,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
start_message: str | None = None,
|
||||
start_media_id: str | None = None,
|
||||
extra_system_prompt: str | None = None,
|
||||
preannounce_media_id: str | None = PREANNOUNCE_URL,
|
||||
preannounce: bool = True,
|
||||
preannounce_media_id: str = PREANNOUNCE_URL,
|
||||
) -> None:
|
||||
"""Start a conversation from the satellite.
|
||||
|
||||
@@ -239,8 +243,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
If start_media_id is provided, it is played directly. It is possible
|
||||
to omit the message and the satellite will not show any text.
|
||||
|
||||
If preannounce_media_id is provided, it is played before the announcement.
|
||||
If preannounce_media_id is None, no sound is played.
|
||||
If preannounce is True, a sound is played before the start message or media.
|
||||
If preannounce_media_id is provided, it overrides the default sound.
|
||||
|
||||
Calls async_start_conversation.
|
||||
"""
|
||||
@@ -257,7 +261,9 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
start_message = ""
|
||||
|
||||
announcement = await self._resolve_announcement_media_id(
|
||||
start_message, start_media_id, preannounce_media_id
|
||||
start_message,
|
||||
start_media_id,
|
||||
preannounce_media_id=preannounce_media_id if preannounce else None,
|
||||
)
|
||||
|
||||
if self._is_announcing:
|
||||
|
||||
@@ -15,6 +15,11 @@ announce:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
@@ -40,6 +45,11 @@ start_conversation:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
|
||||
@@ -24,9 +24,13 @@
|
||||
"name": "Media ID",
|
||||
"description": "The media ID to announce instead of using text-to-speech."
|
||||
},
|
||||
"preannounce": {
|
||||
"name": "Preannounce",
|
||||
"description": "Play a sound before the announcement."
|
||||
},
|
||||
"preannounce_media_id": {
|
||||
"name": "Preannounce Media ID",
|
||||
"description": "The media ID to play before the announcement."
|
||||
"name": "Preannounce media ID",
|
||||
"description": "Custom media ID to play before the announcement."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -46,9 +50,13 @@
|
||||
"name": "Extra system prompt",
|
||||
"description": "Provide background information to the AI about the request."
|
||||
},
|
||||
"preannounce": {
|
||||
"name": "Preannounce",
|
||||
"description": "Play a sound before the start message or media."
|
||||
},
|
||||
"preannounce_media_id": {
|
||||
"name": "Preannounce Media ID",
|
||||
"description": "The media ID to play before the start message or media."
|
||||
"name": "Preannounce media ID",
|
||||
"description": "Custom media ID to play before the start message or media."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ async def websocket_test_connection(
|
||||
hass.async_create_background_task(
|
||||
satellite.async_internal_announce(
|
||||
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}",
|
||||
preannounce_media_id=None,
|
||||
preannounce=False,
|
||||
),
|
||||
f"assist_satellite_connection_test_{msg['entity_id']}",
|
||||
)
|
||||
|
||||
@@ -175,7 +175,8 @@ class AzureStorageBackupAgent(BackupAgent):
|
||||
"""Find a blob by backup id."""
|
||||
async for blob in self._client.list_blobs(include="metadata"):
|
||||
if (
|
||||
backup_id == blob.metadata.get("backup_id", "")
|
||||
blob.metadata is not None
|
||||
and backup_id == blob.metadata.get("backup_id", "")
|
||||
and blob.metadata.get("metadata_version") == METADATA_VERSION
|
||||
):
|
||||
return blob
|
||||
|
||||
@@ -501,18 +501,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
return
|
||||
|
||||
# presets and inputs might have the same name; presets have priority
|
||||
url: str | None = None
|
||||
for input_ in self._inputs:
|
||||
if input_.text == source:
|
||||
url = input_.url
|
||||
await self._player.play_url(input_.url)
|
||||
return
|
||||
for preset in self._presets:
|
||||
if preset.name == source:
|
||||
url = preset.url
|
||||
await self._player.load_preset(preset.id)
|
||||
return
|
||||
|
||||
if url is None:
|
||||
raise ServiceValidationError(f"Source {source} not found")
|
||||
|
||||
await self._player.play_url(url)
|
||||
raise ServiceValidationError(f"Source {source} not found")
|
||||
|
||||
async def async_clear_playlist(self) -> None:
|
||||
"""Clear players playlist."""
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"bleak-retry-connector==3.9.0",
|
||||
"bluetooth-adapters==0.21.4",
|
||||
"bluetooth-auto-recovery==1.4.5",
|
||||
"bluetooth-data-tools==1.26.1",
|
||||
"bluetooth-data-tools==1.26.5",
|
||||
"dbus-fast==2.43.0",
|
||||
"habluetooth==3.37.0"
|
||||
]
|
||||
|
||||
@@ -2,17 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Literal, cast
|
||||
|
||||
with suppress(Exception):
|
||||
# TurboJPEG imports numpy which may or may not work so
|
||||
# we have to guard the import here. We still want
|
||||
# to import it at top level so it gets loaded
|
||||
# in the import executor and not in the event loop.
|
||||
from turbojpeg import TurboJPEG
|
||||
|
||||
from turbojpeg import TurboJPEG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import Image
|
||||
|
||||
@@ -98,13 +98,13 @@
|
||||
"name": "Preset",
|
||||
"state": {
|
||||
"none": "None",
|
||||
"eco": "Eco",
|
||||
"away": "Away",
|
||||
"home": "[%key:common::state::home%]",
|
||||
"away": "[%key:common::state::not_home%]",
|
||||
"activity": "Activity",
|
||||
"boost": "Boost",
|
||||
"comfort": "Comfort",
|
||||
"home": "[%key:common::state::home%]",
|
||||
"sleep": "Sleep",
|
||||
"activity": "Activity"
|
||||
"eco": "Eco",
|
||||
"sleep": "Sleep"
|
||||
}
|
||||
},
|
||||
"preset_modes": {
|
||||
@@ -257,7 +257,7 @@
|
||||
"selector": {
|
||||
"hvac_mode": {
|
||||
"options": {
|
||||
"off": "Off",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"auto": "Auto",
|
||||
"cool": "Cool",
|
||||
"dry": "Dry",
|
||||
|
||||
@@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
|
||||
flow_id=flow_id, user_input=tokens
|
||||
)
|
||||
|
||||
self.hass.async_create_task(await_tokens())
|
||||
# It's a background task because it should be cancelled on shutdown and there's nothing else
|
||||
# we can do in such case. There's also no need to wait for this during setup.
|
||||
self.hass.async_create_background_task(
|
||||
await_tokens(), name="Awaiting OAuth tokens"
|
||||
)
|
||||
|
||||
return authorize_url
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@
|
||||
"sensor": {
|
||||
"zone_status": {
|
||||
"state": {
|
||||
"open": "[%key:common::state::open%]",
|
||||
"alarm": "Alarm",
|
||||
"armed": "Armed",
|
||||
"open": "Open",
|
||||
"excluded": "Excluded",
|
||||
"faulty": "Faulty",
|
||||
"inhibited": "Inhibited",
|
||||
|
||||
@@ -354,6 +354,35 @@ class ChatLog:
|
||||
if self.delta_listener:
|
||||
self.delta_listener(self, asdict(tool_result))
|
||||
|
||||
async def _async_expand_prompt_template(
|
||||
self,
|
||||
llm_context: llm.LLMContext,
|
||||
prompt: str,
|
||||
language: str,
|
||||
user_name: str | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
return template.Template(prompt, self.hass).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
"user_name": user_name,
|
||||
"llm_context": llm_context,
|
||||
},
|
||||
parse_result=False,
|
||||
)
|
||||
except TemplateError as err:
|
||||
LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response = intent.IntentResponse(language=language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Sorry, I had a problem with my template",
|
||||
)
|
||||
raise ConverseError(
|
||||
"Error rendering prompt",
|
||||
conversation_id=self.conversation_id,
|
||||
response=intent_response,
|
||||
) from err
|
||||
|
||||
async def async_update_llm_data(
|
||||
self,
|
||||
conversing_domain: str,
|
||||
@@ -409,38 +438,28 @@ class ChatLog:
|
||||
):
|
||||
user_name = user.name
|
||||
|
||||
try:
|
||||
prompt_parts = [
|
||||
template.Template(
|
||||
llm.BASE_PROMPT
|
||||
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||
self.hass,
|
||||
).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
"user_name": user_name,
|
||||
"llm_context": llm_context,
|
||||
},
|
||||
parse_result=False,
|
||||
)
|
||||
]
|
||||
|
||||
except TemplateError as err:
|
||||
LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Sorry, I had a problem with my template",
|
||||
prompt_parts = []
|
||||
prompt_parts.append(
|
||||
await self._async_expand_prompt_template(
|
||||
llm_context,
|
||||
(user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||
user_input.language,
|
||||
user_name,
|
||||
)
|
||||
raise ConverseError(
|
||||
"Error rendering prompt",
|
||||
conversation_id=self.conversation_id,
|
||||
response=intent_response,
|
||||
) from err
|
||||
)
|
||||
|
||||
if llm_api:
|
||||
prompt_parts.append(llm_api.api_prompt)
|
||||
|
||||
prompt_parts.append(
|
||||
await self._async_expand_prompt_template(
|
||||
llm_context,
|
||||
llm.BASE_PROMPT,
|
||||
user_input.language,
|
||||
user_name,
|
||||
)
|
||||
)
|
||||
|
||||
if extra_system_prompt := (
|
||||
# Take new system prompt if one was given
|
||||
user_input.extra_system_prompt or self.extra_system_prompt
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls",
|
||||
"description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"],
|
||||
"requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["async-upnp-client==0.43.0"],
|
||||
"requirements": ["async-upnp-client==0.44.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
"protect_mode": {
|
||||
"name": "Protect mode",
|
||||
"state": {
|
||||
"away": "Away",
|
||||
"home": "Home",
|
||||
"away": "[%key:common::state::not_home%]",
|
||||
"home": "[%key:common::state::home%]",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,15 +91,15 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
|
||||
def operation_list(self) -> list[str]:
|
||||
"""List of available operation modes."""
|
||||
econet_modes = self.water_heater.modes
|
||||
op_list = []
|
||||
operation_modes = set()
|
||||
for mode in econet_modes:
|
||||
if (
|
||||
mode is not WaterHeaterOperationMode.UNKNOWN
|
||||
and mode is not WaterHeaterOperationMode.VACATION
|
||||
):
|
||||
ha_mode = ECONET_STATE_TO_HA[mode]
|
||||
op_list.append(ha_mode)
|
||||
return op_list
|
||||
operation_modes.add(ha_mode)
|
||||
return list(operation_modes)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> WaterHeaterEntityFeature:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.4.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"]
|
||||
}
|
||||
|
||||
@@ -100,7 +100,11 @@ class ElkEntity(Entity):
|
||||
return {"index": self._element.index + 1}
|
||||
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
pass
|
||||
"""Handle changes to the element.
|
||||
|
||||
This method is called when the element changes. It should be
|
||||
overridden by subclasses to handle the changes.
|
||||
"""
|
||||
|
||||
@callback
|
||||
def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
@@ -111,7 +115,7 @@ class ElkEntity(Entity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callback for ElkM1 changes and update entity state."""
|
||||
self._element.add_callback(self._element_callback)
|
||||
self._element_callback(self._element, {})
|
||||
self._element_changed(self._element, {})
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
|
||||
@@ -25,6 +25,7 @@ from homeassistant.core import (
|
||||
split_entity_id,
|
||||
valid_entity_id,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
@@ -122,6 +123,10 @@ SOURCE_ADAPTERS: Final = (
|
||||
)
|
||||
|
||||
|
||||
class EntityNotFoundError(HomeAssistantError):
|
||||
"""When a referenced entity was not found."""
|
||||
|
||||
|
||||
class SensorManager:
|
||||
"""Class to handle creation/removal of sensor data."""
|
||||
|
||||
@@ -311,43 +316,25 @@ class EnergyCostSensor(SensorEntity):
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
# Determine energy price
|
||||
if self._config["entity_energy_price"] is not None:
|
||||
energy_price_state = self.hass.states.get(
|
||||
self._config["entity_energy_price"]
|
||||
try:
|
||||
energy_price, energy_price_unit = self._get_energy_price(
|
||||
valid_units, default_price_unit
|
||||
)
|
||||
|
||||
if energy_price_state is None:
|
||||
return
|
||||
|
||||
try:
|
||||
energy_price = float(energy_price_state.state)
|
||||
except ValueError:
|
||||
if self._last_energy_sensor_state is None:
|
||||
# Initialize as it's the first time all required entities except
|
||||
# price are in place. This means that the cost will update the first
|
||||
# time the energy is updated after the price entity is in place.
|
||||
self._reset(energy_state)
|
||||
return
|
||||
|
||||
energy_price_unit: str | None = energy_price_state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT, ""
|
||||
).partition("/")[2]
|
||||
|
||||
# For backwards compatibility we don't validate the unit of the price
|
||||
# If it is not valid, we assume it's our default price unit.
|
||||
if energy_price_unit not in valid_units:
|
||||
energy_price_unit = default_price_unit
|
||||
|
||||
else:
|
||||
energy_price = cast(float, self._config["number_energy_price"])
|
||||
energy_price_unit = default_price_unit
|
||||
except EntityNotFoundError:
|
||||
return
|
||||
except ValueError:
|
||||
energy_price = None
|
||||
|
||||
if self._last_energy_sensor_state is None:
|
||||
# Initialize as it's the first time all required entities are in place.
|
||||
# Initialize as it's the first time all required entities are in place or
|
||||
# only the price is missing. In the later case, cost will update the first
|
||||
# time the energy is updated after the price entity is in place.
|
||||
self._reset(energy_state)
|
||||
return
|
||||
|
||||
if energy_price is None:
|
||||
return
|
||||
|
||||
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
if energy_unit is None or energy_unit not in valid_units:
|
||||
@@ -383,20 +370,9 @@ class EnergyCostSensor(SensorEntity):
|
||||
old_energy_value = float(self._last_energy_sensor_state.state)
|
||||
cur_value = cast(float, self._attr_native_value)
|
||||
|
||||
if energy_price_unit is None:
|
||||
converted_energy_price = energy_price
|
||||
else:
|
||||
converter: Callable[[float, str, str], float]
|
||||
if energy_unit in VALID_ENERGY_UNITS:
|
||||
converter = unit_conversion.EnergyConverter.convert
|
||||
else:
|
||||
converter = unit_conversion.VolumeConverter.convert
|
||||
|
||||
converted_energy_price = converter(
|
||||
energy_price,
|
||||
energy_unit,
|
||||
energy_price_unit,
|
||||
)
|
||||
converted_energy_price = self._convert_energy_price(
|
||||
energy_price, energy_price_unit, energy_unit
|
||||
)
|
||||
|
||||
self._attr_native_value = (
|
||||
cur_value + (energy - old_energy_value) * converted_energy_price
|
||||
@@ -404,6 +380,52 @@ class EnergyCostSensor(SensorEntity):
|
||||
|
||||
self._last_energy_sensor_state = energy_state
|
||||
|
||||
def _get_energy_price(
|
||||
self, valid_units: set[str], default_unit: str | None
|
||||
) -> tuple[float, str | None]:
|
||||
"""Get the energy price.
|
||||
|
||||
Raises:
|
||||
EntityNotFoundError: When the energy price entity is not found.
|
||||
ValueError: When the entity state is not a valid float.
|
||||
|
||||
"""
|
||||
|
||||
if self._config["entity_energy_price"] is None:
|
||||
return cast(float, self._config["number_energy_price"]), default_unit
|
||||
|
||||
energy_price_state = self.hass.states.get(self._config["entity_energy_price"])
|
||||
if energy_price_state is None:
|
||||
raise EntityNotFoundError
|
||||
|
||||
energy_price = float(energy_price_state.state)
|
||||
|
||||
energy_price_unit: str | None = energy_price_state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT, ""
|
||||
).partition("/")[2]
|
||||
|
||||
# For backwards compatibility we don't validate the unit of the price
|
||||
# If it is not valid, we assume it's our default price unit.
|
||||
if energy_price_unit not in valid_units:
|
||||
energy_price_unit = default_unit
|
||||
|
||||
return energy_price, energy_price_unit
|
||||
|
||||
def _convert_energy_price(
|
||||
self, energy_price: float, energy_price_unit: str | None, energy_unit: str
|
||||
) -> float:
|
||||
"""Convert the energy price to the correct unit."""
|
||||
if energy_price_unit is None:
|
||||
return energy_price
|
||||
|
||||
converter: Callable[[float, str, str], float]
|
||||
if energy_unit in VALID_ENERGY_UNITS:
|
||||
converter = unit_conversion.EnergyConverter.convert
|
||||
else:
|
||||
converter = unit_conversion.VolumeConverter.convert
|
||||
|
||||
return converter(energy_price, energy_unit, energy_price_unit)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key])
|
||||
|
||||
@@ -128,8 +128,23 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._password = ""
|
||||
return await self._async_authenticate_or_add()
|
||||
|
||||
if error is None and entry_data.get(CONF_NOISE_PSK):
|
||||
return await self.async_step_reauth_encryption_removed_confirm()
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_encryption_removed_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthorization flow when encryption was removed."""
|
||||
if user_input is not None:
|
||||
self._noise_psk = None
|
||||
return self._async_get_entry()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_encryption_removed_confirm",
|
||||
description_placeholders={"name": self._name},
|
||||
)
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -13,6 +13,7 @@ from aioesphomeapi import (
|
||||
APIConnectionError,
|
||||
APIVersion,
|
||||
DeviceInfo as EsphomeDeviceInfo,
|
||||
EncryptionHelloAPIError,
|
||||
EntityInfo,
|
||||
HomeassistantServiceCall,
|
||||
InvalidAuthAPIError,
|
||||
@@ -570,6 +571,7 @@ class ESPHomeManager:
|
||||
if isinstance(
|
||||
err,
|
||||
(
|
||||
EncryptionHelloAPIError,
|
||||
RequiresEncryptionAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
InvalidAuthAPIError,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"requirements": [
|
||||
"aioesphomeapi==29.7.0",
|
||||
"aioesphomeapi==29.8.0",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==2.12.0"
|
||||
],
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
"description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your device configuration."
|
||||
},
|
||||
"reauth_encryption_removed_confirm": {
|
||||
"description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to add the ESPHome node `{name}` to Home Assistant?",
|
||||
"title": "Discovered ESPHome node"
|
||||
|
||||
@@ -238,6 +238,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
|
||||
key="link_noise_margin_sent",
|
||||
translation_key="link_noise_margin_sent",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=_retrieve_link_noise_margin_sent_state,
|
||||
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
|
||||
),
|
||||
@@ -245,6 +247,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
|
||||
key="link_noise_margin_received",
|
||||
translation_key="link_noise_margin_received",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=_retrieve_link_noise_margin_received_state,
|
||||
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
|
||||
),
|
||||
@@ -252,6 +256,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
|
||||
key="link_attenuation_sent",
|
||||
translation_key="link_attenuation_sent",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=_retrieve_link_attenuation_sent_state,
|
||||
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
|
||||
),
|
||||
@@ -259,6 +265,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
|
||||
key="link_attenuation_received",
|
||||
translation_key="link_attenuation_received",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=_retrieve_link_attenuation_received_state,
|
||||
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
|
||||
),
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250328.0"]
|
||||
"requirements": ["home-assistant-frontend==20250401.0"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"common": {
|
||||
"data_description_password": "The Remote Admin password from the Fully Kiosk Browser app settings.",
|
||||
"data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?",
|
||||
"data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates."
|
||||
"data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates."
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
|
||||
@@ -539,10 +539,14 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
return
|
||||
|
||||
assert self._cur_temp is not None and self._target_temp is not None
|
||||
too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance
|
||||
too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance
|
||||
|
||||
min_temp = self._target_temp - self._cold_tolerance
|
||||
max_temp = self._target_temp + self._hot_tolerance
|
||||
|
||||
if self._is_device_active:
|
||||
if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
|
||||
if (self.ac_mode and self._cur_temp <= min_temp) or (
|
||||
not self.ac_mode and self._cur_temp >= max_temp
|
||||
):
|
||||
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_off()
|
||||
elif time is not None:
|
||||
@@ -552,7 +556,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
self.heater_entity_id,
|
||||
)
|
||||
await self._async_heater_turn_on()
|
||||
elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
|
||||
elif (self.ac_mode and self._cur_temp > max_temp) or (
|
||||
not self.ac_mode and self._cur_temp < min_temp
|
||||
):
|
||||
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_on()
|
||||
elif time is not None:
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
"heater": "Switch entity used to cool or heat depending on A/C mode.",
|
||||
"target_sensor": "Temperature sensor that reflects the current temperature.",
|
||||
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
|
||||
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.",
|
||||
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
|
||||
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5."
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"title": "Temperature presets",
|
||||
"data": {
|
||||
"away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
|
||||
"home_temp": "[%key:common::state::home%]",
|
||||
"away_temp": "[%key:common::state::not_home%]",
|
||||
"comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
|
||||
"eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
|
||||
"home_temp": "[%key:common::state::home%]",
|
||||
"sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
|
||||
"activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]"
|
||||
}
|
||||
@@ -63,10 +63,10 @@
|
||||
"presets": {
|
||||
"title": "[%key:component::generic_thermostat::config::step::presets::title%]",
|
||||
"data": {
|
||||
"away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
|
||||
"home_temp": "[%key:common::state::home%]",
|
||||
"away_temp": "[%key:common::state::not_home%]",
|
||||
"comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
|
||||
"eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
|
||||
"home_temp": "[%key:common::state::home%]",
|
||||
"sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
|
||||
"activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"requirements": [
|
||||
"google-cloud-texttospeech==2.17.2",
|
||||
"google-cloud-speech==2.27.0"
|
||||
"google-cloud-texttospeech==2.25.1",
|
||||
"google-cloud-speech==2.31.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -356,6 +356,15 @@ class GoogleGenerativeAIConversationEntity(
|
||||
|
||||
messages.append(_convert_content(chat_content))
|
||||
|
||||
# The SDK requires the first message to be a user message
|
||||
# This is not the case if user used `start_conversation`
|
||||
# Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537
|
||||
if messages and messages[0].role != "user":
|
||||
messages.insert(
|
||||
0,
|
||||
Content(role="user", parts=[Part.from_text(text=" ")]),
|
||||
)
|
||||
|
||||
if tool_results:
|
||||
messages.append(_create_google_tool_response_content(tool_results))
|
||||
generateContentConfig = GenerateContentConfig(
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"fix_menu": {
|
||||
"description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.",
|
||||
"menu_options": {
|
||||
"addon_execute_start": "Start",
|
||||
"addon_disable_boot": "Disable"
|
||||
"addon_execute_start": "[%key:common::action::start%]",
|
||||
"addon_disable_boot": "[%key:common::action::disable%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -511,7 +511,7 @@
|
||||
},
|
||||
"spin_speed": {
|
||||
"options": {
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "Off",
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm",
|
||||
@@ -521,7 +521,7 @@
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "Off",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_low": "Low",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_high": "High"
|
||||
@@ -529,7 +529,7 @@
|
||||
},
|
||||
"vario_perfect": {
|
||||
"options": {
|
||||
"laundry_care_common_enum_type_vario_perfect_off": "Off",
|
||||
"laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]",
|
||||
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect",
|
||||
"laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect"
|
||||
}
|
||||
@@ -1494,7 +1494,7 @@
|
||||
"spin_speed": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]",
|
||||
"state": {
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]",
|
||||
@@ -1504,7 +1504,7 @@
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]"
|
||||
@@ -1513,7 +1513,7 @@
|
||||
"vario_perfect": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]",
|
||||
"state": {
|
||||
"laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]",
|
||||
"laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]",
|
||||
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]",
|
||||
"laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ from .util import (
|
||||
OwningIntegration,
|
||||
get_otbr_addon_manager,
|
||||
get_zigbee_flasher_addon_manager,
|
||||
guess_firmware_info,
|
||||
guess_hardware_owners,
|
||||
probe_silabs_firmware_info,
|
||||
)
|
||||
@@ -511,6 +512,16 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm a discovery."""
|
||||
assert self._device is not None
|
||||
fw_info = await guess_firmware_info(self.hass, self._device)
|
||||
|
||||
# If our guess for the firmware type is actually running, we can save the user
|
||||
# an unnecessary confirmation and silently confirm the flow
|
||||
for owner in fw_info.owners:
|
||||
if await owner.is_running(self.hass):
|
||||
self._probed_firmware_info = fw_info
|
||||
return self._async_flow_finished()
|
||||
|
||||
return await self.async_step_pick_firmware()
|
||||
|
||||
|
||||
|
||||
@@ -95,8 +95,7 @@ class BaseFirmwareUpdateEntity(
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
# Until this entity can be associated with a device, we must manually name it
|
||||
_attr_has_entity_name = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -195,10 +194,6 @@ class BaseFirmwareUpdateEntity(
|
||||
|
||||
def _update_attributes(self) -> None:
|
||||
"""Recompute the attributes of the entity."""
|
||||
|
||||
# This entity is not currently associated with a device so we must manually
|
||||
# give it a name
|
||||
self._attr_name = f"{self._config_entry.title} Update"
|
||||
self._attr_title = self.entity_description.firmware_name or "Unknown"
|
||||
|
||||
if (
|
||||
|
||||
@@ -3,19 +3,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os.path
|
||||
|
||||
from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
|
||||
from homeassistant.components.usb import (
|
||||
USBDevice,
|
||||
async_register_port_event_callback,
|
||||
scan_serial_ports,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DESCRIPTION, DEVICE, FIRMWARE, FIRMWARE_VERSION, PRODUCT
|
||||
from .const import (
|
||||
DESCRIPTION,
|
||||
DEVICE,
|
||||
DOMAIN,
|
||||
FIRMWARE,
|
||||
FIRMWARE_VERSION,
|
||||
MANUFACTURER,
|
||||
PID,
|
||||
PRODUCT,
|
||||
SERIAL_NUMBER,
|
||||
VID,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the ZBT-1 integration."""
|
||||
|
||||
@callback
|
||||
def async_port_event_callback(
|
||||
added: set[USBDevice], removed: set[USBDevice]
|
||||
) -> None:
|
||||
"""Handle USB port events."""
|
||||
current_entries_by_path = {
|
||||
entry.data[DEVICE]: entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
}
|
||||
|
||||
for device in added | removed:
|
||||
path = device.device
|
||||
entry = current_entries_by_path.get(path)
|
||||
|
||||
if entry is not None:
|
||||
_LOGGER.debug(
|
||||
"Device %r has changed state, reloading config entry %s",
|
||||
path,
|
||||
entry,
|
||||
)
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
async_register_port_event_callback(hass, async_port_event_callback)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a Home Assistant SkyConnect config entry."""
|
||||
|
||||
# Postpone loading the config entry if the device is missing
|
||||
device_path = entry.data[DEVICE]
|
||||
if not await hass.async_add_executor_job(os.path.exists, device_path):
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_disconnected",
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, ["update"])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -29,7 +91,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
"""Migrate old entry."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migrating from version %s:%s", config_entry.version, config_entry.minor_version
|
||||
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
|
||||
)
|
||||
|
||||
if config_entry.version == 1:
|
||||
@@ -64,6 +126,43 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
minor_version=3,
|
||||
)
|
||||
|
||||
if config_entry.minor_version == 3:
|
||||
# Old SkyConnect config entries were missing keys
|
||||
if any(
|
||||
key not in config_entry.data
|
||||
for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER)
|
||||
):
|
||||
serial_ports = await hass.async_add_executor_job(scan_serial_ports)
|
||||
serial_ports_info = {port.device: port for port in serial_ports}
|
||||
device = config_entry.data[DEVICE]
|
||||
|
||||
if not (usb_info := serial_ports_info.get(device)):
|
||||
raise HomeAssistantError(
|
||||
f"USB device {device} is missing, cannot migrate"
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={
|
||||
**config_entry.data,
|
||||
VID: usb_info.vid,
|
||||
PID: usb_info.pid,
|
||||
MANUFACTURER: usb_info.manufacturer,
|
||||
PRODUCT: usb_info.description,
|
||||
DESCRIPTION: usb_info.description,
|
||||
SERIAL_NUMBER: usb_info.serial_number,
|
||||
},
|
||||
version=1,
|
||||
minor_version=4,
|
||||
)
|
||||
else:
|
||||
# Existing entries are migrated by just incrementing the version
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
version=1,
|
||||
minor_version=4,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s.%s successful",
|
||||
config_entry.version,
|
||||
|
||||
@@ -81,7 +81,7 @@ class HomeAssistantSkyConnectConfigFlow(
|
||||
"""Handle a config flow for Home Assistant SkyConnect."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 4
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initialize the config flow."""
|
||||
|
||||
@@ -195,5 +195,10 @@
|
||||
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
|
||||
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"device_disconnected": {
|
||||
"message": "The device is not plugged in"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,6 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""SkyConnect firmware update entity."""
|
||||
|
||||
bootloader_reset_type = None
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"firmware": {
|
||||
"radio_firmware": {
|
||||
"name": "Radio firmware"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
] = {
|
||||
ApplicationType.EZSP: FirmwareUpdateEntityDescription(
|
||||
key="radio_firmware",
|
||||
translation_key="radio_firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
@@ -55,6 +56,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
),
|
||||
ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
|
||||
key="radio_firmware",
|
||||
translation_key="radio_firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
@@ -65,7 +67,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
firmware_name="OpenThread RCP",
|
||||
),
|
||||
ApplicationType.CPC: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
key="radio_firmware",
|
||||
translation_key="radio_firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
@@ -76,7 +79,8 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
firmware_name="Multiprotocol",
|
||||
),
|
||||
ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
key="radio_firmware",
|
||||
translation_key="radio_firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
@@ -88,6 +92,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
),
|
||||
None: FirmwareUpdateEntityDescription(
|
||||
key="radio_firmware",
|
||||
translation_key="radio_firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
@@ -168,7 +173,6 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Yellow firmware update entity."""
|
||||
|
||||
bootloader_reset_type = "yellow" # Triggers a GPIO reset
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
|
||||
200
homeassistant/components/homee/climate.py
Normal file
200
homeassistant/components/homee/climate.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""The Homee climate platform."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyHomee.const import AttributeType, NodeProfile
|
||||
from pyHomee.model import HomeeNode
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_TEMPERATURE,
|
||||
PRESET_BOOST,
|
||||
PRESET_ECO,
|
||||
PRESET_NONE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL
|
||||
from .entity import HomeeNodeEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
ROOM_THERMOSTATS = {
|
||||
NodeProfile.ROOM_THERMOSTAT,
|
||||
NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR,
|
||||
NodeProfile.WIFI_ROOM_THERMOSTAT,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeeConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the Homee platform for the climate component."""
|
||||
|
||||
async_add_devices(
|
||||
HomeeClimate(node, config_entry)
|
||||
for node in config_entry.runtime_data.nodes
|
||||
if node.profile in CLIMATE_PROFILES
|
||||
)
|
||||
|
||||
|
||||
class HomeeClimate(HomeeNodeEntity, ClimateEntity):
|
||||
"""Representation of a Homee climate entity."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
|
||||
"""Initialize a Homee climate entity."""
|
||||
super().__init__(node, entry)
|
||||
|
||||
(
|
||||
self._attr_supported_features,
|
||||
self._attr_hvac_modes,
|
||||
self._attr_preset_modes,
|
||||
) = get_climate_features(self._node)
|
||||
|
||||
self._target_temp = self._node.get_attribute_by_type(
|
||||
AttributeType.TARGET_TEMPERATURE
|
||||
)
|
||||
assert self._target_temp is not None
|
||||
self._attr_temperature_unit = str(HOMEE_UNIT_TO_HA_UNIT[self._target_temp.unit])
|
||||
self._attr_target_temperature_step = self._target_temp.step_value
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-{self._target_temp.id}"
|
||||
|
||||
self._heating_mode = self._node.get_attribute_by_type(
|
||||
AttributeType.HEATING_MODE
|
||||
)
|
||||
self._temperature = self._node.get_attribute_by_type(AttributeType.TEMPERATURE)
|
||||
self._valve_position = self._node.get_attribute_by_type(
|
||||
AttributeType.CURRENT_VALVE_POSITION
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return the hvac operation mode."""
|
||||
if ClimateEntityFeature.TURN_OFF in self.supported_features and (
|
||||
self._heating_mode is not None
|
||||
):
|
||||
if self._heating_mode.current_value == 0:
|
||||
return HVACMode.OFF
|
||||
|
||||
return HVACMode.HEAT
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction:
|
||||
"""Return the hvac action."""
|
||||
if self._heating_mode is not None and self._heating_mode.current_value == 0:
|
||||
return HVACAction.OFF
|
||||
|
||||
if (
|
||||
self._valve_position is not None and self._valve_position.current_value == 0
|
||||
) or (
|
||||
self._temperature is not None
|
||||
and self._temperature.current_value >= self.target_temperature
|
||||
):
|
||||
return HVACAction.IDLE
|
||||
|
||||
return HVACAction.HEATING
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str:
|
||||
"""Return the present preset mode."""
|
||||
if (
|
||||
ClimateEntityFeature.PRESET_MODE in self.supported_features
|
||||
and self._heating_mode is not None
|
||||
and self._heating_mode.current_value > 0
|
||||
):
|
||||
assert self._attr_preset_modes is not None
|
||||
return self._attr_preset_modes[int(self._heating_mode.current_value) - 1]
|
||||
|
||||
return PRESET_NONE
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self._temperature is not None:
|
||||
return self._temperature.current_value
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
"""Return the temperature we try to reach."""
|
||||
assert self._target_temp is not None
|
||||
return self._target_temp.current_value
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the lowest settable target temperature."""
|
||||
assert self._target_temp is not None
|
||||
return self._target_temp.minimum
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the lowest settable target temperature."""
|
||||
assert self._target_temp is not None
|
||||
return self._target_temp.maximum
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
# Currently only HEAT and OFF are supported.
|
||||
assert self._heating_mode is not None
|
||||
await self.async_set_homee_value(
|
||||
self._heating_mode, float(hvac_mode == HVACMode.HEAT)
|
||||
)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
assert self._heating_mode is not None and self._attr_preset_modes is not None
|
||||
await self.async_set_homee_value(
|
||||
self._heating_mode, self._attr_preset_modes.index(preset_mode) + 1
|
||||
)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
assert self._target_temp is not None
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
await self.async_set_homee_value(
|
||||
self._target_temp, kwargs[ATTR_TEMPERATURE]
|
||||
)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
assert self._heating_mode is not None
|
||||
await self.async_set_homee_value(self._heating_mode, 1)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
assert self._heating_mode is not None
|
||||
await self.async_set_homee_value(self._heating_mode, 0)
|
||||
|
||||
|
||||
def get_climate_features(
|
||||
node: HomeeNode,
|
||||
) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]:
|
||||
"""Determine supported climate features of a node based on the available attributes."""
|
||||
features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
hvac_modes = [HVACMode.HEAT]
|
||||
preset_modes: list[str] = []
|
||||
|
||||
if (
|
||||
attribute := node.get_attribute_by_type(AttributeType.HEATING_MODE)
|
||||
) is not None:
|
||||
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
hvac_modes.append(HVACMode.OFF)
|
||||
|
||||
if attribute.maximum > 1:
|
||||
# Node supports more modes than off and heating.
|
||||
features |= ClimateEntityFeature.PRESET_MODE
|
||||
preset_modes.extend([PRESET_ECO, PRESET_BOOST, PRESET_MANUAL])
|
||||
|
||||
if len(preset_modes) > 0:
|
||||
preset_modes.insert(0, PRESET_NONE)
|
||||
return (features, hvac_modes, preset_modes if len(preset_modes) > 0 else None)
|
||||
@@ -95,3 +95,6 @@ LIGHT_PROFILES = [
|
||||
NodeProfile.WIFI_DIMMABLE_LIGHT,
|
||||
NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH,
|
||||
]
|
||||
|
||||
# Climate Presets
|
||||
PRESET_MANUAL = "manual"
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"entity": {
|
||||
"climate": {
|
||||
"homee": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"manual": "mdi:hand-back-left"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"brightness": {
|
||||
"default": "mdi:brightness-5"
|
||||
|
||||
@@ -131,6 +131,17 @@
|
||||
"name": "Ventilate"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"homee": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"manual": "Manual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"light_instance": {
|
||||
"name": "Light {instance}"
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"hold": "Hold",
|
||||
"away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
|
||||
"away": "[%key:common::state::not_home%]",
|
||||
"none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Final
|
||||
|
||||
from aiohttp import hdrs
|
||||
from aiohttp.web import Application, Request, StreamResponse, middleware
|
||||
from aiohttp.web_exceptions import HTTPException
|
||||
from multidict import CIMultiDict, istr
|
||||
|
||||
from homeassistant.core import callback
|
||||
|
||||
REFERRER_POLICY: Final[istr] = istr("Referrer-Policy")
|
||||
X_CONTENT_TYPE_OPTIONS: Final[istr] = istr("X-Content-Type-Options")
|
||||
X_FRAME_OPTIONS: Final[istr] = istr("X-Frame-Options")
|
||||
|
||||
|
||||
@callback
|
||||
def setup_headers(app: Application, use_x_frame_options: bool) -> None:
|
||||
"""Create headers middleware for the app."""
|
||||
|
||||
added_headers = {
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Server": "", # Empty server header, to prevent aiohttp of setting one.
|
||||
}
|
||||
added_headers = CIMultiDict(
|
||||
{
|
||||
REFERRER_POLICY: "no-referrer",
|
||||
X_CONTENT_TYPE_OPTIONS: "nosniff",
|
||||
hdrs.SERVER: "", # Empty server header, to prevent aiohttp of setting one.
|
||||
}
|
||||
)
|
||||
|
||||
if use_x_frame_options:
|
||||
added_headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
added_headers[X_FRAME_OPTIONS] = "SAMEORIGIN"
|
||||
|
||||
@middleware
|
||||
async def headers_middleware(
|
||||
|
||||
@@ -197,5 +197,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_effect_none": {
|
||||
"title": "Light turned on with deprecated effect",
|
||||
"description": "A light was turned on with the deprecated effect `None`. This has been replaced with `off`. Please update any automations, scenes, or scripts that use this effect."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.components.light import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from ..bridge import HueBridge
|
||||
@@ -44,6 +45,9 @@ FALLBACK_MIN_KELVIN = 6500
|
||||
FALLBACK_MAX_KELVIN = 2000
|
||||
FALLBACK_KELVIN = 5800 # halfway
|
||||
|
||||
# HA 2025.4 replaced the deprecated effect "None" with HA default "off"
|
||||
DEPRECATED_EFFECT_NONE = "None"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -233,6 +237,23 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
self._color_temp_active = color_temp is not None
|
||||
flash = kwargs.get(ATTR_FLASH)
|
||||
effect = effect_str = kwargs.get(ATTR_EFFECT)
|
||||
if effect_str == DEPRECATED_EFFECT_NONE:
|
||||
# deprecated effect "None" is now "off"
|
||||
effect_str = EFFECT_OFF
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"deprecated_effect_none",
|
||||
breaks_in_ha_version="2025.10.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_effect_none",
|
||||
)
|
||||
self.logger.warning(
|
||||
"Detected deprecated effect 'None' in %s, use 'off' instead. "
|
||||
"This will stop working in HA 2025.10",
|
||||
self.entity_id,
|
||||
)
|
||||
if effect_str == EFFECT_OFF:
|
||||
# ignore effect if set to "off" and we have no effect active
|
||||
# the special effect "off" is only used to stop an active effect
|
||||
|
||||
@@ -63,14 +63,14 @@
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"normal": "Normal",
|
||||
"eco": "Eco",
|
||||
"away": "Away",
|
||||
"home": "[%key:common::state::home%]",
|
||||
"away": "[%key:common::state::not_home%]",
|
||||
"auto": "Auto",
|
||||
"baby": "Baby",
|
||||
"boost": "Boost",
|
||||
"comfort": "Comfort",
|
||||
"home": "[%key:common::state::home%]",
|
||||
"sleep": "Sleep",
|
||||
"auto": "Auto",
|
||||
"baby": "Baby"
|
||||
"eco": "Eco",
|
||||
"sleep": "Sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,9 +227,9 @@ async def async_unload_entry(
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
isy_data = hass.data[DOMAIN][entry.entry_id]
|
||||
isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
isy: ISY = isy_data.root
|
||||
isy = isy_data.root
|
||||
|
||||
_LOGGER.debug("ISY Stopping Event Stream and automatic updates")
|
||||
isy.websocket.stop()
|
||||
|
||||
@@ -181,6 +181,7 @@ class ISYProgramEntity(ISYEntity):
|
||||
|
||||
_actions: Program
|
||||
_status: Program
|
||||
_node: Program
|
||||
|
||||
def __init__(self, name: str, status: Program, actions: Program = None) -> None:
|
||||
"""Initialize the ISY program-based entity."""
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyisy"],
|
||||
"requirements": ["pyisy==3.2.0"],
|
||||
"requirements": ["pyisy==3.4.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Universal Devices Inc.",
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.helpers.service import entity_service_call
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .models import IsyData
|
||||
|
||||
# Common Services for All Platforms:
|
||||
SERVICE_SEND_PROGRAM_COMMAND = "send_program_command"
|
||||
@@ -149,7 +150,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
isy_name = service.data.get(CONF_ISY)
|
||||
|
||||
for config_entry_id in hass.data[DOMAIN]:
|
||||
isy_data = hass.data[DOMAIN][config_entry_id]
|
||||
isy_data: IsyData = hass.data[DOMAIN][config_entry_id]
|
||||
isy = isy_data.root
|
||||
if isy_name and isy_name != isy.conf["name"]:
|
||||
continue
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"get_zwave_parameter": {
|
||||
"name": "Get Z-Wave Parameter",
|
||||
"description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
|
||||
"description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
|
||||
"fields": {
|
||||
"parameter": {
|
||||
"name": "Parameter",
|
||||
@@ -100,7 +100,7 @@
|
||||
},
|
||||
"set_zwave_parameter": {
|
||||
"name": "Set Z-Wave parameter",
|
||||
"description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
|
||||
"description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
|
||||
"fields": {
|
||||
"parameter": {
|
||||
"name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]",
|
||||
|
||||
@@ -157,7 +157,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity):
|
||||
device_info=device_info,
|
||||
)
|
||||
self._attr_name = description.name # Override super
|
||||
self._change_handler: EventListener = None
|
||||
self._change_handler: EventListener | None = None
|
||||
|
||||
# pylint: disable-next=hass-missing-super-call
|
||||
async def async_added_to_hass(self) -> None:
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
"prebrew_infusion_select": {
|
||||
"name": "Prebrew/-infusion mode",
|
||||
"state": {
|
||||
"disabled": "Disabled",
|
||||
"disabled": "[%key:common::state::disabled%]",
|
||||
"prebrew": "Prebrew",
|
||||
"prebrew_enabled": "Prebrew",
|
||||
"preinfusion": "Preinfusion"
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bluetooth-data-tools==1.26.1", "ld2410-ble==0.1.1"]
|
||||
"requirements": ["bluetooth-data-tools==1.26.5", "ld2410-ble==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/led_ble",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["bluetooth-data-tools==1.26.1", "led-ble==1.1.6"]
|
||||
"requirements": ["bluetooth-data-tools==1.26.5", "led-ble==1.1.6"]
|
||||
}
|
||||
|
||||
@@ -87,11 +87,11 @@
|
||||
"state": {
|
||||
"available": "Available",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"connected": "Connected",
|
||||
"connected": "[%key:common::state::connected%]",
|
||||
"error": "Error",
|
||||
"locked": "Locked",
|
||||
"locked": "[%key:common::state::locked%]",
|
||||
"need_auth": "Waiting for authentication",
|
||||
"paused": "Paused",
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
"paused_by_scheduler": "Paused by scheduler",
|
||||
"updating_firmware": "Updating firmware"
|
||||
}
|
||||
|
||||
@@ -63,10 +63,12 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
# Add a callback to handle core config update.
|
||||
self.unit_system: str | None = None
|
||||
self.hass.bus.async_listen(
|
||||
event_type=EVENT_CORE_CONFIG_UPDATE,
|
||||
listener=self._handle_update_config,
|
||||
event_filter=self.async_config_update_filter,
|
||||
self.config_entry.async_on_unload(
|
||||
self.hass.bus.async_listen(
|
||||
event_type=EVENT_CORE_CONFIG_UPDATE,
|
||||
listener=self._handle_update_config,
|
||||
event_filter=self.async_config_update_filter,
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_update_config(self, _: Event) -> None:
|
||||
|
||||
@@ -169,6 +169,9 @@
|
||||
"current_job_mode": {
|
||||
"default": "mdi:format-list-bulleted"
|
||||
},
|
||||
"current_job_mode_dehumidifier": {
|
||||
"default": "mdi:format-list-bulleted"
|
||||
},
|
||||
"operation_mode": {
|
||||
"default": "mdi:gesture-tap-button"
|
||||
},
|
||||
|
||||
@@ -98,7 +98,13 @@ DEVICE_TYPE_SELECT_MAP: dict[DeviceType, tuple[SelectEntityDescription, ...]] =
|
||||
AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],
|
||||
SELECT_DESC[ThinQProperty.CURRENT_JOB_MODE],
|
||||
),
|
||||
DeviceType.DEHUMIDIFIER: (AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],),
|
||||
DeviceType.DEHUMIDIFIER: (
|
||||
AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],
|
||||
SelectEntityDescription(
|
||||
key=ThinQProperty.CURRENT_JOB_MODE,
|
||||
translation_key="current_job_mode_dehumidifier",
|
||||
),
|
||||
),
|
||||
DeviceType.DISH_WASHER: (
|
||||
OPERATION_SELECT_DESC[ThinQProperty.DISH_WASHER_OPERATION_MODE],
|
||||
),
|
||||
|
||||
@@ -928,6 +928,17 @@
|
||||
"vacation": "Vacation"
|
||||
}
|
||||
},
|
||||
"current_job_mode_dehumidifier": {
|
||||
"name": "[%key:component::lg_thinq::entity::sensor::current_job_mode::name%]",
|
||||
"state": {
|
||||
"air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]",
|
||||
"clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]",
|
||||
"intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]",
|
||||
"quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]",
|
||||
"rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]",
|
||||
"smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]"
|
||||
}
|
||||
},
|
||||
"operation_mode": {
|
||||
"name": "Operation",
|
||||
"state": {
|
||||
|
||||
@@ -293,11 +293,10 @@ turn_on:
|
||||
- light.LightEntityFeature.FLASH
|
||||
selector:
|
||||
select:
|
||||
translation_key: flash
|
||||
options:
|
||||
- label: "Long"
|
||||
value: "long"
|
||||
- label: "Short"
|
||||
value: "short"
|
||||
- long
|
||||
- short
|
||||
|
||||
turn_off:
|
||||
target:
|
||||
|
||||
@@ -283,6 +283,12 @@
|
||||
"yellow": "Yellow",
|
||||
"yellowgreen": "Yellow green"
|
||||
}
|
||||
},
|
||||
"flash": {
|
||||
"options": {
|
||||
"short": "Short",
|
||||
"long": "Long"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
from sqlalchemy.engine.row import Row
|
||||
@@ -114,6 +114,7 @@ DATA_POS: Final = 11
|
||||
CONTEXT_POS: Final = 12
|
||||
|
||||
|
||||
@final # Final to allow direct checking of the type instead of using isinstance
|
||||
class EventAsRow(NamedTuple):
|
||||
"""Convert an event to a row.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import frontend, websocket_api
|
||||
from homeassistant.components import frontend, onboarding, websocket_api
|
||||
from homeassistant.config import (
|
||||
async_hass_config_yaml,
|
||||
async_process_component_and_handle_errors,
|
||||
@@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import collection, config_validation as cv
|
||||
from homeassistant.helpers.frame import report_usage
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_integration
|
||||
from homeassistant.util import slugify
|
||||
@@ -282,6 +283,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
STORAGE_DASHBOARD_UPDATE_FIELDS,
|
||||
).async_setup(hass)
|
||||
|
||||
def create_map_dashboard() -> None:
|
||||
"""Create a map dashboard."""
|
||||
hass.async_create_task(_create_map_dashboard(hass, dashboards_collection))
|
||||
|
||||
if not onboarding.async_is_onboarded(hass):
|
||||
onboarding.async_add_listener(hass, create_map_dashboard)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -323,3 +331,25 @@ def _register_panel(
|
||||
kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON)
|
||||
|
||||
frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs)
|
||||
|
||||
|
||||
async def _create_map_dashboard(
|
||||
hass: HomeAssistant, dashboards_collection: dashboard.DashboardsCollection
|
||||
) -> None:
|
||||
"""Create a map dashboard."""
|
||||
translations = await async_get_translations(
|
||||
hass, hass.config.language, "dashboard", {onboarding.DOMAIN}
|
||||
)
|
||||
title = translations["component.onboarding.dashboard.map.title"]
|
||||
|
||||
await dashboards_collection.async_create_item(
|
||||
{
|
||||
CONF_ALLOW_SINGLE_WORD: True,
|
||||
CONF_ICON: "mdi:map",
|
||||
CONF_TITLE: title,
|
||||
CONF_URL_PATH: "map",
|
||||
}
|
||||
)
|
||||
|
||||
map_store = hass.data[LOVELACE_DATA].dashboards["map"]
|
||||
await map_store.async_save({"strategy": {"type": "map"}})
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, llm
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ModelContextProtocolCoordinator
|
||||
from .application_credentials import authorization_server_context
|
||||
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
|
||||
from .coordinator import ModelContextProtocolCoordinator, TokenManager
|
||||
from .types import ModelContextProtocolConfigEntry
|
||||
|
||||
__all__ = [
|
||||
@@ -20,11 +23,45 @@ __all__ = [
|
||||
API_PROMPT = "The following tools are available from a remote server named {name}."
|
||||
|
||||
|
||||
async def async_get_config_entry_implementation(
|
||||
hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
|
||||
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation | None:
|
||||
"""OAuth implementation for the config entry."""
|
||||
if "auth_implementation" not in entry.data:
|
||||
return None
|
||||
with authorization_server_context(
|
||||
AuthorizationServer(
|
||||
authorize_url=entry.data[CONF_AUTHORIZATION_URL],
|
||||
token_url=entry.data[CONF_TOKEN_URL],
|
||||
)
|
||||
):
|
||||
return await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
|
||||
|
||||
async def _create_token_manager(
|
||||
hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
|
||||
) -> TokenManager | None:
|
||||
"""Create a OAuth token manager for the config entry if the server requires authentication."""
|
||||
if not (implementation := await async_get_config_entry_implementation(hass, entry)):
|
||||
return None
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
async def token_manager() -> str:
|
||||
await session.async_ensure_token_valid()
|
||||
return cast(str, session.token[CONF_ACCESS_TOKEN])
|
||||
|
||||
return token_manager
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Model Context Protocol from a config entry."""
|
||||
coordinator = ModelContextProtocolCoordinator(hass, entry)
|
||||
token_manager = await _create_token_manager(hass, entry)
|
||||
coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
unsub = llm.async_register_api(
|
||||
|
||||
35
homeassistant/components/mcp/application_credentials.py
Normal file
35
homeassistant/components/mcp/application_credentials.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Application credentials platform for Model Context Protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
import contextvars
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
CONF_ACTIVE_AUTHORIZATION_SERVER = "active_authorization_server"
|
||||
|
||||
_mcp_context: contextvars.ContextVar[AuthorizationServer] = contextvars.ContextVar(
|
||||
"mcp_authorization_server_context"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def authorization_server_context(
|
||||
authorization_server: AuthorizationServer,
|
||||
) -> Generator[None]:
|
||||
"""Context manager for setting the active authorization server."""
|
||||
token = _mcp_context.set(authorization_server)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_mcp_context.reset(token)
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server, for the default auth implementation."""
|
||||
if _mcp_context.get() is None:
|
||||
raise RuntimeError("No MCP authorization server set in context")
|
||||
return _mcp_context.get()
|
||||
@@ -2,20 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
AbstractOAuth2FlowHandler,
|
||||
async_get_implementations,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import mcp_client
|
||||
from . import async_get_config_entry_implementation
|
||||
from .application_credentials import authorization_server_context
|
||||
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
|
||||
from .coordinator import TokenManager, mcp_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,8 +34,62 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
# OAuth server discovery endpoint for rfc8414
|
||||
OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server"
|
||||
MCP_DISCOVERY_HEADERS = {
|
||||
"MCP-Protocol-Version": "2025-03-26",
|
||||
}
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
async def async_discover_oauth_config(
|
||||
hass: HomeAssistant, mcp_server_url: str
|
||||
) -> AuthorizationServer:
|
||||
"""Discover the OAuth configuration for the MCP server.
|
||||
|
||||
This implements the functionality in the MCP spec for discovery. If the MCP server URL
|
||||
is https://api.example.com/v1/mcp, then:
|
||||
- The authorization base URL is https://api.example.com
|
||||
- The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server
|
||||
- For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses
|
||||
default paths relative to the authorization base URL.
|
||||
"""
|
||||
parsed_url = URL(mcp_server_url)
|
||||
discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT))
|
||||
try:
|
||||
async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client:
|
||||
response = await client.get(discovery_endpoint)
|
||||
response.raise_for_status()
|
||||
except httpx.TimeoutException as error:
|
||||
_LOGGER.info("Timeout connecting to MCP server: %s", error)
|
||||
raise TimeoutConnectError from error
|
||||
except httpx.HTTPStatusError as error:
|
||||
if error.response.status_code == 404:
|
||||
_LOGGER.info("Authorization Server Metadata not found, using default paths")
|
||||
return AuthorizationServer(
|
||||
authorize_url=str(parsed_url.with_path("/authorize")),
|
||||
token_url=str(parsed_url.with_path("/token")),
|
||||
)
|
||||
raise CannotConnect from error
|
||||
except httpx.HTTPError as error:
|
||||
_LOGGER.info("Cannot discover OAuth configuration: %s", error)
|
||||
raise CannotConnect from error
|
||||
|
||||
data = response.json()
|
||||
authorize_url = data["authorization_endpoint"]
|
||||
token_url = data["token_endpoint"]
|
||||
if authorize_url.startswith("/"):
|
||||
authorize_url = str(parsed_url.with_path(authorize_url))
|
||||
if token_url.startswith("/"):
|
||||
token_url = str(parsed_url.with_path(token_url))
|
||||
return AuthorizationServer(
|
||||
authorize_url=authorize_url,
|
||||
token_url=token_url,
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(
|
||||
hass: HomeAssistant, data: dict[str, Any], token_manager: TokenManager | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the user input and connect to the MCP server."""
|
||||
url = data[CONF_URL]
|
||||
try:
|
||||
@@ -34,7 +97,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
except vol.Invalid as error:
|
||||
raise InvalidUrl from error
|
||||
try:
|
||||
async with mcp_client(url) as session:
|
||||
async with mcp_client(url, token_manager=token_manager) as session:
|
||||
response = await session.initialize()
|
||||
except httpx.TimeoutException as error:
|
||||
_LOGGER.info("Timeout connecting to MCP server: %s", error)
|
||||
@@ -56,10 +119,17 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
return {"title": response.serverInfo.name}
|
||||
|
||||
|
||||
class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Handle a config flow for Model Context Protocol."""
|
||||
|
||||
VERSION = 1
|
||||
DOMAIN = DOMAIN
|
||||
logger = _LOGGER
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self.data: dict[str, Any] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -76,7 +146,8 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
return self.async_abort(reason="invalid_auth")
|
||||
self.data[CONF_URL] = user_input[CONF_URL]
|
||||
return await self.async_step_auth_discovery()
|
||||
except MissingCapabilities:
|
||||
return self.async_abort(reason="missing_capabilities")
|
||||
except Exception:
|
||||
@@ -90,6 +161,130 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_auth_discovery(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the OAuth server discovery step.
|
||||
|
||||
Since this OAuth server requires authentication, this step will attempt
|
||||
to find the OAuth medata then run the OAuth authentication flow.
|
||||
"""
|
||||
try:
|
||||
authorization_server = await async_discover_oauth_config(
|
||||
self.hass, self.data[CONF_URL]
|
||||
)
|
||||
except TimeoutConnectError:
|
||||
return self.async_abort(reason="timeout_connect")
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
else:
|
||||
_LOGGER.info("OAuth configuration: %s", authorization_server)
|
||||
self.data.update(
|
||||
{
|
||||
CONF_AUTHORIZATION_URL: authorization_server.authorize_url,
|
||||
CONF_TOKEN_URL: authorization_server.token_url,
|
||||
}
|
||||
)
|
||||
return await self.async_step_credentials_choice()
|
||||
|
||||
def authorization_server(self) -> AuthorizationServer:
|
||||
"""Return the authorization server provided by the MCP server."""
|
||||
return AuthorizationServer(
|
||||
self.data[CONF_AUTHORIZATION_URL],
|
||||
self.data[CONF_TOKEN_URL],
|
||||
)
|
||||
|
||||
async def async_step_credentials_choice(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Step to ask they user if they would like to add credentials.
|
||||
|
||||
This is needed since we can't automatically assume existing credentials
|
||||
should be used given they may be for another existing server.
|
||||
"""
|
||||
with authorization_server_context(self.authorization_server()):
|
||||
if not await async_get_implementations(self.hass, self.DOMAIN):
|
||||
return await self.async_step_new_credentials()
|
||||
return self.async_show_menu(
|
||||
step_id="credentials_choice",
|
||||
menu_options=["pick_implementation", "new_credentials"],
|
||||
)
|
||||
|
||||
async def async_step_new_credentials(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Step to take the frontend flow to enter new credentials."""
|
||||
return self.async_abort(reason="missing_credentials")
|
||||
|
||||
async def async_step_pick_implementation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the pick implementation step.
|
||||
|
||||
This exists to dynamically set application credentials Authorization Server
|
||||
based on the values form the OAuth discovery step.
|
||||
"""
|
||||
with authorization_server_context(self.authorization_server()):
|
||||
return await super().async_step_pick_implementation(user_input)
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow.
|
||||
|
||||
Ok to override if you want to fetch extra info or even add another step.
|
||||
"""
|
||||
config_entry_data = {
|
||||
**self.data,
|
||||
**data,
|
||||
}
|
||||
|
||||
async def token_manager() -> str:
|
||||
return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
||||
|
||||
try:
|
||||
info = await validate_input(self.hass, config_entry_data, token_manager)
|
||||
except TimeoutConnectError:
|
||||
return self.async_abort(reason="timeout_connect")
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except MissingCapabilities:
|
||||
return self.async_abort(reason="missing_capabilities")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
# Unique id based on the application credentials OAuth Client ID
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=config_entry_data
|
||||
)
|
||||
await self.async_set_unique_id(config_entry_data["auth_implementation"])
|
||||
return self.async_create_entry(
|
||||
title=info["title"],
|
||||
data=config_entry_data,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
config_entry = self._get_reauth_entry()
|
||||
self.data = {**config_entry.data}
|
||||
self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment]
|
||||
self.hass, config_entry
|
||||
)
|
||||
return await self.async_step_auth()
|
||||
|
||||
|
||||
class InvalidUrl(HomeAssistantError):
|
||||
"""Error to indicate the URL format is invalid."""
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"""Constants for the Model Context Protocol integration."""
|
||||
|
||||
DOMAIN = "mcp"
|
||||
|
||||
CONF_ACCESS_TOKEN = "access_token"
|
||||
CONF_AUTHORIZATION_URL = "authorization_url"
|
||||
CONF_TOKEN_URL = "token_url"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Types for the Model Context Protocol integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
import datetime
|
||||
import logging
|
||||
@@ -15,7 +15,7 @@ from voluptuous_openapi import convert_to_voluptuous
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
@@ -27,16 +27,28 @@ _LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_INTERVAL = datetime.timedelta(minutes=30)
|
||||
TIMEOUT = 10
|
||||
|
||||
TokenManager = Callable[[], Awaitable[str]]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def mcp_client(url: str) -> AsyncGenerator[ClientSession]:
|
||||
async def mcp_client(
|
||||
url: str,
|
||||
token_manager: TokenManager | None = None,
|
||||
) -> AsyncGenerator[ClientSession]:
|
||||
"""Create a server-sent event MCP client.
|
||||
|
||||
This is an asynccontext manager that exists to wrap other async context managers
|
||||
so that the coordinator has a single object to manage.
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if token_manager is not None:
|
||||
token = await token_manager()
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
try:
|
||||
async with sse_client(url=url) as streams, ClientSession(*streams) as session:
|
||||
async with (
|
||||
sse_client(url=url, headers=headers) as streams,
|
||||
ClientSession(*streams) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
yield session
|
||||
except ExceptionGroup as err:
|
||||
@@ -53,12 +65,14 @@ class ModelContextProtocolTool(llm.Tool):
|
||||
description: str | None,
|
||||
parameters: vol.Schema,
|
||||
server_url: str,
|
||||
token_manager: TokenManager | None = None,
|
||||
) -> None:
|
||||
"""Initialize the tool."""
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parameters = parameters
|
||||
self.server_url = server_url
|
||||
self.token_manager = token_manager
|
||||
|
||||
async def async_call(
|
||||
self,
|
||||
@@ -69,7 +83,7 @@ class ModelContextProtocolTool(llm.Tool):
|
||||
"""Call the tool."""
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
async with mcp_client(self.server_url) as session:
|
||||
async with mcp_client(self.server_url, self.token_manager) as session:
|
||||
result = await session.call_tool(
|
||||
tool_input.tool_name, tool_input.tool_args
|
||||
)
|
||||
@@ -87,7 +101,12 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
token_manager: TokenManager | None = None,
|
||||
) -> None:
|
||||
"""Initialize ModelContextProtocolCoordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -96,6 +115,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
|
||||
config_entry=config_entry,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self.token_manager = token_manager
|
||||
|
||||
async def _async_update_data(self) -> list[llm.Tool]:
|
||||
"""Fetch data from API endpoint.
|
||||
@@ -105,11 +125,20 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
|
||||
"""
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
async with mcp_client(self.config_entry.data[CONF_URL]) as session:
|
||||
async with mcp_client(
|
||||
self.config_entry.data[CONF_URL], self.token_manager
|
||||
) as session:
|
||||
result = await session.list_tools()
|
||||
except TimeoutError as error:
|
||||
_LOGGER.debug("Timeout when listing tools: %s", error)
|
||||
raise UpdateFailed(f"Timeout when listing tools: {error}") from error
|
||||
except httpx.HTTPStatusError as error:
|
||||
_LOGGER.debug("Error communicating with API: %s", error)
|
||||
if error.response.status_code == 401 and self.token_manager is not None:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"The MCP server requires authentication"
|
||||
) from error
|
||||
raise UpdateFailed(f"Error communicating with API: {error}") from error
|
||||
except httpx.HTTPError as err:
|
||||
_LOGGER.debug("Error communicating with API: %s", err)
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
@@ -129,6 +158,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
|
||||
tool.description,
|
||||
parameters,
|
||||
self.config_entry.data[CONF_URL],
|
||||
self.token_manager,
|
||||
)
|
||||
)
|
||||
return tools
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Model Context Protocol",
|
||||
"codeowners": ["@allenporter"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/mcp",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
|
||||
@@ -44,9 +44,7 @@ rules:
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: Integration does not have platforms.
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Integration does not support authentication.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"data_description": {
|
||||
"url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse"
|
||||
}
|
||||
},
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
|
||||
"data": {
|
||||
"implementation": "Credentials"
|
||||
},
|
||||
"data_description": {
|
||||
"implementation": "The credentials to use for the OAuth2 flow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -17,9 +26,15 @@
|
||||
"invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_capabilities": "The MCP server does not support a required capability (Tools)",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
},
|
||||
"repeat": {
|
||||
"options": {
|
||||
"off": "Off",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"all": "Repeat all",
|
||||
"one": "Repeat one"
|
||||
}
|
||||
|
||||
@@ -88,11 +88,11 @@
|
||||
},
|
||||
"duplicate_entity_entry": {
|
||||
"title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.",
|
||||
"description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
"description": "An address can only be associated with one entity. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
},
|
||||
"duplicate_entity_name": {
|
||||
"title": "Modbus {sub_1} is duplicate, second entry not loaded.",
|
||||
"description": "A entity name must be unique, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
"description": "An entity name must be unique. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
},
|
||||
"no_entities": {
|
||||
"title": "Modbus {sub_1} contain no entities, entry not loaded.",
|
||||
|
||||
@@ -72,8 +72,8 @@
|
||||
"connection": {
|
||||
"name": "Connection status",
|
||||
"state": {
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"connected": "[%key:common::state::connected%]",
|
||||
"disconnected": "[%key:common::state::disconnected%]",
|
||||
"connecting": "Connecting",
|
||||
"disconnecting": "Disconnecting"
|
||||
}
|
||||
|
||||
@@ -399,6 +399,9 @@ class MqttAttributesMixin(Entity):
|
||||
|
||||
_attributes_extra_blocked: frozenset[str] = frozenset()
|
||||
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
|
||||
_message_callback: Callable[
|
||||
[MessageCallbackType, set[str] | None, ReceiveMessage], None
|
||||
]
|
||||
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize the JSON attributes mixin."""
|
||||
@@ -433,7 +436,7 @@ class MqttAttributesMixin(Entity):
|
||||
CONF_JSON_ATTRS_TOPIC: {
|
||||
"topic": self._attributes_config.get(CONF_JSON_ATTRS_TOPIC),
|
||||
"msg_callback": partial(
|
||||
self._message_callback, # type: ignore[attr-defined]
|
||||
self._message_callback,
|
||||
self._attributes_message_received,
|
||||
{"_attr_extra_state_attributes"},
|
||||
),
|
||||
@@ -482,6 +485,10 @@ class MqttAttributesMixin(Entity):
|
||||
class MqttAvailabilityMixin(Entity):
|
||||
"""Mixin used for platforms that report availability."""
|
||||
|
||||
_message_callback: Callable[
|
||||
[MessageCallbackType, set[str] | None, ReceiveMessage], None
|
||||
]
|
||||
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize the availability mixin."""
|
||||
self._availability_sub_state: dict[str, EntitySubscription] = {}
|
||||
@@ -547,7 +554,7 @@ class MqttAvailabilityMixin(Entity):
|
||||
f"availability_{topic}": {
|
||||
"topic": topic,
|
||||
"msg_callback": partial(
|
||||
self._message_callback, # type: ignore[attr-defined]
|
||||
self._message_callback,
|
||||
self._availability_message_received,
|
||||
{"available"},
|
||||
),
|
||||
|
||||
@@ -62,6 +62,7 @@ from ..entity import MqttEntity
|
||||
from ..models import (
|
||||
MqttCommandTemplate,
|
||||
MqttValueTemplate,
|
||||
PayloadSentinel,
|
||||
PublishPayloadType,
|
||||
ReceiveMessage,
|
||||
)
|
||||
@@ -126,7 +127,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
|
||||
_command_templates: dict[
|
||||
str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType]
|
||||
]
|
||||
_value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]]
|
||||
_value_templates: dict[
|
||||
str, Callable[[ReceivePayloadType, ReceivePayloadType], ReceivePayloadType]
|
||||
]
|
||||
_fixed_color_mode: ColorMode | str | None
|
||||
_topics: dict[str, str | None]
|
||||
|
||||
@@ -203,73 +206,133 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
|
||||
@callback
|
||||
def _state_received(self, msg: ReceiveMessage) -> None:
|
||||
"""Handle new MQTT messages."""
|
||||
state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload)
|
||||
if state == STATE_ON:
|
||||
state_value = self._value_templates[CONF_STATE_TEMPLATE](
|
||||
msg.payload,
|
||||
PayloadSentinel.NONE,
|
||||
)
|
||||
if not state_value:
|
||||
_LOGGER.debug(
|
||||
"Ignoring message from '%s' with empty state value", msg.topic
|
||||
)
|
||||
elif state_value == STATE_ON:
|
||||
self._attr_is_on = True
|
||||
elif state == STATE_OFF:
|
||||
elif state_value == STATE_OFF:
|
||||
self._attr_is_on = False
|
||||
elif state == PAYLOAD_NONE:
|
||||
elif state_value == PAYLOAD_NONE:
|
||||
self._attr_is_on = None
|
||||
else:
|
||||
_LOGGER.warning("Invalid state value received")
|
||||
_LOGGER.warning(
|
||||
"Invalid state value '%s' received from %s",
|
||||
state_value,
|
||||
msg.topic,
|
||||
)
|
||||
|
||||
if CONF_BRIGHTNESS_TEMPLATE in self._config:
|
||||
try:
|
||||
if brightness := int(
|
||||
self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload)
|
||||
):
|
||||
self._attr_brightness = brightness
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Ignoring zero brightness value for entity %s",
|
||||
self.entity_id,
|
||||
brightness_value = self._value_templates[CONF_BRIGHTNESS_TEMPLATE](
|
||||
msg.payload,
|
||||
PayloadSentinel.NONE,
|
||||
)
|
||||
if not brightness_value:
|
||||
_LOGGER.debug(
|
||||
"Ignoring message from '%s' with empty brightness value",
|
||||
msg.topic,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
if brightness := int(brightness_value):
|
||||
self._attr_brightness = brightness
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Ignoring zero brightness value for entity %s",
|
||||
self.entity_id,
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"Invalid brightness value '%s' received from %s",
|
||||
brightness_value,
|
||||
msg.topic,
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid brightness value received from %s", msg.topic)
|
||||
|
||||
if CONF_COLOR_TEMP_TEMPLATE in self._config:
|
||||
try:
|
||||
color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE](
|
||||
msg.payload
|
||||
color_temp_value = self._value_templates[CONF_COLOR_TEMP_TEMPLATE](
|
||||
msg.payload,
|
||||
PayloadSentinel.NONE,
|
||||
)
|
||||
if not color_temp_value:
|
||||
_LOGGER.debug(
|
||||
"Ignoring message from '%s' with empty color temperature value",
|
||||
msg.topic,
|
||||
)
|
||||
self._attr_color_temp_kelvin = (
|
||||
int(color_temp)
|
||||
if self._color_temp_kelvin
|
||||
else color_util.color_temperature_mired_to_kelvin(int(color_temp))
|
||||
if color_temp != "None"
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid color temperature value received")
|
||||
else:
|
||||
try:
|
||||
self._attr_color_temp_kelvin = (
|
||||
int(color_temp_value)
|
||||
if self._color_temp_kelvin
|
||||
else color_util.color_temperature_mired_to_kelvin(
|
||||
int(color_temp_value)
|
||||
)
|
||||
if color_temp_value != "None"
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"Invalid color temperature value '%s' received from %s",
|
||||
color_temp_value,
|
||||
msg.topic,
|
||||
)
|
||||
|
||||
if (
|
||||
CONF_RED_TEMPLATE in self._config
|
||||
and CONF_GREEN_TEMPLATE in self._config
|
||||
and CONF_BLUE_TEMPLATE in self._config
|
||||
):
|
||||
try:
|
||||
red = self._value_templates[CONF_RED_TEMPLATE](msg.payload)
|
||||
green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload)
|
||||
blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload)
|
||||
if red == "None" and green == "None" and blue == "None":
|
||||
self._attr_hs_color = None
|
||||
else:
|
||||
self._attr_hs_color = color_util.color_RGB_to_hs(
|
||||
int(red), int(green), int(blue)
|
||||
)
|
||||
red_value = self._value_templates[CONF_RED_TEMPLATE](
|
||||
msg.payload,
|
||||
PayloadSentinel.NONE,
|
||||
)
|
||||
green_value = self._value_templates[CONF_GREEN_TEMPLATE](
|
||||
msg.payload,
|
||||
PayloadSentinel.NONE,
|
||||
)
|
||||
blue_value = self._value_templates[CONF_BLUE_TEMPLATE](
|
||||
msg.payload,
|
||||
PayloadSentinel.NONE,
|
||||
)
|
||||
if not red_value or not green_value or not blue_value:
|
||||
_LOGGER.debug(
|
||||
"Ignoring message from '%s' with empty color value", msg.topic
|
||||
)
|
||||
elif red_value == "None" and green_value == "None" and blue_value == "None":
|
||||
self._attr_hs_color = None
|
||||
self._update_color_mode()
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid color value received")
|
||||
else:
|
||||
try:
|
||||
self._attr_hs_color = color_util.color_RGB_to_hs(
|
||||
int(red_value), int(green_value), int(blue_value)
|
||||
)
|
||||
self._update_color_mode()
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid color value received from %s", msg.topic)
|
||||
|
||||
if CONF_EFFECT_TEMPLATE in self._config:
|
||||
effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload))
|
||||
if (
|
||||
effect_list := self._config[CONF_EFFECT_LIST]
|
||||
) and effect in effect_list:
|
||||
self._attr_effect = effect
|
||||
effect_value = self._value_templates[CONF_EFFECT_TEMPLATE](
|
||||
msg.payload,
|
||||
PayloadSentinel.NONE,
|
||||
)
|
||||
if not effect_value:
|
||||
_LOGGER.debug(
|
||||
"Ignoring message from '%s' with empty effect value", msg.topic
|
||||
)
|
||||
elif (effect_list := self._config[CONF_EFFECT_LIST]) and str(
|
||||
effect_value
|
||||
) in effect_list:
|
||||
self._attr_effect = str(effect_value)
|
||||
else:
|
||||
_LOGGER.warning("Unsupported effect value received")
|
||||
_LOGGER.warning(
|
||||
"Unsupported effect value '%s' received from %s",
|
||||
effect_value,
|
||||
msg.topic,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _prepare_subscribe_topics(self) -> None:
|
||||
|
||||
@@ -26,7 +26,7 @@ from . import subscription
|
||||
from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA
|
||||
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON
|
||||
from .entity import MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import MqttValueTemplate, ReceiveMessage
|
||||
from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage
|
||||
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
|
||||
from .util import valid_publish_topic, valid_subscribe_topic
|
||||
|
||||
@@ -136,7 +136,18 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity):
|
||||
@callback
|
||||
def _handle_state_message_received(self, msg: ReceiveMessage) -> None:
|
||||
"""Handle receiving state message via MQTT."""
|
||||
payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload)
|
||||
payload = self._templates[CONF_VALUE_TEMPLATE](
|
||||
msg.payload, PayloadSentinel.DEFAULT
|
||||
)
|
||||
|
||||
if payload is PayloadSentinel.DEFAULT:
|
||||
_LOGGER.warning(
|
||||
"Unable to process payload '%s' for topic %s, with value template '%s'",
|
||||
msg.payload,
|
||||
msg.topic,
|
||||
self._config.get(CONF_VALUE_TEMPLATE),
|
||||
)
|
||||
return
|
||||
|
||||
if not payload or payload == PAYLOAD_EMPTY_JSON:
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"global_override": {
|
||||
"name": "Global override",
|
||||
"state": {
|
||||
"away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
|
||||
"away": "[%key:common::state::not_home%]",
|
||||
"comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
|
||||
"eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
|
||||
"none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]"
|
||||
|
||||
@@ -34,7 +34,7 @@ def validate_prices(
|
||||
index: int,
|
||||
) -> float | None:
|
||||
"""Validate and return."""
|
||||
if result := func(entity)[area][index]:
|
||||
if (result := func(entity)[area][index]) is not None:
|
||||
return result / 1000
|
||||
return None
|
||||
|
||||
|
||||
@@ -23,14 +23,10 @@ from homeassistant.const import (
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
INTEGRATION_SUPPORTED_COMMANDS,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS
|
||||
|
||||
NUT_FAKE_SERIAL = ["unknown", "blank"]
|
||||
|
||||
@@ -68,7 +64,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
|
||||
alias = config.get(CONF_ALIAS)
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
if CONF_SCAN_INTERVAL in entry.options:
|
||||
current_options = {**entry.options}
|
||||
current_options.pop(CONF_SCAN_INTERVAL)
|
||||
hass.config_entries.async_update_entry(entry, options=current_options)
|
||||
|
||||
data = PyNUTData(host, port, alias, username, password)
|
||||
|
||||
@@ -101,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
|
||||
config_entry=entry,
|
||||
name="NUT resource status",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=scan_interval),
|
||||
update_interval=timedelta(seconds=60),
|
||||
always_update=False,
|
||||
)
|
||||
|
||||
@@ -122,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
|
||||
if unique_id is None:
|
||||
unique_id = entry.entry_id
|
||||
|
||||
elif entry.unique_id is None:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=unique_id)
|
||||
|
||||
if username is not None and password is not None:
|
||||
# Dynamically add outlet integration commands
|
||||
additional_integration_commands = set()
|
||||
@@ -155,10 +157,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
|
||||
coordinator, data, unique_id, user_available_commands
|
||||
)
|
||||
|
||||
connections: set[tuple[str, str]] | None = None
|
||||
if data.device_info.mac_address is not None:
|
||||
connections = {(CONNECTION_NETWORK_MAC, data.device_info.mac_address)}
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
connections=connections,
|
||||
name=data.name.title(),
|
||||
manufacturer=data.device_info.manufacturer,
|
||||
model=data.device_info.model,
|
||||
@@ -246,6 +253,7 @@ class NUTDeviceInfo:
|
||||
model_id: str | None = None
|
||||
firmware: str | None = None
|
||||
serial: str | None = None
|
||||
mac_address: str | None = None
|
||||
device_location: str | None = None
|
||||
|
||||
|
||||
@@ -309,9 +317,18 @@ class PyNUTData:
|
||||
model_id: str | None = self._status.get("device.part")
|
||||
firmware = _firmware_from_status(self._status)
|
||||
serial = _serial_from_status(self._status)
|
||||
mac_address: str | None = self._status.get("device.macaddr")
|
||||
if mac_address is not None:
|
||||
mac_address = format_mac(mac_address.rstrip().replace(" ", ":"))
|
||||
device_location: str | None = self._status.get("device.location")
|
||||
return NUTDeviceInfo(
|
||||
manufacturer, model, model_id, firmware, serial, device_location
|
||||
manufacturer,
|
||||
model,
|
||||
model_id,
|
||||
firmware,
|
||||
serial,
|
||||
mac_address,
|
||||
device_location,
|
||||
)
|
||||
|
||||
async def _async_get_status(self) -> dict[str, str]:
|
||||
|
||||
@@ -9,27 +9,21 @@ from typing import Any
|
||||
from aionut import NUTError, NUTLoginError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_ALIAS,
|
||||
CONF_BASE,
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from . import PyNUTData
|
||||
from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
from . import PyNUTData, _unique_id_from_status
|
||||
from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,6 +119,11 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if self._host_port_alias_already_configured(nut_config):
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
if unique_id := _unique_id_from_status(info["available_resources"]):
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
title = _format_host_port_alias(nut_config)
|
||||
return self.async_create_entry(title=title, data=nut_config)
|
||||
|
||||
@@ -147,8 +146,13 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.nut_config.update(user_input)
|
||||
if self._host_port_alias_already_configured(nut_config):
|
||||
return self.async_abort(reason="already_configured")
|
||||
_, errors, placeholders = await self._async_validate_or_error(nut_config)
|
||||
|
||||
info, errors, placeholders = await self._async_validate_or_error(nut_config)
|
||||
if not errors:
|
||||
if unique_id := _unique_id_from_status(info["available_resources"]):
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
title = _format_host_port_alias(nut_config)
|
||||
return self.async_create_entry(title=title, data=nut_config)
|
||||
|
||||
@@ -230,32 +234,3 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema(AUTH_SCHEMA),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlow):
|
||||
"""Handle a option flow for nut."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle options flow."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
scan_interval = self.config_entry.options.get(
|
||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||
)
|
||||
|
||||
base_schema = {
|
||||
vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval): vol.All(
|
||||
vol.Coerce(int), vol.Clamp(min=10, max=300)
|
||||
)
|
||||
}
|
||||
|
||||
return self.async_show_form(step_id="init", data_schema=vol.Schema(base_schema))
|
||||
|
||||
@@ -19,8 +19,6 @@ DEFAULT_PORT = 3493
|
||||
KEY_STATUS = "ups.status"
|
||||
KEY_STATUS_DISPLAY = "ups.status.display"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = 60
|
||||
|
||||
STATE_TYPES = {
|
||||
"OL": "Online",
|
||||
"OB": "On Battery",
|
||||
|
||||
@@ -38,15 +38,6 @@
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"scan_interval": "Scan Interval (seconds)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"beeper_disable": "Disable UPS beeper/buzzer",
|
||||
|
||||
@@ -6,6 +6,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
@@ -31,7 +32,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool:
|
||||
"""Set up Ohme from a config entry."""
|
||||
|
||||
client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
|
||||
client = OhmeApiClient(
|
||||
email=entry.data[CONF_EMAIL],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
try:
|
||||
await client.async_login()
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ohme import ApiException, ChargerStatus, OhmeApiClient
|
||||
|
||||
@@ -23,7 +24,7 @@ PARALLEL_UPDATES = 1
|
||||
class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription):
|
||||
"""Class describing Ohme button entities."""
|
||||
|
||||
press_fn: Callable[[OhmeApiClient], Awaitable[None]]
|
||||
press_fn: Callable[[OhmeApiClient], Coroutine[Any, Any, bool]]
|
||||
|
||||
|
||||
BUTTON_DESCRIPTIONS = [
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ohme/",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ohme==1.4.1"]
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["ohme==1.5.1"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Platform for number."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ohme import ApiException, OhmeApiClient
|
||||
|
||||
@@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1
|
||||
class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription):
|
||||
"""Class describing Ohme number entities."""
|
||||
|
||||
set_fn: Callable[[OhmeApiClient, float], Awaitable[None]]
|
||||
set_fn: Callable[[OhmeApiClient, float], Coroutine[Any, Any, bool]]
|
||||
value_fn: Callable[[OhmeApiClient], float]
|
||||
|
||||
|
||||
@@ -31,7 +32,7 @@ NUMBER_DESCRIPTION = [
|
||||
key="target_percentage",
|
||||
translation_key="target_percentage",
|
||||
value_fn=lambda client: client.target_soc,
|
||||
set_fn=lambda client, value: client.async_set_target(target_percent=value),
|
||||
set_fn=lambda client, value: client.async_set_target(target_percent=int(value)),
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
@@ -42,7 +43,7 @@ NUMBER_DESCRIPTION = [
|
||||
translation_key="preconditioning_duration",
|
||||
value_fn=lambda client: client.preconditioning,
|
||||
set_fn=lambda client, value: client.async_set_target(
|
||||
pre_condition_length=value
|
||||
pre_condition_length=int(value)
|
||||
),
|
||||
native_min_value=0,
|
||||
native_max_value=60,
|
||||
|
||||
@@ -75,6 +75,6 @@ rules:
|
||||
comment: |
|
||||
Not supported by the API. Accounts and devices have a one-to-one relationship.
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user