Compare commits

..

50 Commits

Author SHA1 Message Date
Ville Skyttä
5e7e299876 Add Tasmota firmware update availability support 2026-03-30 23:08:38 +03:00
smarthome-10
c12b7bfd18 Rename component to integration in Bitcoin (#166882) 2026-03-30 20:41:26 +01:00
smarthome-10
1c2f583587 Rename component to integration in FortiOS (#166887)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 20:33:06 +01:00
Raj Laud
58a376e68b Bump victron-ble-ha-parser (#166906) 2026-03-30 20:23:22 +01:00
Jan Bouwhuis
78b251e7cb Add clean segment support to MQTT vacuum entities (#166794) 2026-03-30 21:20:17 +02:00
Abílio Costa
a2c65b9126 Remove checkout requirement from PR review skill (#166902) 2026-03-30 19:12:59 +01:00
Denis Shulyaka
5e443681c3 Add troubleshooting documentation for Anthropic integration (#166766) 2026-03-30 20:10:49 +02:00
smarthome-10
13756863f1 Rename component to integration in Fail2Ban (#166901) 2026-03-30 20:08:56 +02:00
Raphael Hehl
fd54e45aeb Add dynamic device support for UniFi Access door platforms (#166793) 2026-03-30 19:51:05 +02:00
Manu
52af74c3b6 Add entity action html5.send_message to HTML5 integration (#166349)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-30 19:49:59 +02:00
Denis Shulyaka
dc111a475e Add support for web search dynamic filtering for Anthropic (#164116) 2026-03-30 19:40:56 +02:00
Chase
14cb42349a OpenRouter: Add WebSearch Support (#164293)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-30 19:40:02 +02:00
Raphael Hehl
c42b50418e Add stale device removal support to UniFi Access (#166792)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-30 19:19:20 +02:00
AlCalzone
501b4e6efb Convert Z-Wave Opening state to separate Open/Closed and Tilted sensors (#166635)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 19:17:05 +02:00
smarthome-10
ca2099b165 Rename component to integration in Panasonic Blu-Ray (#166890)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 18:13:17 +02:00
smarthome-10
69b55c295d Rename component to integration in OhmConnect (#166881) 2026-03-30 17:47:38 +02:00
smarthome-10
13709b1c90 Rename component to integration in Sky Hub (#166888) 2026-03-30 17:45:18 +02:00
smarthome-10
2c013777db Rename component to integration in Opple (#166891) 2026-03-30 17:43:56 +02:00
Raphael Hehl
91099ea489 Update UniFi Access quality scale: mark fulfilled Gold rules (#166789)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-30 17:19:07 +02:00
Michal Čihař
70cea66e5b Skip unavailable sensors in LaCrosse View (#166859) 2026-03-30 17:03:21 +02:00
Taylor Wilsdon
e78bb97e84 Support vacation mode in Econet (#166659) 2026-03-30 16:58:11 +02:00
Robert Svensson
732b170190 Introduce per-source DataUpdateCoordinator for UniFi polling data sources (#166806) 2026-03-30 16:48:18 +02:00
Raphael Hehl
0a05993a4e Unifi Access add reconfiguration flow and refactor validation logic (#166812)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-30 16:44:12 +02:00
Abílio Costa
42c3610685 Add counter purpose-specific condition (#166879) 2026-03-30 16:41:08 +02:00
Raphael Hehl
4ad73da7ec Add strict typing to UniFi Access integration (#166787) 2026-03-30 16:36:07 +02:00
hanwg
0d14bdab24 Fix webhook leak for Telegram bot (#166776) 2026-03-30 16:29:28 +02:00
Denis Shulyaka
157362f225 Fix OpenAI image generation with reasoning (#166827) 2026-03-30 16:27:39 +02:00
Manu
1aa380fdfa Add tr4nt0r as codeowner to html5 integration (#166771) 2026-03-30 10:25:10 -04:00
Jan Bouwhuis
9348948afa Add attribute group_entities to the list of blocked MQTT entity attributes (#165360) 2026-03-30 16:21:02 +02:00
Jan Bouwhuis
14b9915914 Add repair flow when MQTT YAML config is present but the broker is not set up correctly (#165090)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-30 16:16:31 +02:00
smarthome-10
607462028b Rename component to integration in Thomson (#166880) 2026-03-30 16:08:03 +02:00
epenet
8c07348a3d Migrate neato to use runtime_data (#166854)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:03:43 +02:00
epenet
cda52af178 Migrate motioneye to use runtime_data (#166848)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:56:08 +02:00
Tom Matheussen
d1ccda18f7 Skip unchanged connection check on reconfigure flow for Satel Integra (#166695)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-30 15:52:11 +02:00
Franck Nijhof
9fb0b69f0a Improve text action naming consistency (#166523)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-30 15:42:31 +02:00
Paul Bottein
f0848edea9 Use translation key and icons.json for Synology DSM button entities (#166862) 2026-03-30 15:23:49 +02:00
Mike O'Driscoll
5be12a213d Bump pycasperglow to 1.2.0 (#166791) 2026-03-30 15:03:40 +02:00
mettolen
20b284d0e9 Fix Huum exception translations (#166778)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 14:55:45 +02:00
Lorenzo Gasparini
49c3376c95 Bump fing_agent_api to 1.1.0 (#166855) 2026-03-30 14:33:00 +02:00
Joost Lekkerkerker
174b5f5593 Get list of analytics insights integrations from next environment (#166867) 2026-03-30 14:29:25 +02:00
epenet
b38e41a34a Refactor Tuya device diagnostics (#166846) 2026-03-30 14:01:18 +02:00
epenet
b6350478a5 Migrate meteo_france to use runtime_data (#166852)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:48:01 +02:00
Erik Montnemery
b75af6d84a Mark Entity.async_write_ha_state as final (#166627) 2026-03-30 13:21:45 +02:00
Ariel Ebersberger
194485d863 Fix shelly tests - mock async_unload_entry (#166851) 2026-03-30 13:19:52 +02:00
Raphael Hehl
d6458bc574 Add diagnostics support to UniFi Access integration (#166819)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 12:39:38 +02:00
Mike O'Driscoll
434f1dca2c Add diagnostics to Casper Glow (#166807) 2026-03-30 12:38:28 +02:00
Florian
c6ad6da6ae Clamp surepetcare battery percentage to 0-100 (#166824)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-30 12:34:38 +02:00
epenet
be3d65538d Use runtime_data in motion_blinds integration (#166849)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:32:27 +02:00
Michael
297e9e265a Add valve.opened and valve.closed triggers (#165160) 2026-03-30 12:06:43 +02:00
Simone Chemelli
119dfbddea Update quality scale for Fritz (#166853) 2026-03-30 11:32:16 +02:00
157 changed files with 4903 additions and 798 deletions

View File

@@ -5,14 +5,6 @@ description: Review a GitHub pull request and provide feedback comments. Use whe
# Review GitHub Pull Request
## Preparation:
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
- Do NOT attempt any workarounds.
- Do NOT proceed with the review.
- ALERT about the failure and WAIT for instructions.
- This is a hard requirement - no exceptions.
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.

View File

@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.02.0"
BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}

View File

@@ -1 +1 @@
3.14.3
3.14.2

View File

@@ -579,6 +579,7 @@ homeassistant.components.trmnl.*
homeassistant.components.tts.*
homeassistant.components.twentemilieu.*
homeassistant.components.unifi.*
homeassistant.components.unifi_access.*
homeassistant.components.unifiprotect.*
homeassistant.components.upcloud.*
homeassistant.components.update.*

8
CODEOWNERS generated
View File

@@ -741,8 +741,8 @@ build.json @home-assistant/supervisor
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015
/tests/components/html5/ @alexyao2015
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
/tests/components/html5/ @alexyao2015 @tr4nt0r
/homeassistant/components/http/ @home-assistant/core
/tests/components/http/ @home-assistant/core
/homeassistant/components/huawei_lte/ @scop @fphammerle
@@ -1232,8 +1232,8 @@ build.json @home-assistant/supervisor
/tests/components/onvif/ @jterrace
/homeassistant/components/open_meteo/ @frenck
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek
/homeassistant/components/open_router/ @joostlek @ab3lson
/tests/components/open_router/ @joostlek @ab3lson
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from python_homeassistant_analytics import (
Environment,
HomeassistantAnalyticsClient,
HomeassistantAnalyticsConnectionError,
)
@@ -38,7 +39,7 @@ async def async_setup_entry(
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass))
try:
integrations = await client.get_integrations()
integrations = await client.get_integrations(Environment.NEXT)
except HomeassistantAnalyticsConnectionError as ex:
raise ConfigEntryNotReady("Could not fetch integration list") from ex

View File

@@ -71,6 +71,16 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
"claude-haiku-4-5",
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]

View File

@@ -19,6 +19,8 @@ from anthropic.types import (
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockParamContentParam,
Container,
ContentBlockParam,
DocumentBlockParam,
@@ -61,15 +63,16 @@ from anthropic.types import (
ToolUseBlockParam,
Usage,
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
WebSearchToolResultBlockParamContentParam,
)
from anthropic.types.bash_code_execution_tool_result_block_param import (
Content as BashCodeExecutionToolResultContentParam,
Content as BashCodeExecutionToolResultBlockParamContentParam,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultContentParam,
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -105,6 +108,7 @@ from .const import (
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
)
@@ -224,12 +228,22 @@ def _convert_content(
},
),
}
elif content.tool_name == "code_execution":
tool_result_block = {
"type": "code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
CodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
elif content.tool_name == "bash_code_execution":
tool_result_block = {
"type": "bash_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
BashCodeExecutionToolResultContentParam, content.tool_result
BashCodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
elif content.tool_name == "text_editor_code_execution":
@@ -237,7 +251,7 @@ def _convert_content(
"type": "text_editor_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
TextEditorCodeExecutionToolResultContentParam,
TextEditorCodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
@@ -368,6 +382,7 @@ def _convert_content(
name=cast(
Literal[
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
],
@@ -379,6 +394,7 @@ def _convert_content(
and tool_call.tool_name
in [
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
]
@@ -470,7 +486,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input={},
input=response.content_block.input or {},
)
current_tool_args = ""
if response.content_block.name == output_tool:
@@ -532,13 +548,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input={},
input=response.content_block.input or {},
)
current_tool_args = ""
elif isinstance(
response.content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
),
@@ -594,13 +611,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] = tool_args
current_tool_block["input"] |= tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=tool_args,
tool_args=current_tool_block["input"],
external=current_tool_block["type"] == "server_tool_use",
)
]
@@ -735,19 +752,34 @@ class AnthropicBaseLLMEntity(Entity):
]
if options.get(CONF_CODE_EXECUTION):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_WEB_SEARCH):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
if options.get(CONF_WEB_SEARCH):
web_search = WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_CODE_EXECUTION):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
)
else:
web_search = WebSearchTool20260209Param(
name="web_search",
type="web_search_20260209",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = {
"type": "approximate",

View File

@@ -66,7 +66,7 @@ rules:
comment: |
To write something about what models we support.
docs-supported-functions: done
docs-troubleshooting: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt

View File

@@ -124,6 +124,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"battery",
"calendar",
"climate",
"counter",
"cover",
"device_tracker",
"door",
@@ -193,6 +194,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"todo",
"update",
"vacuum",
"valve",
"water_heater",
"window",
}

View File

@@ -1 +1 @@
"""The bitcoin component."""
"""The Bitcoin integration."""

View File

@@ -0,0 +1,31 @@
"""Diagnostics support for the Casper Glow integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components import bluetooth
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import CasperGlowConfigEntry
SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"})
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: CasperGlowConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
service_info = bluetooth.async_last_service_info(
hass, coordinator.device.address, connectable=True
)
return {
"service_info": async_redact_data(
service_info.as_dict() if service_info else None,
SERVICE_INFO_TO_REDACT,
),
}

View File

@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"loggers": ["pycasperglow"],
"quality_scale": "silver",
"requirements": ["pycasperglow==1.1.0"]
"requirements": ["pycasperglow==1.2.0"]
}

View File

@@ -39,7 +39,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: No network discovery.

View File

@@ -0,0 +1,15 @@
"""Provides conditions for counters."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
DOMAIN = "counter"
CONDITIONS: dict[str, type[Condition]] = {
"is_value": make_entity_numerical_condition(DOMAIN),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for counters."""
return CONDITIONS

View File

@@ -0,0 +1,25 @@
is_value:
target:
entity:
- domain: counter
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
threshold:
required: true
selector:
numeric_threshold:
entity:
- domain: counter
- domain: input_number
- domain: number
mode: is
number:
mode: box

View File

@@ -1,4 +1,9 @@
{
"conditions": {
"is_value": {
"condition": "mdi:counter"
}
},
"services": {
"decrement": {
"service": "mdi:numeric-negative-1"

View File

@@ -3,6 +3,22 @@
"trigger_behavior_description": "The behavior of the targeted counters to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_value": {
"description": "Tests the value of one or more counters.",
"fields": {
"behavior": {
"description": "How the state should match on the targeted counters.",
"name": "Behavior"
},
"threshold": {
"description": "What to test for and threshold values.",
"name": "Threshold"
}
},
"name": "Counter value"
}
},
"entity_component": {
"_": {
"name": "[%key:component::counter::title%]",
@@ -30,6 +46,12 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -45,6 +45,13 @@ SUPPORT_FLAGS_HEATER = (
)
def _operation_mode_to_ha(mode: WaterHeaterOperationMode | None) -> str:
"""Translate an EcoNet operation mode to a Home Assistant state."""
if mode in (None, WaterHeaterOperationMode.VACATION):
return STATE_OFF
return ECONET_STATE_TO_HA[mode]
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
@@ -80,26 +87,22 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
@property
def current_operation(self) -> str:
"""Return current operation."""
econet_mode = self.water_heater.mode
_current_op = STATE_OFF
if econet_mode is not None:
_current_op = ECONET_STATE_TO_HA[econet_mode]
return _current_op
return _operation_mode_to_ha(self.water_heater.mode)
@property
def operation_list(self) -> list[str]:
"""List of available operation modes."""
econet_modes = self.water_heater.modes
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]
operation_modes.add(ha_mode)
return list(operation_modes)
return list(
dict.fromkeys(
ECONET_STATE_TO_HA[mode]
for mode in self.water_heater.modes
if mode
not in (
WaterHeaterOperationMode.UNKNOWN,
WaterHeaterOperationMode.VACATION,
)
)
)
@property
def supported_features(self) -> WaterHeaterEntityFeature:

View File

@@ -1 +1 @@
"""The fail2ban component."""
"""The Fail2Ban integration."""

View File

@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.helpers.httpx_client import get_async_client
from .const import DOMAIN, UPNP_AVAILABLE
@@ -40,6 +41,7 @@ class FingConfigFlow(ConfigFlow, domain=DOMAIN):
ip=user_input[CONF_IP_ADDRESS],
port=int(user_input[CONF_PORT]),
key=user_input[CONF_API_KEY],
client=get_async_client(self.hass),
)
try:

View File

@@ -11,6 +11,7 @@ import httpx
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPNP_AVAILABLE
@@ -38,6 +39,7 @@ class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
ip=config_entry.data[CONF_IP_ADDRESS],
port=int(config_entry.data[CONF_PORT]),
key=config_entry.data[CONF_API_KEY],
client=get_async_client(hass),
)
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
update_interval = timedelta(seconds=30)

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["fing_agent_api==1.0.3"]
"requirements": ["fing_agent_api==1.1.0"]
}

View File

@@ -68,5 +68,5 @@ rules:
# Platinum
async-dependency: todo
inject-websession: todo
inject-websession: done
strict-typing: todo

View File

@@ -1 +1 @@
"""Fortinet FortiOS components."""
"""Fortinet FortiOS integration."""

View File

@@ -1,6 +1,6 @@
"""Support to use FortiOS device like FortiGate as device tracker.
This component is part of the device_tracker platform.
This FortiOS integration provides a device_tracker platform.
"""
from __future__ import annotations

View File

@@ -34,23 +34,17 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: todo
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations:
status: exempt
comment: no known limitations, yet
docs-supported-devices:
status: todo
comment: add the known supported devices
docs-supported-functions:
status: todo
comment: need to be overhauled
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases:
status: todo
comment: need to be overhauled
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done

View File

@@ -4,14 +4,23 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.EVENT, Platform.NOTIFY]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HTML5 services."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HTML5 from a config entry."""
hass.async_create_task(

View File

@@ -11,5 +11,18 @@ ATTR_VAPID_EMAIL = "vapid_email"
REGISTRATIONS_FILE = "html5_push_registrations.conf"
ATTR_ACTION = "action"
ATTR_ACTIONS = "actions"
ATTR_BADGE = "badge"
ATTR_DATA = "data"
ATTR_DIR = "dir"
ATTR_ICON = "icon"
ATTR_IMAGE = "image"
ATTR_LANG = "lang"
ATTR_RENOTIFY = "renotify"
ATTR_REQUIRE_INTERACTION = "require_interaction"
ATTR_SILENT = "silent"
ATTR_TAG = "tag"
ATTR_TIMESTAMP = "timestamp"
ATTR_TTL = "ttl"
ATTR_URGENCY = "urgency"
ATTR_VIBRATE = "vibrate"

View File

@@ -9,6 +9,9 @@
"services": {
"dismiss": {
"service": "mdi:bell-off"
},
"send_message": {
"service": "mdi:message-arrow-right"
}
}
}

View File

@@ -0,0 +1,31 @@
"""Issues for HTML5 integration."""
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util import slugify
from .const import DOMAIN
@callback
def deprecated_notify_action_call(
hass: HomeAssistant, target: list[str] | None
) -> None:
"""Deprecated action call."""
action = (
f"notify.html5_{slugify(target[0])}"
if target and len(target) == 1
else "notify.html5"
)
async_create_issue(
hass,
DOMAIN,
f"deprecated_notify_action_{action}",
breaks_in_ha_version="2026.11.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_notify_action",
translation_placeholders={"action": action},
)

View File

@@ -1,7 +1,7 @@
{
"domain": "html5",
"name": "HTML5 Push Notifications",
"codeowners": ["@alexyao2015"],
"codeowners": ["@alexyao2015", "@tr4nt0r"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/html5",

View File

@@ -47,7 +47,11 @@ from homeassistant.util.json import load_json_object
from .const import (
ATTR_ACTION,
ATTR_ACTIONS,
ATTR_REQUIRE_INTERACTION,
ATTR_TAG,
ATTR_TIMESTAMP,
ATTR_TTL,
ATTR_VAPID_EMAIL,
ATTR_VAPID_PRV_KEY,
ATTR_VAPID_PUB_KEY,
@@ -56,6 +60,7 @@ from .const import (
SERVICE_DISMISS,
)
from .entity import HTML5Entity, Registration
from .issue import deprecated_notify_action_call
_LOGGER = logging.getLogger(__name__)
@@ -69,13 +74,11 @@ ATTR_AUTH = "auth"
ATTR_P256DH = "p256dh"
ATTR_EXPIRATIONTIME = "expirationTime"
ATTR_ACTIONS = "actions"
ATTR_TYPE = "type"
ATTR_URL = "url"
ATTR_DISMISS = "dismiss"
ATTR_PRIORITY = "priority"
DEFAULT_PRIORITY = "normal"
ATTR_TTL = "ttl"
DEFAULT_TTL = 86400
DEFAULT_BADGE = "/static/images/notification-badge.png"
@@ -465,6 +468,9 @@ class HTML5NotificationService(BaseNotificationService):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET))
tag = str(uuid.uuid4())
payload: dict[str, Any] = {
"badge": DEFAULT_BADGE,
@@ -605,32 +611,53 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
_key = "device"
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to a device."""
timestamp = int(time.time())
tag = str(uuid.uuid4())
"""Send a message to a device via notify.send_message action."""
await self._webpush(
title=title or ATTR_TITLE_DEFAULT,
message=message,
badge=DEFAULT_BADGE,
icon=DEFAULT_ICON,
)
payload: dict[str, Any] = {
"badge": DEFAULT_BADGE,
"body": message,
"icon": DEFAULT_ICON,
ATTR_TAG: tag,
ATTR_TITLE: title or ATTR_TITLE_DEFAULT,
"timestamp": timestamp * 1000,
ATTR_DATA: {
ATTR_JWT: add_jwt(
timestamp,
self.target,
tag,
self.registration["subscription"]["keys"]["auth"],
)
},
}
async def send_push_notification(self, **kwargs: Any) -> None:
"""Send a message to a device via html5.send_message action."""
await self._webpush(**kwargs)
self._async_record_notification()
async def _webpush(
self,
message: str | None = None,
timestamp: datetime | None = None,
ttl: timedelta | None = None,
urgency: str | None = None,
**kwargs: Any,
) -> None:
"""Shared internal helper to push messages."""
payload: dict[str, Any] = kwargs
if message is not None:
payload["body"] = message
payload.setdefault(ATTR_TAG, str(uuid.uuid4()))
ts = int(timestamp.timestamp()) if timestamp else int(time.time())
payload[ATTR_TIMESTAMP] = ts * 1000
if ATTR_REQUIRE_INTERACTION in payload:
payload["requireInteraction"] = payload.pop(ATTR_REQUIRE_INTERACTION)
payload.setdefault(ATTR_DATA, {})
payload[ATTR_DATA][ATTR_JWT] = add_jwt(
ts,
self.target,
payload[ATTR_TAG],
self.registration["subscription"]["keys"]["auth"],
)
endpoint = urlparse(self.registration["subscription"]["endpoint"])
vapid_claims = {
"sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}",
"aud": f"{endpoint.scheme}://{endpoint.netloc}",
"exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
"exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
}
try:
@@ -639,6 +666,8 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
json.dumps(payload),
self.config_entry.data[ATTR_VAPID_PRV_KEY],
vapid_claims,
ttl=int(ttl.total_seconds()) if ttl is not None else DEFAULT_TTL,
headers={"Urgency": urgency} if urgency else None,
aiohttp_session=self.session,
)
cast(ClientResponse, response).raise_for_status()

View File

@@ -0,0 +1,82 @@
"""Service registration for HTML5 integration."""
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
ATTR_MESSAGE,
ATTR_TITLE,
ATTR_TITLE_DEFAULT,
DOMAIN as NOTIFY_DOMAIN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import (
ATTR_ACTION,
ATTR_ACTIONS,
ATTR_BADGE,
ATTR_DIR,
ATTR_ICON,
ATTR_IMAGE,
ATTR_LANG,
ATTR_RENOTIFY,
ATTR_REQUIRE_INTERACTION,
ATTR_SILENT,
ATTR_TAG,
ATTR_TIMESTAMP,
ATTR_TTL,
ATTR_URGENCY,
ATTR_VIBRATE,
DOMAIN,
)
SERVICE_SEND_MESSAGE = "send_message"
SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema(
{
vol.Required(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string,
vol.Optional(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_DIR): vol.In({"auto", "ltr", "rtl"}),
vol.Optional(ATTR_ICON): cv.string,
vol.Optional(ATTR_BADGE): cv.string,
vol.Optional(ATTR_IMAGE): cv.string,
vol.Optional(ATTR_TAG): cv.string,
vol.Exclusive(ATTR_VIBRATE, "silent_xor_vibrate"): vol.All(
cv.ensure_list,
[vol.All(vol.Coerce(int), vol.Range(min=0))],
),
vol.Optional(ATTR_TIMESTAMP): cv.datetime,
vol.Optional(ATTR_LANG): cv.language,
vol.Exclusive(ATTR_SILENT, "silent_xor_vibrate"): cv.boolean,
vol.Optional(ATTR_RENOTIFY): cv.boolean,
vol.Optional(ATTR_REQUIRE_INTERACTION): cv.boolean,
vol.Optional(ATTR_URGENCY): vol.In({"normal", "high", "low"}),
vol.Optional(ATTR_TTL): vol.All(cv.time_period, cv.positive_timedelta),
vol.Optional(ATTR_ACTIONS): vol.All(
cv.ensure_list,
[
{
vol.Required(ATTR_ACTION): cv.string,
vol.Required(ATTR_TITLE): cv.string,
vol.Optional(ATTR_ICON): cv.string,
}
],
),
vol.Optional(ATTR_DATA): dict,
}
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for HTML5 integration."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SEND_MESSAGE,
entity_domain=NOTIFY_DOMAIN,
schema=SERVICE_SEND_MESSAGE_SCHEMA,
func="send_push_notification",
)

View File

@@ -8,3 +8,137 @@ dismiss:
example: '{ "tag": "tagname" }'
selector:
object:
send_message:
target:
entity:
domain: notify
integration: html5
fields:
title:
required: true
selector:
text:
example: Home Assistant
default: Home Assistant
message:
required: false
selector:
text:
multiline: true
example: Hello World
icon:
required: false
selector:
text:
type: url
example: /static/icons/favicon-192x192.png
badge:
required: false
selector:
text:
type: url
example: /static/images/notification-badge.png
image:
required: false
selector:
text:
type: url
example: /static/images/image.jpg
tag:
required: false
selector:
text:
example: message-group-1
actions:
selector:
object:
label_field: "action"
description_field: "title"
multiple: true
translation_key: actions
fields:
action:
required: true
selector:
text:
title:
required: true
selector:
text:
icon:
selector:
text:
type: url
example: '[{"action": "test-action", "title": "🆗 Click here!", "icon": "/images/action-1-128x128.png"}]'
dir:
required: false
selector:
select:
options:
- auto
- ltr
- rtl
mode: dropdown
translation_key: dir
example: auto
renotify:
required: false
selector:
constant:
value: true
label: ""
example: true
silent:
required: false
selector:
constant:
value: true
label: ""
example: true
require_interaction:
required: false
selector:
constant:
value: true
label: ""
example: true
vibrate:
required: false
selector:
text:
multiple: true
type: number
suffix: ms
example: "[125,75,125,275,200,275,125,75,125,275,200,600,200,600]"
lang:
required: false
selector:
language:
example: es-419
timestamp:
required: false
selector:
datetime:
example: "1970-01-01 00:00:00"
ttl:
required: false
selector:
duration:
enable_day: true
example: "{'days': 28}"
urgency:
required: false
selector:
select:
options:
- low
- normal
- high
mode: dropdown
translation_key: urgency
example: normal
data:
required: false
selector:
object:
example: "{'customKey': 'customValue'}"

View File

@@ -48,6 +48,44 @@
"message": "Sending notification to {target} failed due to a request error"
}
},
"issues": {
"deprecated_notify_action": {
"description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `notify.send_message` or `html5.send_message` actions instead.",
"title": "Detected use of deprecated action {action}"
}
},
"selector": {
"actions": {
"fields": {
"action": {
"description": "The identifier of the action. This will be sent back to Home Assistant when the user clicks the button.",
"name": "Action identifier"
},
"icon": {
"description": "URL of an image displayed as the icon for this button.",
"name": "Icon"
},
"title": {
"description": "The label of the button displayed to the user.",
"name": "Title"
}
}
},
"dir": {
"options": {
"auto": "[%key:common::state::auto%]",
"ltr": "Left-to-right",
"rtl": "Right-to-left"
}
},
"urgency": {
"options": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"normal": "[%key:common::state::normal%]"
}
}
},
"services": {
"dismiss": {
"description": "Dismisses an HTML5 notification.",
@@ -62,6 +100,80 @@
}
},
"name": "Dismiss"
},
"send_message": {
"description": "Sends a message via HTML5 Push Notifications",
"fields": {
"actions": {
"description": "Adds action buttons to the notification. When the user clicks a button, an event is sent back to Home Assistant. Amount of actions supported may vary between platforms.",
"name": "Action buttons"
},
"badge": {
"description": "URL or relative path of a small image to replace the browser icon on mobile platforms. Maximum size is 96px by 96px",
"name": "Badge"
},
"data": {
"description": "Additional custom key-value pairs to include in the payload of the push message. This can be used to include extra information that can be accessed in the notification events.",
"name": "Extra data"
},
"dir": {
"description": "The direction of the notification's text. Adopts the browser's language setting behavior by default.",
"name": "Text direction"
},
"icon": {
"description": "URL or relative path of an image to display as the main icon in the notification. Maximum size is 320px by 320px.",
"name": "Icon"
},
"image": {
"description": "URL or relative path of a larger image to display in the main body of the notification. Experimental support, may not be displayed on all platforms.",
"name": "Image"
},
"lang": {
"description": "The language of the notification's content.",
"name": "Language"
},
"message": {
"description": "The message body of the notification.",
"name": "Message"
},
"renotify": {
"description": "If enabled, the user will be alerted again (sound/vibration) when a notification with the same tag replaces a previous one.",
"name": "Renotify"
},
"require_interaction": {
"description": "If enabled, the notification will remain active until the user clicks or dismisses it, rather than automatically closing after a few seconds. This provides the same behavior on desktop as on mobile platforms.",
"name": "Require interaction"
},
"silent": {
"description": "If enabled, the notification will not play sounds or trigger vibration, regardless of the device's notification settings.",
"name": "Silent"
},
"tag": {
"description": "The identifier of the notification. Sending a new notification with the same tag will replace the existing one. If not specified, a unique tag will be generated for each notification.",
"name": "Tag"
},
"timestamp": {
"description": "The timestamp of the notification. By default, it uses the time when the notification is sent.",
"name": "Timestamp"
},
"title": {
"description": "Title for your notification message.",
"name": "Title"
},
"ttl": {
"description": "Specifies how long the push service should retain the message if the user's browser or device is offline. After this period, the notification expires. A value of 0 means the notification is discarded immediately if the target is not connected. Defaults to 1 day.",
"name": "Time to live"
},
"urgency": {
"description": "Whether the push service should try to deliver the notification immediately or defer it in accordance with the user's power saving preferences.",
"name": "Urgency"
},
"vibrate": {
"description": "A vibration pattern to run with the notification. An array of integers representing alternating periods of vibration and silence in milliseconds. For example, [200, 100, 200] would vibrate for 200ms, pause for 100ms, then vibrate for another 200ms.",
"name": "Vibration pattern"
}
},
"name": "Send message"
}
}
}

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
from typing import Any
from huum.const import SaunaStatus
@@ -18,12 +17,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP, DOMAIN
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
from .entity import HuumBaseEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
@@ -113,5 +110,7 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
try:
await self.coordinator.huum.turn_on(temperature)
except (ValueError, SafetyException) as err:
_LOGGER.error(str(err))
raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unable_to_turn_on",
) from err

View File

@@ -56,5 +56,6 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
return await self.huum.status()
except (Forbidden, NotAuthenticated) as err:
raise ConfigEntryAuthFailed(
"Could not log in to Huum with given credentials"
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err

View File

@@ -62,7 +62,7 @@ rules:
status: exempt
comment: All entities are core functionality.
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:

View File

@@ -45,5 +45,13 @@
"name": "[%key:component::sensor::entity_component::humidity::name%]"
}
}
},
"exceptions": {
"auth_failed": {
"message": "Could not log in to Huum with the given credentials."
},
"unable_to_turn_on": {
"message": "Unable to turn on the sauna."
}
}
}

View File

@@ -73,31 +73,45 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
except HTTPError as error:
raise UpdateFailed from error
try:
# Fetch last hour of data
for sensor in self.devices:
# Fetch last hour of data
for sensor in self.devices:
try:
data = await self.api.get_sensor_status(
sensor=sensor,
tz=self.hass.config.time_zone,
)
_LOGGER.debug("Got data: %s", data)
except HTTPError as error:
error_data = error.args[1] if len(error.args) > 1 else None
if (
isinstance(error_data, dict)
and error_data.get("error") == "no_readings"
):
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
) from error
if data_error := data.get("error"):
if data_error == "no_readings":
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
_LOGGER.debug("Error: %s", data_error)
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
)
_LOGGER.debug("Got data: %s", data)
sensor.data = data["data"]["current"]
if data_error := data.get("error"):
if data_error == "no_readings":
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
_LOGGER.debug("Error: %s", data_error)
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
)
except HTTPError as error:
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
) from error
current_data = data.get("data", {}).get("current")
if current_data is None:
sensor.data = None
_LOGGER.debug("No current data payload for %s", sensor.name)
continue
sensor.data = current_data
# Verify that we have permission to read the sensors
for sensor in self.devices:

View File

@@ -6,19 +6,14 @@ from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.helpers import is_valid_warning_department
from requests import RequestException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import (
COORDINATOR_ALERT,
COORDINATOR_FORECAST,
COORDINATOR_RAIN,
DOMAIN,
PLATFORMS,
)
from .const import DOMAIN, PLATFORMS
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceConfigEntry,
MeteoFranceData,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
@@ -26,7 +21,7 @@ from .coordinator import (
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MeteoFranceConfigEntry) -> bool:
"""Set up a Meteo-France account from a config entry."""
hass.data.setdefault(DOMAIN, {})
@@ -91,25 +86,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
hass.data[DOMAIN][entry.entry_id] = {
COORDINATOR_FORECAST: coordinator_forecast,
}
if coordinator_rain and coordinator_rain.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain
if coordinator_alert and coordinator_alert.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert
if coordinator_rain and not coordinator_rain.last_update_success:
coordinator_rain = None
if coordinator_alert and not coordinator_alert.last_update_success:
coordinator_alert = None
entry.runtime_data = MeteoFranceData(
forecast_coordinator=coordinator_forecast,
rain_coordinator=coordinator_rain,
alert_coordinator=coordinator_alert,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: MeteoFranceConfigEntry
) -> bool:
"""Unload a config entry."""
if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]:
department = hass.data[DOMAIN][entry.entry_id][
COORDINATOR_FORECAST
].data.position.get("dept")
if entry.runtime_data.alert_coordinator:
department = entry.runtime_data.forecast_coordinator.data.position.get("dept")
hass.data[DOMAIN][department] = False
_LOGGER.debug(
(
@@ -121,13 +118,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)
return unload_ok
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def _async_update_listener(
hass: HomeAssistant, entry: MeteoFranceConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -23,9 +23,6 @@ from homeassistant.const import Platform
DOMAIN = "meteo_france"
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
COORDINATOR_FORECAST = "coordinator_forecast"
COORDINATOR_RAIN = "coordinator_rain"
COORDINATOR_ALERT = "coordinator_alert"
ATTRIBUTION = "Data provided by Météo-France"
MODEL = "Météo-France mobile API"
MANUFACTURER = "Météo-France"

View File

@@ -1,5 +1,8 @@
"""Support for Meteo-France weather data."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -13,6 +16,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type MeteoFranceConfigEntry = ConfigEntry[MeteoFranceData]
@dataclass
class MeteoFranceData:
"""Data for the Meteo-France integration."""
forecast_coordinator: MeteoFranceForecastUpdateCoordinator
rain_coordinator: MeteoFranceRainUpdateCoordinator | None
alert_coordinator: MeteoFranceAlertUpdateCoordinator | None
SCAN_INTERVAL_RAIN = timedelta(minutes=5)
SCAN_INTERVAL = timedelta(minutes=15)
@@ -20,12 +35,12 @@ SCAN_INTERVAL = timedelta(minutes=15)
class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
"""Coordinator for Meteo-France forecast data."""
config_entry: ConfigEntry
config_entry: MeteoFranceConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
@@ -50,12 +65,12 @@ class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
"""Coordinator for Meteo-France rain data."""
config_entry: ConfigEntry
config_entry: MeteoFranceConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
@@ -80,12 +95,12 @@ class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
class MeteoFranceAlertUpdateCoordinator(DataUpdateCoordinator[CurrentPhenomenons]):
"""Coordinator for Meteo-France alert data."""
config_entry: ConfigEntry
config_entry: MeteoFranceConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
client: MeteoFranceClient,
department: str,
) -> None:

View File

@@ -19,7 +19,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
UV_INDEX,
@@ -41,18 +40,11 @@ from .const import (
ATTR_NEXT_RAIN_1_HOUR_FORECAST,
ATTR_NEXT_RAIN_DT_REF,
ATTRIBUTION,
COORDINATOR_ALERT,
COORDINATOR_FORECAST,
COORDINATOR_RAIN,
DOMAIN,
MANUFACTURER,
MODEL,
)
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
from .coordinator import MeteoFranceAlertUpdateCoordinator, MeteoFranceConfigEntry
@dataclass(frozen=True, kw_only=True)
@@ -188,20 +180,13 @@ SENSOR_TYPES_PROBABILITY: tuple[MeteoFranceSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France sensor platform."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator_forecast: MeteoFranceForecastUpdateCoordinator = data[
COORDINATOR_FORECAST
]
coordinator_rain: MeteoFranceRainUpdateCoordinator | None = data.get(
COORDINATOR_RAIN
)
coordinator_alert: MeteoFranceAlertUpdateCoordinator | None = data.get(
COORDINATOR_ALERT
)
coordinator_forecast = entry.runtime_data.forecast_coordinator
coordinator_rain = entry.runtime_data.rain_coordinator
coordinator_alert = entry.runtime_data.alert_coordinator
entities: list[MeteoFranceSensor[Any]] = [
MeteoFranceSensor(coordinator_forecast, description)

View File

@@ -18,7 +18,6 @@ from homeassistant.components.weather import (
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_MODE,
UnitOfPrecipitationDepth,
@@ -35,14 +34,13 @@ from homeassistant.util import dt as dt_util
from .const import (
ATTRIBUTION,
CONDITION_MAP,
COORDINATOR_FORECAST,
DOMAIN,
FORECAST_MODE_DAILY,
FORECAST_MODE_HOURLY,
MANUFACTURER,
MODEL,
)
from .coordinator import MeteoFranceForecastUpdateCoordinator
from .coordinator import MeteoFranceConfigEntry, MeteoFranceForecastUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -58,13 +56,11 @@ def format_condition(condition: str, force_day: bool = False) -> str:
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MeteoFranceConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France weather platform."""
coordinator: MeteoFranceForecastUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
][COORDINATOR_FORECAST]
coordinator = entry.runtime_data.forecast_coordinator
async_add_entities(
[

View File

@@ -2,11 +2,9 @@
import asyncio
import logging
from typing import TYPE_CHECKING
from motionblinds import AsyncMotionMulticast
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -14,32 +12,28 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .const import (
CONF_BLIND_TYPE_LIST,
CONF_INTERFACE,
CONF_WAIT_FOR_PUSH,
DEFAULT_INTERFACE,
DEFAULT_WAIT_FOR_PUSH,
DOMAIN,
KEY_API_LOCK,
KEY_COORDINATOR,
KEY_GATEWAY,
KEY_MULTICAST_LISTENER,
KEY_SETUP_LOCK,
KEY_UNSUB_STOP,
PLATFORMS,
)
from .coordinator import DataUpdateCoordinatorMotionBlinds
from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry
from .gateway import ConnectMotionGateway
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(
hass: HomeAssistant, entry: MotionBlindsConfigEntry
) -> bool:
"""Set up the motion_blinds components from a config entry."""
hass.data.setdefault(DOMAIN, {})
setup_lock = hass.data[DOMAIN].setdefault(KEY_SETUP_LOCK, asyncio.Lock())
host = entry.data[CONF_HOST]
key = entry.data[CONF_API_KEY]
multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE)
wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH)
blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST)
# Create multicast Listener
@@ -88,15 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
):
raise ConfigEntryNotReady
motion_gateway = connect_gateway_class.gateway_device
api_lock = asyncio.Lock()
coordinator_info = {
KEY_GATEWAY: motion_gateway,
KEY_API_LOCK: api_lock,
CONF_WAIT_FOR_PUSH: wait_for_push,
}
coordinator = DataUpdateCoordinatorMotionBlinds(
hass, entry, _LOGGER, coordinator_info
hass, entry, _LOGGER, motion_gateway
)
# store blind type list for next time
@@ -110,20 +98,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Fetch initial data so we have data when entities subscribe
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = {
KEY_GATEWAY: motion_gateway,
KEY_COORDINATOR: coordinator,
}
if TYPE_CHECKING:
assert entry.unique_id is not None
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, config_entry: MotionBlindsConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
@@ -132,7 +116,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
if unload_ok:
multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER]
multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST])
hass.data[DOMAIN].pop(config_entry.entry_id)
if not hass.config_entries.async_loaded_entries(DOMAIN):
# No motion gateways left, stop Motion multicast

View File

@@ -5,25 +5,23 @@ from __future__ import annotations
from motionblinds.motion_blinds import LimitStatus, MotionBlind
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
from .coordinator import DataUpdateCoordinatorMotionBlinds
from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry
from .entity import MotionCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: MotionBlindsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Motionblinds."""
entities: list[ButtonEntity] = []
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
coordinator = config_entry.runtime_data
motion_gateway = coordinator.gateway
for blind in motion_gateway.device_list.values():
if blind.limit_status in (

View File

@@ -9,7 +9,6 @@ from motionblinds import MotionDiscovery, MotionGateway
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
@@ -27,6 +26,7 @@ from .const import (
DEFAULT_WAIT_FOR_PUSH,
DOMAIN,
)
from .coordinator import MotionBlindsConfigEntry
from .gateway import ConnectMotionGateway
_LOGGER = logging.getLogger(__name__)
@@ -79,7 +79,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
config_entry: MotionBlindsConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow."""
return OptionsFlowHandler()

View File

@@ -16,7 +16,6 @@ DEFAULT_INTERFACE = "any"
KEY_GATEWAY = "gateway"
KEY_API_LOCK = "api_lock"
KEY_COORDINATOR = "coordinator"
KEY_MULTICAST_LISTENER = "multicast_listener"
KEY_SETUP_LOCK = "setup_lock"
KEY_UNSUB_STOP = "unsub_stop"

View File

@@ -1,11 +1,12 @@
"""DataUpdateCoordinator for Motionblinds integration."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any
from motionblinds import DEVICE_TYPES_WIFI, ParseException
from motionblinds import DEVICE_TYPES_WIFI, MotionGateway, ParseException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -14,7 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
ATTR_AVAILABLE,
CONF_WAIT_FOR_PUSH,
KEY_API_LOCK,
DEFAULT_WAIT_FOR_PUSH,
KEY_GATEWAY,
UPDATE_INTERVAL,
UPDATE_INTERVAL_FAST,
@@ -23,17 +24,20 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
type MotionBlindsConfigEntry = ConfigEntry[DataUpdateCoordinatorMotionBlinds]
class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
"""Class to manage fetching data from single endpoint."""
config_entry: ConfigEntry
config_entry: MotionBlindsConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: MotionBlindsConfigEntry,
logger: logging.Logger,
coordinator_info: dict[str, Any],
gateway: MotionGateway,
) -> None:
"""Initialize global data updater."""
super().__init__(
@@ -44,14 +48,16 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
self.api_lock = coordinator_info[KEY_API_LOCK]
self._gateway = coordinator_info[KEY_GATEWAY]
self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH]
self.api_lock = asyncio.Lock()
self.gateway = gateway
self._wait_for_push = config_entry.options.get(
CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH
)
def update_gateway(self):
"""Fetch data from gateway."""
try:
self._gateway.Update()
self.gateway.Update()
except TimeoutError, ParseException:
# let the error be logged and handled by the motionblinds library
return {ATTR_AVAILABLE: False}
@@ -82,7 +88,7 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
self.update_gateway
)
for blind in self._gateway.device_list.values():
for blind in self.gateway.device_list.values():
await asyncio.sleep(1.5)
async with self.api_lock:
data[blind.mac] = await self.hass.async_add_executor_job(

View File

@@ -15,7 +15,6 @@ from homeassistant.components.cover import (
CoverEntity,
CoverEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -25,12 +24,11 @@ from .const import (
ATTR_ABSOLUTE_POSITION,
ATTR_AVAILABLE,
ATTR_WIDTH,
DOMAIN,
KEY_COORDINATOR,
KEY_GATEWAY,
SERVICE_SET_ABSOLUTE_POSITION,
UPDATE_DELAY_STOP,
)
from .coordinator import MotionBlindsConfigEntry
from .entity import MotionCoordinatorEntity
_LOGGER = logging.getLogger(__name__)
@@ -84,13 +82,13 @@ SET_ABSOLUTE_POSITION_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: MotionBlindsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Motion Blind from a config entry."""
entities: list[MotionBaseDevice] = []
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
coordinator = config_entry.runtime_data
motion_gateway = coordinator.gateway
for blind in motion_gateway.device_list.values():
if blind.type in POSITION_DEVICE_MAP:

View File

@@ -10,7 +10,6 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@@ -19,7 +18,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
from .coordinator import MotionBlindsConfigEntry
from .entity import MotionCoordinatorEntity
ATTR_BATTERY_VOLTAGE = "battery_voltage"
@@ -27,13 +26,13 @@ ATTR_BATTERY_VOLTAGE = "battery_voltage"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: MotionBlindsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Motionblinds."""
entities: list[SensorEntity] = []
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
coordinator = config_entry.runtime_data
motion_gateway = coordinator.gateway
for blind in motion_gateway.device_list.values():
entities.append(MotionSignalStrengthSensor(coordinator, blind))

View File

@@ -45,7 +45,7 @@ from homeassistant.components.webhook import (
async_register as webhook_register,
async_unregister as webhook_unregister,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, CONF_URL, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -80,7 +80,7 @@ from .const import (
WEB_HOOK_SENTINEL_KEY,
WEB_HOOK_SENTINEL_VALUE,
)
from .coordinator import MotionEyeUpdateCoordinator
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
@@ -134,7 +134,7 @@ def is_acceptable_camera(camera: dict[str, Any] | None) -> bool:
@callback
def listen_for_new_cameras(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
add_func: Callable,
) -> None:
"""Listen for new cameras."""
@@ -168,7 +168,7 @@ def _add_camera(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
client: MotionEyeClient,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
camera_id: int,
camera: dict[str, Any],
device_identifier: tuple[str, str],
@@ -274,9 +274,8 @@ def _add_camera(
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool:
"""Set up motionEye from a config entry."""
hass.data.setdefault(DOMAIN, {})
client = create_motioneye_client(
entry.data[CONF_URL],
@@ -306,7 +305,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
coordinator = MotionEyeUpdateCoordinator(hass, entry, client)
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
current_cameras: set[tuple[str, str]] = set()
device_registry = dr.async_get(hass)
@@ -362,14 +361,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool:
"""Unload a config entry."""
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
coordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.client.async_client_close()
await entry.runtime_data.client.async_client_close()
return unload_ok
@@ -438,10 +436,14 @@ def _get_media_event_data(
event_file_type: int,
) -> dict[str, str]:
config_entry_id = next(iter(device.config_entries), None)
if not config_entry_id or config_entry_id not in hass.data[DOMAIN]:
if (
not config_entry_id
or not (entry := hass.config_entries.async_get_entry(config_entry_id))
or entry.state != ConfigEntryState.LOADED
):
return {}
coordinator = hass.data[DOMAIN][config_entry_id]
coordinator: MotionEyeUpdateCoordinator = entry.runtime_data
client = coordinator.client
for identifier in device.identifiers:

View File

@@ -30,7 +30,6 @@ from homeassistant.components.mjpeg import (
CONF_STILL_IMAGE_URL,
MjpegCamera,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_AUTHENTICATION,
CONF_NAME,
@@ -50,14 +49,13 @@ from .const import (
CONF_STREAM_URL_TEMPLATE,
CONF_SURVEILLANCE_PASSWORD,
CONF_SURVEILLANCE_USERNAME,
DOMAIN,
MOTIONEYE_MANUFACTURER,
SERVICE_ACTION,
SERVICE_SET_TEXT_OVERLAY,
SERVICE_SNAPSHOT,
TYPE_MOTIONEYE_MJPEG_CAMERA,
)
from .coordinator import MotionEyeUpdateCoordinator
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
PLATFORMS = [Platform.CAMERA]
@@ -92,11 +90,11 @@ SCHEMA_SERVICE_SET_TEXT = vol.Schema(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
@callback
def camera_add(camera: dict[str, Any]) -> None:

View File

@@ -14,7 +14,6 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
@@ -39,6 +38,7 @@ from .const import (
DEFAULT_WEBHOOK_SET_OVERWRITE,
DOMAIN,
)
from .coordinator import MotionEyeConfigEntry
class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -180,7 +180,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
config_entry: MotionEyeConfigEntry,
) -> MotionEyeOptionsFlow:
"""Get the Hyperion Options flow."""
return MotionEyeOptionsFlow()

View File

@@ -16,13 +16,16 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type MotionEyeConfigEntry = ConfigEntry[MotionEyeUpdateCoordinator]
class MotionEyeUpdateCoordinator(DataUpdateCoordinator[dict[str, Any] | None]):
"""Coordinator for motionEye data."""
config_entry: ConfigEntry
config_entry: MotionEyeConfigEntry
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, client: MotionEyeClient
self, hass: HomeAssistant, entry: MotionEyeConfigEntry, client: MotionEyeClient
) -> None:
"""Initialize the coordinator."""
super().__init__(

View File

@@ -17,12 +17,13 @@ from homeassistant.components.media_source import (
PlayMedia,
Unresolvable,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from . import get_media_url, split_motioneye_device_identifier
from .const import DOMAIN
from .coordinator import MotionEyeConfigEntry
MIME_TYPE_MAP = {
"movies": "video/mp4",
@@ -74,7 +75,7 @@ class MotionEyeMediaSource(MediaSource):
self._verify_kind_or_raise(kind)
url = get_media_url(
self.hass.data[DOMAIN][config.entry_id].client,
config.runtime_data.client,
self._get_camera_id_or_raise(config, device),
self._get_path_or_raise(path),
kind == "images",
@@ -120,10 +121,10 @@ class MotionEyeMediaSource(MediaSource):
return self._build_media_devices(config)
return self._build_media_configs()
def _get_config_or_raise(self, config_id: str) -> ConfigEntry:
def _get_config_or_raise(self, config_id: str) -> MotionEyeConfigEntry:
"""Get a config entry from a URL."""
entry = self.hass.config_entries.async_get_entry(config_id)
if not entry:
if not entry or entry.state != ConfigEntryState.LOADED:
raise MediaSourceError(f"Unable to find config entry with id: {config_id}")
return entry
@@ -154,7 +155,7 @@ class MotionEyeMediaSource(MediaSource):
@classmethod
def _get_camera_id_or_raise(
cls, config: ConfigEntry, device: dr.DeviceEntry
cls, config: MotionEyeConfigEntry, device: dr.DeviceEntry
) -> int:
"""Get a config entry from a URL."""
for identifier in device.identifiers:
@@ -164,7 +165,7 @@ class MotionEyeMediaSource(MediaSource):
raise MediaSourceError(f"Could not find camera id for device id: {device.id}")
@classmethod
def _build_media_config(cls, config: ConfigEntry) -> BrowseMediaSource:
def _build_media_config(cls, config: MotionEyeConfigEntry) -> BrowseMediaSource:
return BrowseMediaSource(
domain=DOMAIN,
identifier=config.entry_id,
@@ -196,7 +197,7 @@ class MotionEyeMediaSource(MediaSource):
@classmethod
def _build_media_device(
cls,
config: ConfigEntry,
config: MotionEyeConfigEntry,
device: dr.DeviceEntry,
full_title: bool = True,
) -> BrowseMediaSource:
@@ -211,7 +212,7 @@ class MotionEyeMediaSource(MediaSource):
children_media_class=MediaClass.DIRECTORY,
)
def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource:
def _build_media_devices(self, config: MotionEyeConfigEntry) -> BrowseMediaSource:
"""Build the media sources for device entries."""
device_registry = dr.async_get(self.hass)
devices = dr.async_entries_for_config_entry(device_registry, config.entry_id)
@@ -226,7 +227,7 @@ class MotionEyeMediaSource(MediaSource):
@classmethod
def _build_media_kind(
cls,
config: ConfigEntry,
config: MotionEyeConfigEntry,
device: dr.DeviceEntry,
kind: str,
full_title: bool = True,
@@ -251,7 +252,7 @@ class MotionEyeMediaSource(MediaSource):
)
def _build_media_kinds(
self, config: ConfigEntry, device: dr.DeviceEntry
self, config: MotionEyeConfigEntry, device: dr.DeviceEntry
) -> BrowseMediaSource:
base = self._build_media_device(config, device)
base.children = [
@@ -262,7 +263,7 @@ class MotionEyeMediaSource(MediaSource):
async def _build_media_path(
self,
config: ConfigEntry,
config: MotionEyeConfigEntry,
device: dr.DeviceEntry,
kind: str,
path: str,
@@ -276,7 +277,7 @@ class MotionEyeMediaSource(MediaSource):
base.children = []
client = self.hass.data[DOMAIN][config.entry_id].client
client = config.runtime_data.client
camera_id = self._get_camera_id_or_raise(config, device)
if kind == "movies":
@@ -286,7 +287,7 @@ class MotionEyeMediaSource(MediaSource):
sub_dirs: set[str] = set()
parts = parsed_path.parts
media_list = resp.get(KEY_MEDIA_LIST, [])
media_list = resp.get(KEY_MEDIA_LIST, []) if resp else []
def get_media_sort_key(media: dict) -> str:
"""Get media sort key."""

View File

@@ -9,24 +9,23 @@ from motioneye_client.client import MotionEyeClient
from motioneye_client.const import KEY_ACTIONS
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import get_camera_from_cameras, listen_for_new_cameras
from .const import DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR
from .coordinator import MotionEyeUpdateCoordinator
from .const import TYPE_MOTIONEYE_ACTION_SENSOR
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
@callback
def camera_add(camera: dict[str, Any]) -> None:

View File

@@ -16,14 +16,13 @@ from motioneye_client.const import (
)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import get_camera_from_cameras, listen_for_new_cameras
from .const import DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE
from .coordinator import MotionEyeUpdateCoordinator
from .const import TYPE_MOTIONEYE_SWITCH_BASE
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
MOTIONEYE_SWITCHES = [
@@ -68,11 +67,11 @@ MOTIONEYE_SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MotionEyeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
@callback
def camera_add(camera: dict[str, Any]) -> None:

View File

@@ -311,6 +311,19 @@ def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Plat
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the actions and websocket API for the MQTT component."""
if config.get(DOMAIN) and not mqtt_config_entry_enabled(hass):
issue_registry = ir.async_get(hass)
issue_registry.async_get_or_create(
DOMAIN,
"yaml_setup_without_active_setup",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/"
"#configuration",
translation_key="yaml_setup_without_active_setup",
)
websocket_api.async_register_command(hass, websocket_subscribe)
websocket_api.async_register_command(hass, websocket_mqtt_info)

View File

@@ -18,6 +18,8 @@ ABBREVIATIONS = {
"bri_stat_t": "brightness_state_topic",
"bri_tpl": "brightness_template",
"bri_val_tpl": "brightness_value_template",
"cln_segmnts_cmd_t": "clean_segments_command_topic",
"cln_segmnts_cmd_tpl": "clean_segments_command_template",
"clr_temp_cmd_tpl": "color_temp_command_template",
"clrm_stat_t": "color_mode_state_topic",
"clrm_val_tpl": "color_mode_value_template",

View File

@@ -140,6 +140,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"entity_registry_enabled_default",
"extra_state_attributes",
"force_update",
"group_entities",
"icon",
"friendly_name",
"should_poll",

View File

@@ -1141,6 +1141,10 @@
}
},
"title": "MQTT device \"{name}\" subentry migration to YAML"
},
"yaml_setup_without_active_setup": {
"description": "Home Assistant detected manually configured MQTT items, but these items cannot be loaded because MQTT is not set up correctly. Make sure the MQTT broker is set up correctly, or remove the MQTT configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.",
"title": "MQTT is not set up correctly"
}
},
"options": {

View File

@@ -10,12 +10,13 @@ import voluptuous as vol
from homeassistant.components import vacuum
from homeassistant.components.vacuum import (
ENTITY_ID_FORMAT,
Segment,
StateVacuumEntity,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,13 +28,14 @@ from . import subscription
from .config import MQTT_BASE_SCHEMA
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import ReceiveMessage
from .models import MqttCommandTemplate, ReceiveMessage
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
PARALLEL_UPDATES = 0
FAN_SPEED = "fan_speed"
SEGMENTS = "segments"
STATE = "state"
STATE_IDLE = "idle"
@@ -52,6 +54,8 @@ POSSIBLE_STATES: dict[str, VacuumActivity] = {
STATE_CLEANING: VacuumActivity.CLEANING,
}
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic"
CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template"
CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES
CONF_PAYLOAD_TURN_ON = "payload_turn_on"
CONF_PAYLOAD_TURN_OFF = "payload_turn_off"
@@ -137,8 +141,22 @@ MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset(
MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/"
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
def validate_clean_area_config(config: ConfigType) -> ConfigType:
"""Validate clean area configuration."""
if CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config:
return config
if not config.get(CONF_UNIQUE_ID):
raise vol.Invalid(
f"Option `{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` requires `{CONF_UNIQUE_ID}` to be configured"
)
return config
_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend(
{
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
@@ -164,7 +182,10 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA)
PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config)
DISCOVERY_SCHEMA = vol.All(
_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config
)
async def async_setup_entry(
@@ -191,9 +212,11 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
_entity_id_format = ENTITY_ID_FORMAT
_attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED
_segments: list[Segment]
_command_topic: str | None
_set_fan_speed_topic: str | None
_send_command_topic: str | None
_clean_segments_command_topic: str | None = None
_payloads: dict[str, str | None]
def __init__(
@@ -229,6 +252,14 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
self._attr_supported_features = _strings_to_services(
supported_feature_strings, STRING_TO_SERVICE
)
self._clean_segments_command_topic = config.get(
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC
)
self._clean_segments_command_template = MqttCommandTemplate(
config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE),
entity=self,
).async_render
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
self._command_topic = config.get(CONF_COMMAND_TOPIC)
self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC)
@@ -262,6 +293,24 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None
)
del payload[STATE]
if (
(segments_payload := payload.pop(SEGMENTS, None))
and self._clean_segments_command_topic is not None
and isinstance(segments_payload, dict)
and (
segments := [
Segment(id=segment_id, name=str(segment_name))
for segment_id, segment_name in segments_payload.items()
]
)
):
self._segments = segments
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
if (last_seen := self.last_seen_segments) is not None and {
s.id: s for s in last_seen
} != {s.id: s for s in self._segments}:
self.async_create_segments_issue()
self._update_state_attributes(payload)
@callback
@@ -277,6 +326,20 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
"""(Re)Subscribe to topics."""
subscription.async_subscribe_topics_internal(self.hass, self._sub_state)
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
"""Perform an area clean."""
assert self._clean_segments_command_topic is not None
await self.async_publish_with_config(
self._clean_segments_command_topic,
self._clean_segments_command_template(
json_dumps(segment_ids), {"value": segment_ids}
),
)
async def async_get_segments(self) -> list[Segment]:
"""Return the available segments."""
return self._segments
async def _async_publish_command(self, feature: VacuumEntityFeature) -> None:
"""Publish a command."""
if self._command_topic is None:

View File

@@ -24,12 +24,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from homeassistant.helpers.typing import ConfigType
from . import api
from .const import DOMAIN, NEATO_LOGIN
from .const import DOMAIN
from .hub import NeatoHub
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
type NeatoConfigEntry = ConfigEntry[NeatoHub]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BUTTON,
@@ -46,9 +48,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool:
"""Set up config entry."""
hass.data.setdefault(DOMAIN, {})
if CONF_TOKEN not in entry.data:
raise ConfigEntryAuthFailed
@@ -69,7 +70,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from ex
neato_session = api.ConfigEntryAuth(hass, entry, implementation)
hass.data[DOMAIN][entry.entry_id] = neato_session
hub = NeatoHub(hass, Account(neato_session))
await hub.async_update_entry_unique_id(entry)
@@ -80,17 +80,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Failed to connect to Neato API")
raise ConfigEntryNotReady from ex
hass.data[NEATO_LOGIN] = hub
entry.runtime_data = hub
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool:
"""Unload config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -5,22 +5,21 @@ from __future__ import annotations
from pybotvac import Robot
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_ROBOTS
from . import NeatoConfigEntry
from .entity import NeatoEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato button from config entry."""
entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]]
entities = [NeatoDismissAlertButton(robot) for robot in entry.runtime_data.robots]
async_add_entities(entities, True)

View File

@@ -11,11 +11,11 @@ from pybotvac.robot import Robot
from urllib3.response import HTTPResponse
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from . import NeatoConfigEntry
from .const import SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -27,15 +27,14 @@ ATTR_GENERATED_AT = "generated_at"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato camera with config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
hub = entry.runtime_data
dev = [
NeatoCleaningMap(neato, robot, mapdata)
for robot in hass.data[NEATO_ROBOTS]
NeatoCleaningMap(hub, robot, hub.map_data)
for robot in hub.robots
if "maps" in robot.traits
]
@@ -51,9 +50,7 @@ class NeatoCleaningMap(NeatoEntity, Camera):
_attr_translation_key = "cleaning_map"
def __init__(
self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None
) -> None:
def __init__(self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any]) -> None:
"""Initialize Neato cleaning map."""
super().__init__(robot)
Camera.__init__(self)

View File

@@ -3,10 +3,6 @@
DOMAIN = "neato"
CONF_VENDOR = "vendor"
NEATO_LOGIN = "neato_login"
NEATO_MAP_DATA = "neato_map_data"
NEATO_PERSISTENT_MAPS = "neato_persistent_maps"
NEATO_ROBOTS = "neato_robots"
SCAN_INTERVAL_MINUTES = 1

View File

@@ -1,7 +1,10 @@
"""Support for Neato botvac connected vacuum cleaners."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pybotvac import Account
from urllib3.response import HTTPResponse
@@ -10,8 +13,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.util import Throttle
from .const import NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS
_LOGGER = logging.getLogger(__name__)
@@ -22,14 +23,17 @@ class NeatoHub:
"""Initialize the Neato hub."""
self._hass = hass
self.my_neato: Account = neato
self.robots: set[Any] = set()
self.persistent_maps: dict[str, Any] = {}
self.map_data: dict[str, Any] = {}
@Throttle(timedelta(minutes=1))
def update_robots(self) -> None:
"""Update the robot states."""
_LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS))
self._hass.data[NEATO_ROBOTS] = self.my_neato.robots
self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps
self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps
_LOGGER.debug("Running HUB.update_robots %s", self.robots)
self.robots = self.my_neato.robots
self.persistent_maps = self.my_neato.persistent_maps
self.map_data = self.my_neato.maps
def download_map(self, url: str) -> HTTPResponse:
"""Download a new map image."""

View File

@@ -10,12 +10,12 @@ from pybotvac.exceptions import NeatoRobotException
from pybotvac.robot import Robot
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from . import NeatoConfigEntry
from .const import SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -28,12 +28,12 @@ BATTERY = "Battery"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Neato sensor using config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
dev = [NeatoSensor(neato, robot) for robot in hass.data[NEATO_ROBOTS]]
hub = entry.runtime_data
dev = [NeatoSensor(hub, robot) for robot in hub.robots]
if not dev:
return

View File

@@ -10,12 +10,12 @@ from pybotvac.exceptions import NeatoRobotException
from pybotvac.robot import Robot
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from . import NeatoConfigEntry
from .const import SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -30,14 +30,14 @@ SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato switch with config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
hub = entry.runtime_data
dev = [
NeatoConnectedSwitch(neato, robot, type_name)
for robot in hass.data[NEATO_ROBOTS]
NeatoConnectedSwitch(hub, robot, type_name)
for robot in hub.robots
for type_name in SWITCH_TYPES
]

View File

@@ -15,22 +15,12 @@ from homeassistant.components.vacuum import (
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ACTION,
ALERTS,
ERRORS,
MODE,
NEATO_LOGIN,
NEATO_MAP_DATA,
NEATO_PERSISTENT_MAPS,
NEATO_ROBOTS,
SCAN_INTERVAL_MINUTES,
)
from . import NeatoConfigEntry
from .const import ACTION, ALERTS, ERRORS, MODE, SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -52,16 +42,16 @@ ATTR_LAUNCHED_FROM = "launched_from"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: NeatoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato vacuum with config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS)
hub = entry.runtime_data
dev = [
NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps)
for robot in hass.data[NEATO_ROBOTS]
NeatoConnectedVacuum(
hub, robot, hub.map_data or None, hub.persistent_maps or None
)
for robot in hub.robots
]
if not dev:

View File

@@ -1 +1 @@
"""The ohmconnect component."""
"""The OhmConnect integration."""

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.httpx_client import get_async_client
from .const import LOGGER
from .const import CONF_WEB_SEARCH, LOGGER
PLATFORMS = [Platform.AI_TASK, Platform.CONVERSATION]
@@ -56,3 +56,32 @@ async def _async_update_listener(
async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool:
"""Unload OpenRouter."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, entry: OpenRouterConfigEntry
) -> bool:
"""Migrate config entry."""
LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
if entry.version > 1 or (entry.version == 1 and entry.minor_version > 2):
return False
if entry.version == 1 and entry.minor_version < 2:
for subentry in entry.subentries.values():
if CONF_WEB_SEARCH in subentry.data:
continue
updated_data = {**subentry.data, CONF_WEB_SEARCH: False}
hass.config_entries.async_update_subentry(
entry, subentry, data=updated_data
)
hass.config_entries.async_update_entry(entry, minor_version=2)
LOGGER.info(
"Migration to version %s.%s successful", entry.version, entry.minor_version
)
return True

View File

@@ -27,6 +27,7 @@ from homeassistant.core import callback
from homeassistant.helpers import llm
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
BooleanSelector,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
@@ -34,7 +35,12 @@ from homeassistant.helpers.selector import (
TemplateSelector,
)
from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
from .const import (
CONF_PROMPT,
CONF_WEB_SEARCH,
DOMAIN,
RECOMMENDED_CONVERSATION_OPTIONS,
)
_LOGGER = logging.getLogger(__name__)
@@ -43,6 +49,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenRouter."""
VERSION = 1
MINOR_VERSION = 2
@classmethod
@callback
@@ -66,7 +73,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_API_KEY], async_get_clientsession(self.hass)
)
try:
await client.get_key_data()
key_data = await client.get_key_data()
except OpenRouterError:
errors["base"] = "cannot_connect"
except Exception:
@@ -74,7 +81,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="OpenRouter",
title=key_data.label,
data=user_input,
)
return self.async_show_form(
@@ -106,7 +113,7 @@ class OpenRouterSubentryFlowHandler(ConfigSubentryFlow):
class ConversationFlowHandler(OpenRouterSubentryFlowHandler):
"""Handle subentry flow."""
"""Handle conversation subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
@@ -208,13 +215,20 @@ class ConversationFlowHandler(OpenRouterSubentryFlowHandler):
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
vol.Optional(
CONF_WEB_SEARCH,
default=self.options.get(
CONF_WEB_SEARCH,
RECOMMENDED_CONVERSATION_OPTIONS[CONF_WEB_SEARCH],
),
): BooleanSelector(),
}
),
)
class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler):
"""Handle subentry flow."""
"""Handle AI task subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""

View File

@@ -9,9 +9,13 @@ DOMAIN = "open_router"
LOGGER = logging.getLogger(__package__)
CONF_RECOMMENDED = "recommended"
CONF_WEB_SEARCH = "web_search"
RECOMMENDED_WEB_SEARCH = False
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
CONF_WEB_SEARCH: RECOMMENDED_WEB_SEARCH,
}

View File

@@ -37,9 +37,8 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.json import json_dumps
from . import OpenRouterConfigEntry
from .const import DOMAIN, LOGGER
from .const import CONF_WEB_SEARCH, DOMAIN, LOGGER
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
@@ -52,7 +51,6 @@ def _adjust_schema(schema: dict[str, Any]) -> None:
if "required" not in schema:
schema["required"] = []
# Ensure all properties are required
for prop, prop_info in schema["properties"].items():
_adjust_schema(prop_info)
if prop not in schema["required"]:
@@ -233,14 +231,20 @@ class OpenRouterEntity(Entity):
) -> None:
"""Generate an answer for the chat log."""
model = self.model
if self.subentry.data.get(CONF_WEB_SEARCH):
model = f"{model}:online"
extra_body: dict[str, Any] = {"require_parameters": True}
model_args = {
"model": self.model,
"model": model,
"user": chat_log.conversation_id,
"extra_headers": {
"X-Title": "Home Assistant",
"HTTP-Referer": "https://www.home-assistant.io/integrations/open_router",
},
"extra_body": {"require_parameters": True},
"extra_body": extra_body,
}
tools: list[ChatCompletionFunctionToolParam] | None = None
@@ -296,6 +300,10 @@ class OpenRouterEntity(Entity):
LOGGER.error("Error talking to API: %s", err)
raise HomeAssistantError("Error talking to API") from err
if not result.choices:
LOGGER.error("API returned empty choices")
raise HomeAssistantError("API returned empty response")
result_message = result.choices[0].message
model_args["messages"].extend(

View File

@@ -2,7 +2,7 @@
"domain": "open_router",
"name": "OpenRouter",
"after_dependencies": ["assist_pipeline", "intent"],
"codeowners": ["@joostlek"],
"codeowners": ["@joostlek", "@ab3lson"],
"config_flow": true,
"dependencies": ["conversation"],
"documentation": "https://www.home-assistant.io/integrations/open_router",

View File

@@ -23,19 +23,18 @@
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"entry_type": "AI task",
"initiate_flow": {
"reconfigure": "Reconfigure AI task",
"user": "Add AI task"
},
"step": {
"init": {
"data": {
"model": "[%key:component::open_router::config_subentries::conversation::step::init::data::model%]"
},
"data_description": {
"model": "The model to use for the AI task"
"model": "[%key:common::generic::model%]"
},
"description": "Configure the AI task"
}
@@ -45,22 +44,27 @@
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"entry_not_loaded": "[%key:component::open_router::config_subentries::ai_task_data::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"entry_type": "Conversation agent",
"initiate_flow": {
"reconfigure": "Reconfigure conversation agent",
"user": "Add conversation agent"
},
"step": {
"init": {
"data": {
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"model": "Model",
"prompt": "[%key:common::config_flow::data::prompt%]"
"model": "[%key:common::generic::model%]",
"prompt": "[%key:common::config_flow::data::prompt%]",
"web_search": "Enable web search"
},
"data_description": {
"llm_hass_api": "Select which tools the model can use to interact with your devices and entities.",
"model": "The model to use for the conversation agent",
"prompt": "Instruct how the LLM should respond. This can be a template."
"prompt": "Instruct how the LLM should respond. This can be a template.",
"web_search": "Allow the model to search the web for answers"
},
"description": "Configure the conversation agent"
}

View File

@@ -346,7 +346,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
id=event.item.id,
tool_name="web_search_call",
tool_args={
"action": event.item.action.to_dict(),
"action": event.item.action.to_dict()
if event.item.action
else None,
},
external=True,
)
@@ -360,6 +362,10 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
}
last_role = "tool_result"
elif isinstance(event.item, ImageGenerationCall):
if last_summary_index is not None:
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = None
yield {"native": event.item}
last_summary_index = -1 # Trigger new assistant message on next turn
elif isinstance(event, ResponseTextDeltaEvent):

View File

@@ -1 +1 @@
"""The opple component."""
"""The Opple integration."""

View File

@@ -1 +1 @@
"""The panasonic_bluray component."""
"""The Panasonic Blu-Ray Player integration."""

View File

@@ -39,7 +39,7 @@ def setup_platform(
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Panasonic Blu-ray platform."""
"""Set up the Panasonic Blu-ray media player platform."""
conf = discovery_info or config
# Register configured device with Home Assistant.
@@ -59,7 +59,7 @@ class PanasonicBluRay(MediaPlayerEntity):
)
def __init__(self, ip, name):
"""Initialize the Panasonic Blue-ray device."""
"""Initialize the Panasonic Blu-ray device."""
self._device = PanasonicBD(ip)
self._attr_name = name
self._attr_state = MediaPlayerState.OFF

View File

@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
@@ -163,7 +164,16 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]):
if (
reconfigure_entry.state is not ConfigEntryState.LOADED
or reconfigure_entry.data != user_input
):
if not await self.test_connection(
user_input[CONF_HOST], user_input[CONF_PORT]
):
errors["base"] = "cannot_connect"
if not errors:
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
@@ -171,11 +181,8 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT: user_input[CONF_PORT],
},
title=user_input[CONF_HOST],
reload_even_if_entry_is_unchanged=False,
)
errors["base"] = "cannot_connect"
suggested_values: dict[str, Any] = {
**reconfigure_entry.data,
**(user_input or {}),

View File

@@ -1 +1 @@
"""The sky_hub component."""
"""The Sky Hub integration."""

View File

@@ -73,8 +73,8 @@ class SureBattery(SurePetcareEntity, SensorEntity):
try:
per_battery_voltage = state["battery"] / 4
voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW
self._attr_native_value = min(
int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100
self._attr_native_value = max(
0, min(int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100)
)
except KeyError, TypeError:
self._attr_native_value = None

View File

@@ -34,15 +34,13 @@ class SynologyDSMbuttonDescription(ButtonEntityDescription):
BUTTONS: Final = [
SynologyDSMbuttonDescription(
key="reboot",
name="Reboot",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_action=lambda syno_api: syno_api.async_reboot,
),
SynologyDSMbuttonDescription(
key="shutdown",
name="Shutdown",
icon="mdi:power",
translation_key="shutdown",
entity_category=EntityCategory.CONFIG,
press_action=lambda syno_api: syno_api.async_shutdown,
),
@@ -63,6 +61,7 @@ class SynologyDSMButton(ButtonEntity):
"""Defines a Synology DSM button."""
entity_description: SynologyDSMbuttonDescription
_attr_has_entity_name = True
def __init__(
self,
@@ -75,7 +74,6 @@ class SynologyDSMButton(ButtonEntity):
if TYPE_CHECKING:
assert api.network is not None
assert api.information is not None
self._attr_name = f"{api.network.hostname} {description.name}"
self._attr_unique_id = f"{api.information.serial}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, api.information.serial)}

View File

@@ -1,5 +1,10 @@
{
"entity": {
"button": {
"shutdown": {
"default": "mdi:power"
}
},
"sensor": {
"cpu_15min_load": {
"default": "mdi:chip"

View File

@@ -76,6 +76,11 @@
"name": "Security status"
}
},
"button": {
"shutdown": {
"name": "Shutdown"
}
},
"sensor": {
"cpu_15min_load": {
"name": "CPU load average (15 min)"

View File

@@ -19,6 +19,7 @@ PLATFORMS = [
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
TASMOTA_EVENT = "tasmota_event"

View File

@@ -0,0 +1,38 @@
"""Data update coordinators for Tasmota."""
from datetime import timedelta
import logging
from aiogithubapi import GitHubAPI, GitHubRatelimitException, GitHubReleaseModel
from aiogithubapi.client import GitHubConnectionException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
class TasmotaLatestReleaseUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]):
"""Data update coordinator for Tasmota latest release info."""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the coordinator."""
self.client = GitHubAPI(session=async_get_clientsession(hass))
super().__init__(
hass,
logger=logging.getLogger(__name__),
config_entry=config_entry,
name="Tasmota latest release",
update_interval=timedelta(days=1),
)
async def _async_update_data(self) -> GitHubReleaseModel:
"""Get new data."""
try:
response = await self.client.repos.releases.latest("arendst/Tasmota")
if response.data is None:
raise UpdateFailed("No data received")
except (GitHubConnectionException, GitHubRatelimitException) as ex:
raise UpdateFailed(ex) from ex
else:
return response.data

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["hatasmota"],
"mqtt": ["tasmota/discovery/#"],
"requirements": ["HATasmota==0.10.1"]
"requirements": ["HATasmota==0.10.1", "aiogithubapi==26.0.0"]
}

View File

@@ -0,0 +1,79 @@
"""Update entity for Tasmota."""
import re
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TasmotaLatestReleaseUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tasmota update entities."""
coordinator = TasmotaLatestReleaseUpdateCoordinator(hass, config_entry)
await coordinator.async_config_entry_first_refresh()
device_registry = dr.async_get(hass)
devices = device_registry.devices.get_devices_for_config_entry_id(
config_entry.entry_id
)
async_add_entities(TasmotaUpdateEntity(coordinator, device) for device in devices)
class TasmotaUpdateEntity(UpdateEntity):
"""Representation of a Tasmota update entity."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_name = "Firmware"
_attr_title = "Tasmota firmware"
_attr_supported_features = UpdateEntityFeature.RELEASE_NOTES
def __init__(
self,
coordinator: TasmotaLatestReleaseUpdateCoordinator,
device_entry: DeviceEntry,
) -> None:
"""Initialize the Tasmota update entity."""
self.coordinator = coordinator
self.device_entry = device_entry
self._attr_unique_id = f"{device_entry.id}_update"
@property
def installed_version(self) -> str | None:
"""Return the installed version."""
return self.device_entry.sw_version # type:ignore[union-attr]
@property
def latest_version(self) -> str:
"""Return the latest version."""
return self.coordinator.data.tag_name.removeprefix("v")
@property
def release_url(self) -> str:
"""Return the release URL."""
return self.coordinator.data.html_url
@property
def release_summary(self) -> str:
"""Return the release summary."""
return self.coordinator.data.name
def release_notes(self) -> str | None:
"""Return the release notes."""
if not self.coordinator.data.body:
return None
return re.sub(
r"^<picture>.*?</picture>", "", self.coordinator.data.body, flags=re.DOTALL
)

View File

@@ -37,8 +37,6 @@ async def async_setup_bot_platform(
pushbot = PushBot(hass, bot, config, secret_token)
await pushbot.start_application()
webhook_registered = await pushbot.register_webhook()
if not webhook_registered:
raise RuntimeError("Failed to register webhook with Telegram")
@@ -49,6 +47,8 @@ async def async_setup_bot_platform(
get_base_url(bot),
)
await pushbot.start_application()
hass.http.register_view(
PushBotView(
hass,

View File

@@ -60,14 +60,14 @@
},
"services": {
"set_value": {
"description": "Sets the value.",
"description": "Sets the value of a text entity.",
"fields": {
"value": {
"description": "Enter your text.",
"name": "Value"
}
},
"name": "Set value"
"name": "Set text value"
}
},
"title": "Text",

View File

@@ -1 +1 @@
"""The thomson component."""
"""The Thomson integration."""

View File

@@ -4,14 +4,13 @@ from __future__ import annotations
from typing import Any
from tuya_device_handlers.device_wrapper import DEVICE_WARNINGS
from tuya_device_handlers.helpers.diagnostics import customer_device_as_dict
from tuya_sharing import CustomerDevice
from homeassistant.components.diagnostics import REDACTED
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.util import dt as dt_util
from . import TuyaConfigEntry
from .const import DOMAIN, DPCode
@@ -79,52 +78,13 @@ def _async_device_as_dict(
) -> dict[str, Any]:
"""Represent a Tuya device as a dictionary."""
# Base device information, without sensitive information.
data = {
"id": device.id,
"name": device.name,
"category": device.category,
"product_id": device.product_id,
"product_name": device.product_name,
"online": device.online,
"sub": device.sub,
"time_zone": device.time_zone,
"active_time": dt_util.utc_from_timestamp(device.active_time).isoformat(),
"create_time": dt_util.utc_from_timestamp(device.create_time).isoformat(),
"update_time": dt_util.utc_from_timestamp(device.update_time).isoformat(),
"function": {},
"status_range": {},
"status": {},
"home_assistant": {},
"set_up": device.set_up,
"support_local": device.support_local,
"local_strategy": device.local_strategy,
"warnings": DEVICE_WARNINGS.get(device.id),
}
# Base device information
data = customer_device_as_dict(device)
# Gather Tuya states
for dpcode, value in device.status.items():
# These statuses may contain sensitive information, redact these..
if dpcode in _REDACTED_DPCODES:
data["status"][dpcode] = REDACTED
continue
data["status"][dpcode] = value
# Gather Tuya functions
for function in device.function.values():
data["function"][function.code] = {
"type": function.type,
"value": function.values,
}
# Gather Tuya status ranges
for status_range in device.status_range.values():
data["status_range"][status_range.code] = {
"type": status_range.type,
"value": status_range.values,
"report_type": status_range.report_type,
}
# Redact sensitive information.
for key in data["status"]:
if key in _REDACTED_DPCODES:
data["status"][key] = REDACTED
# Gather information how this Tuya device is represented in Home Assistant
device_registry = dr.async_get(hass)

View File

@@ -0,0 +1,45 @@
"""UniFi Network data update coordinator."""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from aiounifi.interfaces.api_handlers import APIHandler
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import LOGGER
if TYPE_CHECKING:
from .hub.hub import UnifiHub
POLL_INTERVAL = timedelta(seconds=10)
class UnifiDataUpdateCoordinator[HandlerT: APIHandler](DataUpdateCoordinator[None]):
"""Coordinator managing polling for a single UniFi API data source."""
def __init__(
self,
hub: UnifiHub,
handler: HandlerT,
) -> None:
"""Initialize coordinator."""
super().__init__(
hub.hass,
LOGGER,
name=f"UniFi {type(handler).__name__}",
config_entry=hub.config.entry,
update_interval=POLL_INTERVAL,
)
self._handler = handler
@property
def handler(self) -> HandlerT:
"""Return the aiounifi handler managed by this coordinator."""
return self._handler
async def _async_update_data(self) -> None:
"""Update data from the API handler."""
await self._handler.update()

View File

@@ -94,16 +94,14 @@ def async_client_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo:
@dataclass(frozen=True, kw_only=True)
class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem](
EntityDescription
):
class UnifiEntityDescription[HandlerT: APIHandler, ItemT: ApiItem](EntityDescription):
"""UniFi Entity Description."""
api_handler_fn: Callable[[aiounifi.Controller], HandlerT]
"""Provide api_handler from api."""
device_info_fn: Callable[[UnifiHub, str], DeviceInfo | None]
"""Provide device info object based on hub and obj_id."""
object_fn: Callable[[aiounifi.Controller, str], ApiItemT]
object_fn: Callable[[aiounifi.Controller, str], ItemT]
"""Retrieve object based on api and obj_id."""
unique_id_fn: Callable[[UnifiHub, str], str]
"""Provide a unique ID based on hub and obj_id."""
@@ -113,7 +111,7 @@ class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem](
"""Determine if config entry options allow creation of entity."""
available_fn: Callable[[UnifiHub, str], bool] = lambda hub, obj_id: hub.available
"""Determine if entity is available, default is if connection is working."""
name_fn: Callable[[ApiItemT], str | None] = lambda obj: None
name_fn: Callable[[ItemT], str | None] = lambda obj: None
"""Entity name function, can be used to extend entity name beyond device name."""
supported_fn: Callable[[UnifiHub, str], bool] = lambda hub, obj_id: True
"""Determine if UniFi object supports providing relevant data for entity."""
@@ -129,17 +127,17 @@ class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem](
"""If entity needs to do regular checks on state."""
class UnifiEntity[HandlerT: APIHandler, ApiItemT: ApiItem](Entity):
class UnifiEntity[HandlerT: APIHandler, ItemT: ApiItem](Entity):
"""Representation of a UniFi entity."""
entity_description: UnifiEntityDescription[HandlerT, ApiItemT]
entity_description: UnifiEntityDescription[HandlerT, ItemT]
_attr_unique_id: str
def __init__(
self,
obj_id: str,
hub: UnifiHub,
description: UnifiEntityDescription[HandlerT, ApiItemT],
description: UnifiEntityDescription[HandlerT, ItemT],
) -> None:
"""Set up UniFi switch entity."""
self._obj_id = obj_id
@@ -258,6 +256,11 @@ class UnifiEntity[HandlerT: APIHandler, ApiItemT: ApiItem](Entity):
"""
self.async_update_state(ItemEvent.ADDED, self._obj_id)
@callback
def get_object(self) -> ItemT:
"""Return the latest object for this entity."""
return self.entity_description.object_fn(self.api, self._obj_id)
@callback
@abstractmethod
def async_update_state(self, event: ItemEvent, obj_id: str) -> None:

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