mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 13:34:58 +00:00
Compare commits
50 Commits
python-3.1
...
scop-tasmo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e7e299876 | ||
|
|
c12b7bfd18 | ||
|
|
1c2f583587 | ||
|
|
58a376e68b | ||
|
|
78b251e7cb | ||
|
|
a2c65b9126 | ||
|
|
5e443681c3 | ||
|
|
13756863f1 | ||
|
|
fd54e45aeb | ||
|
|
52af74c3b6 | ||
|
|
dc111a475e | ||
|
|
14cb42349a | ||
|
|
c42b50418e | ||
|
|
501b4e6efb | ||
|
|
ca2099b165 | ||
|
|
69b55c295d | ||
|
|
13709b1c90 | ||
|
|
2c013777db | ||
|
|
91099ea489 | ||
|
|
70cea66e5b | ||
|
|
e78bb97e84 | ||
|
|
732b170190 | ||
|
|
0a05993a4e | ||
|
|
42c3610685 | ||
|
|
4ad73da7ec | ||
|
|
0d14bdab24 | ||
|
|
157362f225 | ||
|
|
1aa380fdfa | ||
|
|
9348948afa | ||
|
|
14b9915914 | ||
|
|
607462028b | ||
|
|
8c07348a3d | ||
|
|
cda52af178 | ||
|
|
d1ccda18f7 | ||
|
|
9fb0b69f0a | ||
|
|
f0848edea9 | ||
|
|
5be12a213d | ||
|
|
20b284d0e9 | ||
|
|
49c3376c95 | ||
|
|
174b5f5593 | ||
|
|
b38e41a34a | ||
|
|
b6350478a5 | ||
|
|
b75af6d84a | ||
|
|
194485d863 | ||
|
|
d6458bc574 | ||
|
|
434f1dca2c | ||
|
|
c6ad6da6ae | ||
|
|
be3d65538d | ||
|
|
297e9e265a | ||
|
|
119dfbddea |
@@ -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.
|
||||
|
||||
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -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: {}
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.14.3
|
||||
3.14.2
|
||||
|
||||
@@ -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
8
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The bitcoin component."""
|
||||
"""The Bitcoin integration."""
|
||||
|
||||
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
31
homeassistant/components/casper_glow/diagnostics.py
Normal 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,
|
||||
),
|
||||
}
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycasperglow"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pycasperglow==1.1.0"]
|
||||
"requirements": ["pycasperglow==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No network discovery.
|
||||
|
||||
15
homeassistant/components/counter/condition.py
Normal file
15
homeassistant/components/counter/condition.py
Normal 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
|
||||
25
homeassistant/components/counter/conditions.yaml
Normal file
25
homeassistant/components/counter/conditions.yaml
Normal 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
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_value": {
|
||||
"condition": "mdi:counter"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"decrement": {
|
||||
"service": "mdi:numeric-negative-1"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The fail2ban component."""
|
||||
"""The Fail2Ban integration."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -68,5 +68,5 @@ rules:
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Fortinet FortiOS components."""
|
||||
"""Fortinet FortiOS integration."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"services": {
|
||||
"dismiss": {
|
||||
"service": "mdi:bell-off"
|
||||
},
|
||||
"send_message": {
|
||||
"service": "mdi:message-arrow-right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
homeassistant/components/html5/issue.py
Normal file
31
homeassistant/components/html5/issue.py
Normal 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},
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
82
homeassistant/components/html5/services.py
Normal file
82
homeassistant/components/html5/services.py
Normal 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",
|
||||
)
|
||||
@@ -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'}"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -140,6 +140,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"entity_registry_enabled_default",
|
||||
"extra_state_attributes",
|
||||
"force_update",
|
||||
"group_entities",
|
||||
"icon",
|
||||
"friendly_name",
|
||||
"should_poll",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The ohmconnect component."""
|
||||
"""The OhmConnect integration."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The opple component."""
|
||||
"""The Opple integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The panasonic_bluray component."""
|
||||
"""The Panasonic Blu-Ray Player integration."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}),
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The sky_hub component."""
|
||||
"""The Sky Hub integration."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"shutdown": {
|
||||
"default": "mdi:power"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cpu_15min_load": {
|
||||
"default": "mdi:chip"
|
||||
|
||||
@@ -76,6 +76,11 @@
|
||||
"name": "Security status"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"shutdown": {
|
||||
"name": "Shutdown"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cpu_15min_load": {
|
||||
"name": "CPU load average (15 min)"
|
||||
|
||||
@@ -19,6 +19,7 @@ PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
TASMOTA_EVENT = "tasmota_event"
|
||||
|
||||
38
homeassistant/components/tasmota/coordinator.py
Normal file
38
homeassistant/components/tasmota/coordinator.py
Normal 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
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
79
homeassistant/components/tasmota/update.py
Normal file
79
homeassistant/components/tasmota/update.py
Normal 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
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The thomson component."""
|
||||
"""The Thomson integration."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
45
homeassistant/components/unifi/coordinator.py
Normal file
45
homeassistant/components/unifi/coordinator.py
Normal 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()
|
||||
@@ -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
Reference in New Issue
Block a user