mirror of
https://github.com/home-assistant/core.git
synced 2025-09-22 11:29:33 +00:00
Compare commits
89 Commits
mqtt-entit
...
drop-windo
Author | SHA1 | Date | |
---|---|---|---|
![]() |
26deea4ebe | ||
![]() |
043be272aa | ||
![]() |
017a84a859 | ||
![]() |
d184540967 | ||
![]() |
1740984b3b | ||
![]() |
4db8592c61 | ||
![]() |
27e630c107 | ||
![]() |
ea8833342d | ||
![]() |
87be2ba823 | ||
![]() |
51c35eb631 | ||
![]() |
24a86d042f | ||
![]() |
cd6f653123 | ||
![]() |
fd05ddca28 | ||
![]() |
a1f2eb44ae | ||
![]() |
c4ddc03dbc | ||
![]() |
9db5aafb71 | ||
![]() |
64cdcfb613 | ||
![]() |
c761ce699c | ||
![]() |
40ebce4ae8 | ||
![]() |
29914d6722 | ||
![]() |
5eef6edded | ||
![]() |
db729273a5 | ||
![]() |
946d75d651 | ||
![]() |
093f779edb | ||
![]() |
87658e77a7 | ||
![]() |
38f65cda98 | ||
![]() |
797c6ddedd | ||
![]() |
fe8a53407a | ||
![]() |
ae5f57fd99 | ||
![]() |
a93c3cc23c | ||
![]() |
804b42e1fb | ||
![]() |
a4f15e4840 | ||
![]() |
2471177c84 | ||
![]() |
a494d3ec69 | ||
![]() |
b10a9721a7 | ||
![]() |
04c0bb20d6 | ||
![]() |
1598c4ebe8 | ||
![]() |
d67ec7593a | ||
![]() |
4a4c124181 | ||
![]() |
c34af4be86 | ||
![]() |
823071b722 | ||
![]() |
462fa77ba1 | ||
![]() |
24fc8b9297 | ||
![]() |
2596ab2940 | ||
![]() |
23fa84e20e | ||
![]() |
7f13141297 | ||
![]() |
770f41d079 | ||
![]() |
df16e85359 | ||
![]() |
3c6db923a3 | ||
![]() |
450c47f932 | ||
![]() |
048f64eccf | ||
![]() |
c4c523e8b7 | ||
![]() |
87e30e0907 | ||
![]() |
74660da2d2 | ||
![]() |
6b8c180509 | ||
![]() |
eb4a873c43 | ||
![]() |
6aafa666d6 | ||
![]() |
9ee9bb368d | ||
![]() |
6e4258c8a9 | ||
![]() |
d65e704823 | ||
![]() |
aadaf87c16 | ||
![]() |
e70b147c0c | ||
![]() |
031b12752f | ||
![]() |
df0cfd69a9 | ||
![]() |
b2c53f2d78 | ||
![]() |
3649e949b1 | ||
![]() |
de7e2303a7 | ||
![]() |
892f3f267b | ||
![]() |
0254285285 | ||
![]() |
44a95242dc | ||
![]() |
f9b1c52d65 | ||
![]() |
aa8d78622c | ||
![]() |
ca6289a576 | ||
![]() |
0f372f4b47 | ||
![]() |
4bba167ab3 | ||
![]() |
962c0c443d | ||
![]() |
c6b4cac28a | ||
![]() |
3c7e3a5e30 | ||
![]() |
fa698956c3 | ||
![]() |
32f136b12f | ||
![]() |
e1f617df25 | ||
![]() |
84f1b8a5cc | ||
![]() |
e9cedf4852 | ||
![]() |
9c72b40ab4 | ||
![]() |
65f655e5f5 | ||
![]() |
af28573894 | ||
![]() |
c5fc1de3df | ||
![]() |
1df1144eb9 | ||
![]() |
d51c0e3752 |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -198,7 +198,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.03.0
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -265,7 +265,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.03.0
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
|
25
.github/workflows/ci.yaml
vendored
25
.github/workflows/ci.yaml
vendored
@@ -523,22 +523,24 @@ jobs:
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
|
||||
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Restore apt cache
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: cache-apt
|
||||
uses: actions/cache@v4.2.4
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
if: |
|
||||
steps.cache-venv.outputs.cache-hit != 'true'
|
||||
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
|
||||
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
|
||||
mkdir -p ${{ env.APT_CACHE_DIR }}
|
||||
mkdir -p ${{ env.APT_LIST_CACHE_DIR }}
|
||||
fi
|
||||
@@ -563,9 +565,18 @@ jobs:
|
||||
libswscale-dev \
|
||||
libudev-dev
|
||||
|
||||
if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
|
||||
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
|
||||
sudo chmod -R 755 ${{ env.APT_CACHE_BASE }}
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
|
10
CODEOWNERS
generated
10
CODEOWNERS
generated
@@ -1350,6 +1350,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/samsungtv/ @chemelli74 @epenet
|
||||
/homeassistant/components/sanix/ @tomaszsluszniak
|
||||
/tests/components/sanix/ @tomaszsluszniak
|
||||
/homeassistant/components/satel_integra/ @Tommatheussen
|
||||
/tests/components/satel_integra/ @Tommatheussen
|
||||
/homeassistant/components/scene/ @home-assistant/core
|
||||
/tests/components/scene/ @home-assistant/core
|
||||
/homeassistant/components/schedule/ @home-assistant/core
|
||||
@@ -1531,8 +1533,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/switchbee/ @jafar-atili
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
|
||||
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
|
||||
/tests/components/switcher_kis/ @thecode @YogevBokobza
|
||||
/homeassistant/components/switchmate/ @danielhiversen @qiz-li
|
||||
@@ -1677,6 +1679,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/uptime_kuma/ @tr4nt0r
|
||||
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
|
||||
/tests/components/uptimerobot/ @ludeeus @chemelli74
|
||||
/homeassistant/components/usage_prediction/ @home-assistant/core
|
||||
/tests/components/usage_prediction/ @home-assistant/core
|
||||
/homeassistant/components/usb/ @bdraco
|
||||
/tests/components/usb/ @bdraco
|
||||
/homeassistant/components/usgs_earthquakes_feed/ @exxamalte
|
||||
@@ -1706,6 +1710,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
|
||||
/homeassistant/components/vicare/ @CFenner
|
||||
/tests/components/vicare/ @CFenner
|
||||
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
|
@@ -2,21 +2,31 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.media_source import MediaSource, local_source
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR
|
||||
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||
"""Set up local media source."""
|
||||
media_dir = hass.config.path(f"{DOMAIN}/{IMAGE_DIR}")
|
||||
media_dirs = list(hass.config.media_dirs.values())
|
||||
|
||||
if not media_dirs:
|
||||
raise HomeAssistantError(
|
||||
"AI Task media source requires at least one media directory configured"
|
||||
)
|
||||
|
||||
media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR
|
||||
|
||||
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"AI Generated Images",
|
||||
{IMAGE_DIR: media_dir},
|
||||
{IMAGE_DIR: str(media_dir)},
|
||||
f"/{DOMAIN}",
|
||||
)
|
||||
return source
|
||||
|
@@ -12,7 +12,7 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import camera, conversation, media_source
|
||||
from homeassistant.components import camera, conversation, image, media_source
|
||||
from homeassistant.components.http.auth import async_sign_path
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -31,14 +31,14 @@ from .const import (
|
||||
)
|
||||
|
||||
|
||||
def _save_camera_snapshot(image: camera.Image) -> Path:
|
||||
def _save_camera_snapshot(image_data: camera.Image | image.Image) -> Path:
|
||||
"""Save camera snapshot to temp file."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
suffix=mimetypes.guess_extension(image.content_type, False),
|
||||
suffix=mimetypes.guess_extension(image_data.content_type, False),
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_file.write(image.content)
|
||||
temp_file.write(image_data.content)
|
||||
return Path(temp_file.name)
|
||||
|
||||
|
||||
@@ -54,26 +54,31 @@ async def _resolve_attachments(
|
||||
for attachment in attachments or []:
|
||||
media_content_id = attachment["media_content_id"]
|
||||
|
||||
# Special case for camera media sources
|
||||
if media_content_id.startswith("media-source://camera/"):
|
||||
# Extract entity_id from the media content ID
|
||||
entity_id = media_content_id.removeprefix("media-source://camera/")
|
||||
# Special case for certain media sources
|
||||
for integration in camera, image:
|
||||
media_source_prefix = f"media-source://{integration.DOMAIN}/"
|
||||
if not media_content_id.startswith(media_source_prefix):
|
||||
continue
|
||||
|
||||
# Get snapshot from camera
|
||||
image = await camera.async_get_image(hass, entity_id)
|
||||
# Extract entity_id from the media content ID
|
||||
entity_id = media_content_id.removeprefix(media_source_prefix)
|
||||
|
||||
# Get snapshot from entity
|
||||
image_data = await integration.async_get_image(hass, entity_id)
|
||||
|
||||
temp_filename = await hass.async_add_executor_job(
|
||||
_save_camera_snapshot, image
|
||||
_save_camera_snapshot, image_data
|
||||
)
|
||||
created_files.append(temp_filename)
|
||||
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=image.content_type,
|
||||
mime_type=image_data.content_type,
|
||||
path=temp_filename,
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
# Handle regular media sources
|
||||
media = await media_source.async_resolve_media(hass, media_content_id, None)
|
||||
|
@@ -467,7 +467,10 @@ async def async_setup_entry(
|
||||
# periodical (or manual) self test since last daemon restart. It might not be available
|
||||
# when we set up the integration, and we do not know if it would ever be available. Here we
|
||||
# add it anyway and mark it as unknown initially.
|
||||
for resource in available_resources | {LAST_S_TEST}:
|
||||
#
|
||||
# We also sort the resources to ensure the order of entities created is deterministic since
|
||||
# "APCMODEL" and "MODEL" resources map to the same "Model" name.
|
||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
continue
|
||||
|
@@ -120,6 +120,7 @@ class AsusWrtBridge(ABC):
|
||||
|
||||
def __init__(self, host: str) -> None:
|
||||
"""Initialize Bridge."""
|
||||
self._configuration_url = f"http://{host}"
|
||||
self._host = host
|
||||
self._firmware: str | None = None
|
||||
self._label_mac: str | None = None
|
||||
@@ -127,6 +128,11 @@ class AsusWrtBridge(ABC):
|
||||
self._model_id: str | None = None
|
||||
self._serial_number: str | None = None
|
||||
|
||||
@property
|
||||
def configuration_url(self) -> str:
|
||||
"""Return configuration URL."""
|
||||
return self._configuration_url
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""Return hostname."""
|
||||
@@ -371,6 +377,7 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
||||
# get main router properties
|
||||
if mac := _identity.mac:
|
||||
self._label_mac = format_mac(mac)
|
||||
self._configuration_url = self._api.webpanel
|
||||
self._firmware = str(_identity.firmware)
|
||||
self._model = _identity.model
|
||||
self._model_id = _identity.product_id
|
||||
|
@@ -388,13 +388,13 @@ class AsusWrtRouter:
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device information."""
|
||||
info = DeviceInfo(
|
||||
configuration_url=self._api.configuration_url,
|
||||
identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")},
|
||||
name=self.host,
|
||||
model=self._api.model or "Asus Router",
|
||||
model_id=self._api.model_id,
|
||||
serial_number=self._api.serial_number,
|
||||
manufacturer="Asus",
|
||||
configuration_url=f"http://{self.host}",
|
||||
)
|
||||
if self._api.firmware:
|
||||
info["sw_version"] = self._api.firmware
|
||||
|
@@ -29,5 +29,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.0.1", "yalexs-ble==3.1.2"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"]
|
||||
}
|
||||
|
@@ -26,7 +26,6 @@ EXCLUDE_FROM_BACKUP = [
|
||||
"tmp_backups/*.tar",
|
||||
"OZW_Log.txt",
|
||||
"tts/*",
|
||||
"ai_task/*",
|
||||
]
|
||||
|
||||
EXCLUDE_DATABASE_FROM_BACKUP = [
|
||||
|
@@ -8,7 +8,7 @@ import threading
|
||||
from typing import IO, cast
|
||||
|
||||
from aiohttp import BodyPartReader
|
||||
from aiohttp.hdrs import CONTENT_DISPOSITION
|
||||
from aiohttp.hdrs import CONTENT_DISPOSITION, CONTENT_TYPE
|
||||
from aiohttp.web import FileResponse, Request, Response, StreamResponse
|
||||
from multidict import istr
|
||||
|
||||
@@ -76,7 +76,8 @@ class DownloadBackupView(HomeAssistantView):
|
||||
return Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
headers = {
|
||||
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
|
||||
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar",
|
||||
CONTENT_TYPE: "application/x-tar",
|
||||
}
|
||||
|
||||
try:
|
||||
|
@@ -37,6 +37,10 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.loader import (
|
||||
async_get_custom_components,
|
||||
async_get_loaded_integration,
|
||||
)
|
||||
from homeassistant.util.location import async_detect_location_info
|
||||
|
||||
from .alexa_config import entity_supported as entity_supported_by_alexa
|
||||
@@ -431,6 +435,79 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
url = "/api/cloud/support_package"
|
||||
name = "api:cloud:support_package"
|
||||
|
||||
async def _get_integration_info(self, hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Collect information about active and custom integrations."""
|
||||
# Get loaded components from hass.config.components
|
||||
loaded_components = hass.config.components.copy()
|
||||
|
||||
# Get custom integrations
|
||||
custom_domains = set()
|
||||
with suppress(Exception):
|
||||
custom_domains = set(await async_get_custom_components(hass))
|
||||
|
||||
# Separate built-in and custom integrations
|
||||
builtin_integrations = []
|
||||
custom_integrations = []
|
||||
|
||||
for domain in sorted(loaded_components):
|
||||
try:
|
||||
integration = async_get_loaded_integration(hass, domain)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package
|
||||
# generation. If we can't get integration info,
|
||||
# just add the domain
|
||||
if domain in custom_domains:
|
||||
custom_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": "Unknown",
|
||||
"version": "Unknown",
|
||||
"documentation": "Unknown",
|
||||
}
|
||||
)
|
||||
else:
|
||||
builtin_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": "Unknown",
|
||||
}
|
||||
)
|
||||
else:
|
||||
if domain in custom_domains:
|
||||
# This is a custom integration
|
||||
# include version and documentation link
|
||||
version = (
|
||||
str(integration.version) if integration.version else "Unknown"
|
||||
)
|
||||
if not (documentation := integration.documentation):
|
||||
documentation = "Unknown"
|
||||
|
||||
custom_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": integration.name,
|
||||
"version": version,
|
||||
"documentation": documentation,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# This is a built-in integration.
|
||||
# No version needed, as it is always the same as the
|
||||
# Home Assistant version
|
||||
builtin_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": integration.name,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"builtin_count": len(builtin_integrations),
|
||||
"builtin_integrations": builtin_integrations,
|
||||
"custom_count": len(custom_integrations),
|
||||
"custom_integrations": custom_integrations,
|
||||
}
|
||||
|
||||
async def _generate_markdown(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -453,6 +530,38 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
markdown = "## System Information\n\n"
|
||||
markdown += get_domain_table_markdown(hass_info)
|
||||
|
||||
# Add integration information
|
||||
try:
|
||||
integration_info = await self._get_integration_info(hass)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package generation
|
||||
# If there's any error getting integration info, just note it
|
||||
markdown += "## Active integrations\n\n"
|
||||
markdown += "Unable to collect integration information\n\n"
|
||||
else:
|
||||
markdown += "## Active Integrations\n\n"
|
||||
markdown += f"Built-in integrations: {integration_info['builtin_count']}\n"
|
||||
markdown += f"Custom integrations: {integration_info['custom_count']}\n\n"
|
||||
|
||||
# Built-in integrations
|
||||
if integration_info["builtin_integrations"]:
|
||||
markdown += "<details><summary>Built-in integrations</summary>\n\n"
|
||||
markdown += "Domain | Name\n"
|
||||
markdown += "--- | ---\n"
|
||||
for integration in integration_info["builtin_integrations"]:
|
||||
markdown += f"{integration['domain']} | {integration['name']}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
# Custom integrations
|
||||
if integration_info["custom_integrations"]:
|
||||
markdown += "<details><summary>Custom integrations</summary>\n\n"
|
||||
markdown += "Domain | Name | Version | Documentation\n"
|
||||
markdown += "--- | --- | --- | ---\n"
|
||||
for integration in integration_info["custom_integrations"]:
|
||||
doc_url = integration.get("documentation") or "N/A"
|
||||
markdown += f"{integration['domain']} | {integration['name']} | {integration['version']} | {doc_url}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
|
@@ -50,14 +50,13 @@ from .const import (
|
||||
ATTR_LANGUAGE,
|
||||
ATTR_TEXT,
|
||||
DATA_COMPONENT,
|
||||
DATA_DEFAULT_ENTITY,
|
||||
DOMAIN,
|
||||
HOME_ASSISTANT_AGENT,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .default_agent import DefaultAgent, async_setup_default_agent
|
||||
from .default_agent import async_setup_default_agent
|
||||
from .entity import ConversationEntity
|
||||
from .http import async_setup as async_setup_conversation_http
|
||||
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||
@@ -142,7 +141,7 @@ def async_unset_agent(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Set the agent to handle the conversations."""
|
||||
"""Unset the agent to handle the conversations."""
|
||||
get_agent_manager(hass).async_unset_agent(config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -241,10 +240,10 @@ async def async_handle_sentence_triggers(
|
||||
|
||||
Returns None if no match occurred.
|
||||
"""
|
||||
default_agent = async_get_agent(hass)
|
||||
assert isinstance(default_agent, DefaultAgent)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
return await default_agent.async_handle_sentence_triggers(user_input)
|
||||
return await agent.async_handle_sentence_triggers(user_input)
|
||||
|
||||
|
||||
async def async_handle_intents(
|
||||
@@ -257,12 +256,10 @@ async def async_handle_intents(
|
||||
|
||||
Returns None if no match occurred.
|
||||
"""
|
||||
default_agent = async_get_agent(hass)
|
||||
assert isinstance(default_agent, DefaultAgent)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
return await default_agent.async_handle_intents(
|
||||
user_input, intent_filter=intent_filter
|
||||
)
|
||||
return await agent.async_handle_intents(user_input, intent_filter=intent_filter)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
@@ -298,9 +295,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def handle_reload(service: ServiceCall) -> None:
|
||||
"""Reload intents."""
|
||||
await hass.data[DATA_DEFAULT_ENTITY].async_reload(
|
||||
language=service.data.get(ATTR_LANGUAGE)
|
||||
)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
if agent is not None:
|
||||
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent, singleton
|
||||
|
||||
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT
|
||||
from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT
|
||||
from .entity import ConversationEntity
|
||||
from .models import (
|
||||
AbstractConversationAgent,
|
||||
@@ -28,6 +28,9 @@ from .trace import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .default_agent import DefaultAgent
|
||||
|
||||
|
||||
@singleton.singleton("conversation_agent")
|
||||
@callback
|
||||
@@ -49,8 +52,10 @@ def async_get_agent(
|
||||
hass: HomeAssistant, agent_id: str | None = None
|
||||
) -> AbstractConversationAgent | ConversationEntity | None:
|
||||
"""Get specified agent."""
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
if agent_id is None or agent_id == HOME_ASSISTANT_AGENT:
|
||||
return hass.data[DATA_DEFAULT_ENTITY]
|
||||
return manager.default_agent
|
||||
|
||||
if "." in agent_id:
|
||||
return hass.data[DATA_COMPONENT].get_entity(agent_id)
|
||||
@@ -134,6 +139,7 @@ class AgentManager:
|
||||
"""Initialize the conversation agents."""
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
|
||||
@callback
|
||||
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
|
||||
@@ -182,3 +188,7 @@ class AgentManager:
|
||||
def async_unset_agent(self, agent_id: str) -> None:
|
||||
"""Unset the agent."""
|
||||
self._agents.pop(agent_id, None)
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
self.default_agent = agent
|
||||
|
@@ -10,11 +10,9 @@ from homeassistant.util.hass_dict import HassKey
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from .default_agent import DefaultAgent
|
||||
from .entity import ConversationEntity
|
||||
|
||||
DOMAIN = "conversation"
|
||||
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
HOME_ASSISTANT_AGENT = "conversation.home_assistant"
|
||||
|
||||
ATTR_TEXT = "text"
|
||||
@@ -26,7 +24,6 @@ SERVICE_PROCESS = "process"
|
||||
SERVICE_RELOAD = "reload"
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
|
||||
DATA_DEFAULT_ENTITY: HassKey[DefaultAgent] = HassKey(f"{DOMAIN}_default_entity")
|
||||
|
||||
|
||||
class ConversationEntityFeature(IntFlag):
|
||||
|
@@ -68,13 +68,9 @@ from homeassistant.helpers.event import async_track_state_added_domain
|
||||
from homeassistant.util import language as language_util
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .agent_manager import get_agent_manager
|
||||
from .chat_log import AssistantContent, ChatLog
|
||||
from .const import (
|
||||
DATA_DEFAULT_ENTITY,
|
||||
DEFAULT_EXPOSED_ATTRIBUTES,
|
||||
DOMAIN,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .const import DOMAIN, ConversationEntityFeature
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
@@ -83,6 +79,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
||||
|
||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
|
||||
REGEX_TYPE = type(re.compile(""))
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
@@ -209,9 +207,9 @@ async def async_setup_default_agent(
|
||||
config_intents: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set up entity registry listener for the default agent."""
|
||||
entity = DefaultAgent(hass, config_intents)
|
||||
await entity_component.async_add_entities([entity])
|
||||
hass.data[DATA_DEFAULT_ENTITY] = entity
|
||||
agent = DefaultAgent(hass, config_intents)
|
||||
await entity_component.async_add_entities([agent])
|
||||
await get_agent_manager(hass).async_setup_default_agent(agent)
|
||||
|
||||
@core.callback
|
||||
def async_entity_state_listener(
|
||||
@@ -846,7 +844,7 @@ class DefaultAgent(ConversationEntity):
|
||||
context = {"domain": state.domain}
|
||||
if state.attributes:
|
||||
# Include some attributes
|
||||
for attr in DEFAULT_EXPOSED_ATTRIBUTES:
|
||||
for attr in _DEFAULT_EXPOSED_ATTRIBUTES:
|
||||
if attr not in state.attributes:
|
||||
continue
|
||||
context[attr] = state.attributes[attr]
|
||||
|
@@ -25,7 +25,7 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
|
||||
from .const import DATA_COMPONENT
|
||||
from .default_agent import (
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
@@ -169,7 +169,8 @@ async def websocket_list_sentences(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""List custom registered sentences."""
|
||||
agent = hass.data[DATA_DEFAULT_ENTITY]
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
sentences = []
|
||||
for trigger_data in agent.trigger_sentences:
|
||||
@@ -191,7 +192,8 @@ async def websocket_hass_agent_debug(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Return intents that would be matched by the default agent for a list of sentences."""
|
||||
agent = hass.data[DATA_DEFAULT_ENTITY]
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
# Return results for each sentence in the same order as the input.
|
||||
result_dicts: list[dict[str, Any] | None] = []
|
||||
|
@@ -20,7 +20,8 @@ from homeassistant.helpers.script import ScriptRunResult
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType
|
||||
|
||||
from .const import DATA_DEFAULT_ENTITY, DOMAIN
|
||||
from .agent_manager import get_agent_manager
|
||||
from .const import DOMAIN
|
||||
from .models import ConversationInput
|
||||
|
||||
|
||||
@@ -123,4 +124,6 @@ async def async_attach_trigger(
|
||||
# two trigger copies for who will provide a response.
|
||||
return None
|
||||
|
||||
return hass.data[DATA_DEFAULT_ENTITY].register_trigger(sentences, call_action)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
return agent.register_trigger(sentences, call_action)
|
||||
|
@@ -19,6 +19,7 @@
|
||||
"ssdp",
|
||||
"stream",
|
||||
"sun",
|
||||
"usage_prediction",
|
||||
"usb",
|
||||
"webhook",
|
||||
"zeroconf"
|
||||
|
@@ -43,3 +43,5 @@ class DelugeSensorType(enum.StrEnum):
|
||||
UPLOAD_SPEED_SENSOR = "upload_speed"
|
||||
PROTOCOL_TRAFFIC_UPLOAD_SPEED_SENSOR = "protocol_traffic_upload_speed"
|
||||
PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR = "protocol_traffic_download_speed"
|
||||
DOWNLOADING_COUNT_SENSOR = "downloading_count"
|
||||
SEEDING_COUNT_SENSOR = "seeding_count"
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
from ssl import SSLError
|
||||
from typing import Any
|
||||
@@ -14,11 +15,22 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import LOGGER, DelugeGetSessionStatusKeys
|
||||
from .const import LOGGER, DelugeGetSessionStatusKeys, DelugeSensorType
|
||||
|
||||
type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator]
|
||||
|
||||
|
||||
def count_states(data: dict[str, Any]) -> dict[str, int]:
|
||||
"""Count the states of the provided torrents."""
|
||||
|
||||
counts = Counter(torrent[b"state"].decode() for torrent in data.values())
|
||||
|
||||
return {
|
||||
DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value: counts.get("Downloading", 0),
|
||||
DelugeSensorType.SEEDING_COUNT_SENSOR.value: counts.get("Seeding", 0),
|
||||
}
|
||||
|
||||
|
||||
class DelugeDataUpdateCoordinator(
|
||||
DataUpdateCoordinator[dict[Platform, dict[str, Any]]]
|
||||
):
|
||||
@@ -39,19 +51,22 @@ class DelugeDataUpdateCoordinator(
|
||||
)
|
||||
self.api = api
|
||||
|
||||
async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
|
||||
"""Get the latest data from Deluge and updates the state."""
|
||||
def _get_deluge_data(self):
|
||||
"""Get the latest data from Deluge."""
|
||||
|
||||
data = {}
|
||||
try:
|
||||
_data = await self.hass.async_add_executor_job(
|
||||
self.api.call,
|
||||
data["session_status"] = self.api.call(
|
||||
"core.get_session_status",
|
||||
[iter_member.value for iter_member in list(DelugeGetSessionStatusKeys)],
|
||||
)
|
||||
data[Platform.SENSOR] = {k.decode(): v for k, v in _data.items()}
|
||||
data[Platform.SWITCH] = await self.hass.async_add_executor_job(
|
||||
self.api.call, "core.get_torrents_status", {}, ["paused"]
|
||||
data["torrents_status_state"] = self.api.call(
|
||||
"core.get_torrents_status", {}, ["state"]
|
||||
)
|
||||
data["torrents_status_paused"] = self.api.call(
|
||||
"core.get_torrents_status", {}, ["paused"]
|
||||
)
|
||||
|
||||
except (
|
||||
ConnectionRefusedError,
|
||||
TimeoutError,
|
||||
@@ -66,4 +81,18 @@ class DelugeDataUpdateCoordinator(
|
||||
) from ex
|
||||
LOGGER.error("Unknown error connecting to Deluge: %s", ex)
|
||||
raise
|
||||
|
||||
return data
|
||||
|
||||
async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
|
||||
"""Get the latest data from Deluge and updates the state."""
|
||||
|
||||
deluge_data = await self.hass.async_add_executor_job(self._get_deluge_data)
|
||||
|
||||
data = {}
|
||||
data[Platform.SENSOR] = {
|
||||
k.decode(): v for k, v in deluge_data["session_status"].items()
|
||||
}
|
||||
data[Platform.SENSOR].update(count_states(deluge_data["torrents_status_state"]))
|
||||
data[Platform.SWITCH] = deluge_data["torrents_status_paused"]
|
||||
return data
|
||||
|
12
homeassistant/components/deluge/icons.json
Normal file
12
homeassistant/components/deluge/icons.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"downloading_count": {
|
||||
"default": "mdi:download"
|
||||
},
|
||||
"seeding_count": {
|
||||
"default": "mdi:upload"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -110,6 +110,18 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = (
|
||||
data, DelugeSensorType.PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR.value
|
||||
),
|
||||
),
|
||||
DelugeSensorEntityDescription(
|
||||
key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
|
||||
translation_key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value=lambda data: data[DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value],
|
||||
),
|
||||
DelugeSensorEntityDescription(
|
||||
key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
|
||||
translation_key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value=lambda data: data[DelugeSensorType.SEEDING_COUNT_SENSOR.value],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -36,6 +36,10 @@
|
||||
"idle": "[%key:common::state::idle%]"
|
||||
}
|
||||
},
|
||||
"downloading_count": {
|
||||
"name": "Downloading count",
|
||||
"unit_of_measurement": "torrents"
|
||||
},
|
||||
"download_speed": {
|
||||
"name": "Download speed"
|
||||
},
|
||||
@@ -45,6 +49,10 @@
|
||||
"protocol_traffic_upload_speed": {
|
||||
"name": "Protocol traffic upload speed"
|
||||
},
|
||||
"seeding_count": {
|
||||
"name": "Seeding count",
|
||||
"unit_of_measurement": "[%key:component::deluge::entity::sensor::downloading_count::unit_of_measurement%]"
|
||||
},
|
||||
"upload_speed": {
|
||||
"name": "Upload speed"
|
||||
}
|
||||
|
23
homeassistant/components/derivative/diagnostics.py
Normal file
23
homeassistant/components/derivative/diagnostics.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Diagnostics support for derivative."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
registry = er.async_get(hass)
|
||||
entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
|
||||
|
||||
return {
|
||||
"config_entry": config_entry.as_dict(),
|
||||
"entity": [entity.extended_dict for entity in entities],
|
||||
}
|
@@ -227,15 +227,28 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
weight = calculate_weight(start, end, current_time)
|
||||
derivative = derivative + (value * Decimal(weight))
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Calculated new derivative as %f from %d segments",
|
||||
self.entity_id,
|
||||
derivative,
|
||||
len(self._state_list),
|
||||
)
|
||||
|
||||
return derivative
|
||||
|
||||
def _prune_state_list(self, current_time: datetime) -> None:
|
||||
# filter out all derivatives older than `time_window` from our window list
|
||||
old_len = len(self._state_list)
|
||||
self._state_list = [
|
||||
(time_start, time_end, state)
|
||||
for time_start, time_end, state in self._state_list
|
||||
if (current_time - time_end).total_seconds() < self._time_window
|
||||
]
|
||||
_LOGGER.debug(
|
||||
"%s: Pruned %d elements from state list",
|
||||
self.entity_id,
|
||||
old_len - len(self._state_list),
|
||||
)
|
||||
|
||||
def _handle_invalid_source_state(self, state: State | None) -> bool:
|
||||
# Check the source state for unknown/unavailable condition. If unusable, write unknown/unavailable state and return false.
|
||||
@@ -292,6 +305,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
) -> None:
|
||||
"""Calculate derivative based on time and reschedule."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Recalculating derivative due to max_sub_interval time elapsed",
|
||||
self.entity_id,
|
||||
)
|
||||
self._prune_state_list(now)
|
||||
derivative = self._calc_derivative_from_state_list(now)
|
||||
self._write_native_value(derivative)
|
||||
@@ -300,6 +317,11 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
if derivative != 0:
|
||||
schedule_max_sub_interval_exceeded(source_state)
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Scheduling max_sub_interval_callback in %s",
|
||||
self.entity_id,
|
||||
self._max_sub_interval,
|
||||
)
|
||||
self._cancel_max_sub_interval_exceeded_callback = async_call_later(
|
||||
self.hass,
|
||||
self._max_sub_interval,
|
||||
@@ -309,6 +331,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
@callback
|
||||
def on_state_reported(event: Event[EventStateReportedData]) -> None:
|
||||
"""Handle constant sensor state."""
|
||||
_LOGGER.debug(
|
||||
"%s: New state reported event: %s", self.entity_id, event.data
|
||||
)
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
if not self._handle_invalid_source_state(new_state):
|
||||
@@ -330,6 +355,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
@callback
|
||||
def on_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle changed sensor state."""
|
||||
_LOGGER.debug("%s: New state changed event: %s", self.entity_id, event.data)
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
if not self._handle_invalid_source_state(new_state):
|
||||
@@ -382,15 +408,32 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
/ Decimal(self._unit_prefix)
|
||||
* Decimal(self._unit_time)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%s: Calculated new derivative segment as %f / %f / %f * %f = %f",
|
||||
self.entity_id,
|
||||
delta_value,
|
||||
elapsed_time,
|
||||
self._unit_prefix,
|
||||
self._unit_time,
|
||||
new_derivative,
|
||||
)
|
||||
|
||||
except ValueError as err:
|
||||
_LOGGER.warning("While calculating derivative: %s", err)
|
||||
_LOGGER.warning(
|
||||
"%s: While calculating derivative: %s", self.entity_id, err
|
||||
)
|
||||
except DecimalException as err:
|
||||
_LOGGER.warning(
|
||||
"Invalid state (%s > %s): %s", old_value, new_state.state, err
|
||||
"%s: Invalid state (%s > %s): %s",
|
||||
self.entity_id,
|
||||
old_value,
|
||||
new_state.state,
|
||||
err,
|
||||
)
|
||||
except AssertionError as err:
|
||||
_LOGGER.error("Could not calculate derivative: %s", err)
|
||||
_LOGGER.error(
|
||||
"%s: Could not calculate derivative: %s", self.entity_id, err
|
||||
)
|
||||
|
||||
# For total inreasing sensors, the value is expected to continuously increase.
|
||||
# A negative derivative for a total increasing sensor likely indicates the
|
||||
@@ -400,6 +443,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
== SensorStateClass.TOTAL_INCREASING
|
||||
and new_derivative < 0
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"%s: Dropping sample as source total_increasing sensor decreased",
|
||||
self.entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
# add latest derivative to the window list
|
||||
|
@@ -234,6 +234,17 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.DISTANCE_MM: SensorEntityDescription(
|
||||
key="DISTANCE_MM",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.HEAT_COUNT: SensorEntityDescription(
|
||||
key="HEAT_COUNT",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
EcoWittSensorTypes.PM1: SensorEntityDescription(
|
||||
key="PM1",
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/emoncms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyemoncms==0.1.2"]
|
||||
"requirements": ["pyemoncms==0.1.3"]
|
||||
}
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/emoncms_history",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyemoncms==0.1.2"]
|
||||
"requirements": ["pyemoncms==0.1.3"]
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==41.0.0",
|
||||
"aioesphomeapi==41.1.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.3.0"
|
||||
],
|
||||
|
39
homeassistant/components/geocaching/entity.py
Normal file
39
homeassistant/components/geocaching/entity.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Sensor entities for Geocaching."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from geocachingapi.models import GeocachingCache
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GeocachingDataUpdateCoordinator
|
||||
|
||||
|
||||
# Base class for all platforms
|
||||
class GeocachingBaseEntity(CoordinatorEntity[GeocachingDataUpdateCoordinator]):
|
||||
"""Base class for Geocaching sensors."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
|
||||
# Base class for cache entities
|
||||
class GeocachingCacheEntity(GeocachingBaseEntity):
|
||||
"""Base class for Geocaching cache entities."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: GeocachingDataUpdateCoordinator, cache: GeocachingCache
|
||||
) -> None:
|
||||
"""Initialize the Geocaching cache entity."""
|
||||
super().__init__(coordinator)
|
||||
self.cache = cache
|
||||
|
||||
# A device can have multiple entities, and for a cache which requires multiple entities we want to group them together.
|
||||
# Therefore, we create a device for each cache, which holds all related entities.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=f"Geocache {cache.name}",
|
||||
identifiers={(DOMAIN, cast(str, cache.reference_code))},
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=cache.owner.username,
|
||||
)
|
@@ -15,6 +15,24 @@
|
||||
},
|
||||
"awarded_favorite_points": {
|
||||
"default": "mdi:heart"
|
||||
},
|
||||
"cache_name": {
|
||||
"default": "mdi:label"
|
||||
},
|
||||
"cache_owner": {
|
||||
"default": "mdi:account"
|
||||
},
|
||||
"cache_found_date": {
|
||||
"default": "mdi:calendar-search"
|
||||
},
|
||||
"cache_found": {
|
||||
"default": "mdi:package-variant-closed-check"
|
||||
},
|
||||
"cache_favorite_points": {
|
||||
"default": "mdi:star-check"
|
||||
},
|
||||
"cache_hidden_date": {
|
||||
"default": "mdi:calendar-badge"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,18 +4,25 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
from typing import cast
|
||||
|
||||
from geocachingapi.models import GeocachingStatus
|
||||
from geocachingapi.models import GeocachingCache, GeocachingStatus
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator
|
||||
from .entity import GeocachingBaseEntity, GeocachingCacheEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -25,43 +32,63 @@ class GeocachingSensorEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[GeocachingStatus], str | int | None]
|
||||
|
||||
|
||||
SENSORS: tuple[GeocachingSensorEntityDescription, ...] = (
|
||||
PROFILE_SENSORS: tuple[GeocachingSensorEntityDescription, ...] = (
|
||||
GeocachingSensorEntityDescription(
|
||||
key="find_count",
|
||||
translation_key="find_count",
|
||||
native_unit_of_measurement="caches",
|
||||
value_fn=lambda status: status.user.find_count,
|
||||
),
|
||||
GeocachingSensorEntityDescription(
|
||||
key="hide_count",
|
||||
translation_key="hide_count",
|
||||
native_unit_of_measurement="caches",
|
||||
entity_registry_visible_default=False,
|
||||
value_fn=lambda status: status.user.hide_count,
|
||||
),
|
||||
GeocachingSensorEntityDescription(
|
||||
key="favorite_points",
|
||||
translation_key="favorite_points",
|
||||
native_unit_of_measurement="points",
|
||||
entity_registry_visible_default=False,
|
||||
value_fn=lambda status: status.user.favorite_points,
|
||||
),
|
||||
GeocachingSensorEntityDescription(
|
||||
key="souvenir_count",
|
||||
translation_key="souvenir_count",
|
||||
native_unit_of_measurement="souvenirs",
|
||||
value_fn=lambda status: status.user.souvenir_count,
|
||||
),
|
||||
GeocachingSensorEntityDescription(
|
||||
key="awarded_favorite_points",
|
||||
translation_key="awarded_favorite_points",
|
||||
native_unit_of_measurement="points",
|
||||
entity_registry_visible_default=False,
|
||||
value_fn=lambda status: status.user.awarded_favorite_points,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GeocachingCacheSensorDescription(SensorEntityDescription):
|
||||
"""Define Sensor entity description class."""
|
||||
|
||||
value_fn: Callable[[GeocachingCache], StateType | datetime.date]
|
||||
|
||||
|
||||
CACHE_SENSORS: tuple[GeocachingCacheSensorDescription, ...] = (
|
||||
GeocachingCacheSensorDescription(
|
||||
key="found_date",
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
value_fn=lambda cache: cache.found_date_time,
|
||||
),
|
||||
GeocachingCacheSensorDescription(
|
||||
key="favorite_points",
|
||||
value_fn=lambda cache: cache.favorite_points,
|
||||
),
|
||||
GeocachingCacheSensorDescription(
|
||||
key="hidden_date",
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
value_fn=lambda cache: cache.hidden_date,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GeocachingConfigEntry,
|
||||
@@ -69,14 +96,68 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up a Geocaching sensor entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
GeocachingSensor(coordinator, description) for description in SENSORS
|
||||
|
||||
entities: list[Entity] = []
|
||||
|
||||
entities.extend(
|
||||
GeocachingProfileSensor(coordinator, description)
|
||||
for description in PROFILE_SENSORS
|
||||
)
|
||||
|
||||
status = coordinator.data
|
||||
|
||||
class GeocachingSensor(
|
||||
CoordinatorEntity[GeocachingDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
# Add entities for tracked caches
|
||||
entities.extend(
|
||||
GeoEntityCacheSensorEntity(coordinator, cache, description)
|
||||
for cache in status.tracked_caches
|
||||
for description in CACHE_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
# Base class for a cache entity.
|
||||
# Sets the device, ID and translation settings to correctly group the entity to the correct cache device and give it the correct name.
|
||||
class GeoEntityBaseCache(GeocachingCacheEntity, SensorEntity):
|
||||
"""Base class for cache entities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GeocachingDataUpdateCoordinator,
|
||||
cache: GeocachingCache,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the Geocaching sensor."""
|
||||
super().__init__(coordinator, cache)
|
||||
|
||||
self._attr_unique_id = f"{cache.reference_code}_{key}"
|
||||
|
||||
# The translation key determines the name of the entity as this is the lookup for the `strings.json` file.
|
||||
self._attr_translation_key = f"cache_{key}"
|
||||
|
||||
|
||||
class GeoEntityCacheSensorEntity(GeoEntityBaseCache, SensorEntity):
|
||||
"""Representation of a cache sensor."""
|
||||
|
||||
entity_description: GeocachingCacheSensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GeocachingDataUpdateCoordinator,
|
||||
cache: GeocachingCache,
|
||||
description: GeocachingCacheSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the Geocaching sensor."""
|
||||
super().__init__(coordinator, cache, description.key)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime.date:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.cache)
|
||||
|
||||
|
||||
class GeocachingProfileSensor(GeocachingBaseEntity, SensorEntity):
|
||||
"""Representation of a Sensor."""
|
||||
|
||||
entity_description: GeocachingSensorEntityDescription
|
||||
|
@@ -33,11 +33,36 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"find_count": { "name": "Total finds" },
|
||||
"hide_count": { "name": "Total hides" },
|
||||
"favorite_points": { "name": "Favorite points" },
|
||||
"souvenir_count": { "name": "Total souvenirs" },
|
||||
"awarded_favorite_points": { "name": "Awarded favorite points" }
|
||||
"find_count": {
|
||||
"name": "Total finds",
|
||||
"unit_of_measurement": "caches"
|
||||
},
|
||||
"hide_count": {
|
||||
"name": "Total hides",
|
||||
"unit_of_measurement": "caches"
|
||||
},
|
||||
"favorite_points": {
|
||||
"name": "Favorite points",
|
||||
"unit_of_measurement": "points"
|
||||
},
|
||||
"souvenir_count": {
|
||||
"name": "Total souvenirs",
|
||||
"unit_of_measurement": "souvenirs"
|
||||
},
|
||||
"awarded_favorite_points": {
|
||||
"name": "Awarded favorite points",
|
||||
"unit_of_measurement": "points"
|
||||
},
|
||||
"cache_found_date": {
|
||||
"name": "Found date"
|
||||
},
|
||||
"cache_favorite_points": {
|
||||
"name": "Favorite points",
|
||||
"unit_of_measurement": "points"
|
||||
},
|
||||
"cache_hidden_date": {
|
||||
"name": "Hidden date"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["google-genai==1.29.0"]
|
||||
"requirements": ["google-genai==1.38.0"]
|
||||
}
|
||||
|
@@ -112,11 +112,14 @@ PLACEHOLDER_KEY_ADDON = "addon"
|
||||
PLACEHOLDER_KEY_ADDON_URL = "addon_url"
|
||||
PLACEHOLDER_KEY_REFERENCE = "reference"
|
||||
PLACEHOLDER_KEY_COMPONENTS = "components"
|
||||
PLACEHOLDER_KEY_FREE_SPACE = "free_space"
|
||||
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail"
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
|
||||
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
|
||||
|
||||
CORE_CONTAINER = "homeassistant"
|
||||
SUPERVISOR_CONTAINER = "hassio_supervisor"
|
||||
@@ -137,6 +140,24 @@ KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
|
||||
|
||||
REQUEST_REFRESH_DELAY = 10
|
||||
|
||||
HELP_URLS = {
|
||||
"help_url": "https://www.home-assistant.io/help/",
|
||||
"community_url": "https://community.home-assistant.io/",
|
||||
}
|
||||
|
||||
EXTRA_PLACEHOLDERS = {
|
||||
"issue_mount_mount_failed": {
|
||||
"storage_url": "/config/storage",
|
||||
},
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS,
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE: {
|
||||
"more_info_free_space": "https://www.home-assistant.io/more-info/free-space",
|
||||
},
|
||||
ISSUE_KEY_ADDON_PWNED: {
|
||||
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class SupervisorEntityModel(StrEnum):
|
||||
"""Supervisor entity model."""
|
||||
|
@@ -41,17 +41,21 @@ from .const import (
|
||||
EVENT_SUPERVISOR_EVENT,
|
||||
EVENT_SUPERVISOR_UPDATE,
|
||||
EVENT_SUPPORTED_CHANGED,
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||
PLACEHOLDER_KEY_ADDON,
|
||||
PLACEHOLDER_KEY_ADDON_URL,
|
||||
PLACEHOLDER_KEY_FREE_SPACE,
|
||||
PLACEHOLDER_KEY_REFERENCE,
|
||||
REQUEST_REFRESH_DELAY,
|
||||
UPDATE_KEY_SUPERVISOR,
|
||||
)
|
||||
from .coordinator import get_addons_info
|
||||
from .coordinator import get_addons_info, get_host_info
|
||||
from .handler import HassIO, get_supervisor_client
|
||||
|
||||
ISSUE_KEY_UNHEALTHY = "unhealthy"
|
||||
@@ -78,6 +82,8 @@ ISSUE_KEYS_FOR_REPAIRS = {
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
"issue_system_disk_lifetime",
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -241,11 +247,17 @@ class SupervisorIssues:
|
||||
def add_issue(self, issue: Issue) -> None:
|
||||
"""Add or update an issue in the list. Create or update a repair if necessary."""
|
||||
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
|
||||
placeholders: dict[str, str] | None = None
|
||||
if issue.reference:
|
||||
placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference}
|
||||
placeholders: dict[str, str] = {}
|
||||
if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS:
|
||||
placeholders |= EXTRA_PLACEHOLDERS[issue.key]
|
||||
|
||||
if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING:
|
||||
if issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference
|
||||
|
||||
if issue.key in {
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
}:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
|
||||
f"/hassio/addon/{issue.reference}"
|
||||
)
|
||||
@@ -257,6 +269,19 @@ class SupervisorIssues:
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
|
||||
|
||||
elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE:
|
||||
host_info = get_host_info(self._hass)
|
||||
if (
|
||||
host_info
|
||||
and "data" in host_info
|
||||
and "disk_free" in host_info["data"]
|
||||
):
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(
|
||||
host_info["data"]["disk_free"]
|
||||
)
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
|
||||
|
||||
async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
@@ -264,7 +289,7 @@ class SupervisorIssues:
|
||||
is_fixable=bool(issue.suggestions),
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=issue.key,
|
||||
translation_placeholders=placeholders,
|
||||
translation_placeholders=placeholders or None,
|
||||
)
|
||||
|
||||
self._issues[issue.uuid] = issue
|
||||
|
@@ -16,8 +16,10 @@ from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_info, get_issues_info
|
||||
from .const import (
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
PLACEHOLDER_KEY_ADDON,
|
||||
PLACEHOLDER_KEY_COMPONENTS,
|
||||
@@ -26,11 +28,6 @@ from .const import (
|
||||
from .handler import get_supervisor_client
|
||||
from .issues import Issue, Suggestion
|
||||
|
||||
HELP_URLS = {
|
||||
"help_url": "https://www.home-assistant.io/help/",
|
||||
"community_url": "https://community.home-assistant.io/",
|
||||
}
|
||||
|
||||
SUGGESTION_CONFIRMATION_REQUIRED = {
|
||||
"addon_execute_remove",
|
||||
"system_adopt_data_disk",
|
||||
@@ -38,14 +35,6 @@ SUGGESTION_CONFIRMATION_REQUIRED = {
|
||||
}
|
||||
|
||||
|
||||
EXTRA_PLACEHOLDERS = {
|
||||
"issue_mount_mount_failed": {
|
||||
"storage_url": "/config/storage",
|
||||
},
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS,
|
||||
}
|
||||
|
||||
|
||||
class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
@@ -219,6 +208,7 @@ async def async_create_fix_flow(
|
||||
if issue and issue.key in {
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
}:
|
||||
return AddonIssueRepairFlow(hass, issue_id)
|
||||
|
||||
|
@@ -52,6 +52,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issue_addon_pwned": {
|
||||
"title": "Insecure secrets detected in add-on configuration",
|
||||
"description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue."
|
||||
},
|
||||
"issue_mount_mount_failed": {
|
||||
"title": "Network storage device failed",
|
||||
"fix_flow": {
|
||||
@@ -119,6 +123,10 @@
|
||||
"title": "Disk lifetime exceeding 90%",
|
||||
"description": "The data disk has exceeded 90% of its expected lifespan. The disk may soon malfunction which can lead to data loss. You should replace it soon and migrate your data."
|
||||
},
|
||||
"issue_system_free_space": {
|
||||
"title": "Data disk is running low on free space",
|
||||
"description": "The data disk has only {free_space}GB free space left. This may cause issues with system stability and interfere with functionality such as backups and updates. See [clear up storage]({more_info_free_space}) for tips on how to free up space."
|
||||
},
|
||||
"unhealthy": {
|
||||
"title": "Unhealthy system - {reason}",
|
||||
"description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more."
|
||||
@@ -185,7 +193,7 @@
|
||||
},
|
||||
"unsupported_docker_version": {
|
||||
"title": "Unsupported system - Docker version",
|
||||
"description": "System is unsupported because the wrong version of Docker is in use. Use the link to learn the correct version and how to fix this."
|
||||
"description": "System is unsupported because the Docker version is out of date. For information about the required version and how to fix this, select Learn more."
|
||||
},
|
||||
"unsupported_job_conditions": {
|
||||
"title": "Unsupported system - Protections disabled",
|
||||
@@ -201,7 +209,7 @@
|
||||
},
|
||||
"unsupported_os": {
|
||||
"title": "Unsupported system - Operating System",
|
||||
"description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. Use the link to which operating systems are supported and how to fix this."
|
||||
"description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. For information about supported operating systems and how to fix this, select Learn more."
|
||||
},
|
||||
"unsupported_os_agent": {
|
||||
"title": "Unsupported system - OS-Agent issues",
|
||||
|
23
homeassistant/components/history_stats/diagnostics.py
Normal file
23
homeassistant/components/history_stats/diagnostics.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Diagnostics support for history_stats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
registry = er.async_get(hass)
|
||||
entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
|
||||
|
||||
return {
|
||||
"config_entry": config_entry.as_dict(),
|
||||
"entity": [entity.extended_dict for entity in entities],
|
||||
}
|
@@ -103,6 +103,7 @@ class HomeAssistantConnectZBT2ConfigFlow(
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
ZIGBEE_BAUDRATE = 460800
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initialize the config flow."""
|
||||
|
@@ -52,8 +52,12 @@
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
"menu_options": {
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -75,6 +79,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -112,6 +139,10 @@
|
||||
"menu_options": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -133,6 +164,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -23,6 +24,7 @@ from homeassistant.config_entries import (
|
||||
ConfigEntryBaseFlow,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
FlowType,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
@@ -50,11 +52,27 @@ STEP_PICK_FIRMWARE_THREAD = "pick_firmware_thread"
|
||||
STEP_PICK_FIRMWARE_ZIGBEE = "pick_firmware_zigbee"
|
||||
|
||||
|
||||
class PickedFirmwareType(StrEnum):
|
||||
"""Firmware types that can be picked."""
|
||||
|
||||
THREAD = "thread"
|
||||
ZIGBEE = "zigbee"
|
||||
|
||||
|
||||
class ZigbeeIntegration(StrEnum):
|
||||
"""Zigbee integrations that can be picked."""
|
||||
|
||||
OTHER = "other"
|
||||
ZHA = "zha"
|
||||
|
||||
|
||||
class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Base flow to install firmware."""
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||
_failed_addon_name: str
|
||||
_failed_addon_reason: str
|
||||
_picked_firmware_type: PickedFirmwareType
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Instantiate base flow."""
|
||||
@@ -63,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self._probed_firmware_info: FirmwareInfo | None = None
|
||||
self._device: str | None = None # To be set in a subclass
|
||||
self._hardware_name: str = "unknown" # To be set in a subclass
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
|
||||
self.addon_install_task: asyncio.Task | None = None
|
||||
self.addon_start_task: asyncio.Task | None = None
|
||||
@@ -281,17 +300,79 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_pick_firmware_zigbee(
|
||||
async def async_step_zigbee_installation_type(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Zigbee firmware."""
|
||||
"""Handle the installation type step."""
|
||||
return self.async_show_menu(
|
||||
step_id="zigbee_installation_type",
|
||||
menu_options=[
|
||||
"zigbee_intent_recommended",
|
||||
"zigbee_intent_custom",
|
||||
],
|
||||
)
|
||||
|
||||
async def async_step_zigbee_intent_recommended(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select recommended installation type."""
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
async def async_step_zigbee_intent_custom(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select custom installation type."""
|
||||
return await self.async_step_zigbee_integration()
|
||||
|
||||
async def async_step_zigbee_integration(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select Zigbee integration."""
|
||||
return self.async_show_menu(
|
||||
step_id="zigbee_integration",
|
||||
menu_options=[
|
||||
"zigbee_integration_zha",
|
||||
"zigbee_integration_other",
|
||||
],
|
||||
)
|
||||
|
||||
async def async_step_zigbee_integration_zha(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select ZHA integration."""
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
async def async_step_zigbee_integration_other(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select other Zigbee integration."""
|
||||
self._zigbee_integration = ZigbeeIntegration.OTHER
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
async def _async_continue_picked_firmware(self) -> ConfigFlowResult:
|
||||
"""Continue to the picked firmware step."""
|
||||
if not await self._probe_firmware_info():
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
return await self.async_step_install_zigbee_firmware()
|
||||
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
|
||||
return await self.async_step_install_zigbee_firmware()
|
||||
|
||||
if result := await self._ensure_thread_addon_setup():
|
||||
return result
|
||||
|
||||
return await self.async_step_install_thread_firmware()
|
||||
|
||||
async def async_step_pick_firmware_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Zigbee firmware."""
|
||||
self._picked_firmware_type = PickedFirmwareType.ZIGBEE
|
||||
return await self.async_step_zigbee_installation_type()
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -317,42 +398,43 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Pre-confirm Zigbee setup."""
|
||||
|
||||
# This step is necessary to prevent `user_input` from being passed through
|
||||
return await self.async_step_confirm_zigbee()
|
||||
return await self.async_step_continue_zigbee()
|
||||
|
||||
async def async_step_confirm_zigbee(
|
||||
async def async_step_continue_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm Zigbee setup."""
|
||||
"""Continue Zigbee setup."""
|
||||
assert self._device is not None
|
||||
assert self._hardware_name is not None
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="confirm_zigbee",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
await self.hass.config_entries.flow.async_init(
|
||||
if self._zigbee_integration == ZigbeeIntegration.OTHER:
|
||||
return self._async_flow_finished()
|
||||
|
||||
result = await self.hass.config_entries.flow.async_init(
|
||||
ZHA_DOMAIN,
|
||||
context={"source": "hardware"},
|
||||
data={
|
||||
"name": self._hardware_name,
|
||||
"port": {
|
||||
"path": self._device,
|
||||
"baudrate": 115200,
|
||||
"baudrate": self.ZIGBEE_BAUDRATE,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
},
|
||||
)
|
||||
return self._continue_zha_flow(result)
|
||||
|
||||
return self._async_flow_finished()
|
||||
@callback
|
||||
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
|
||||
"""Continue the ZHA flow."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None:
|
||||
"""Ensure the OTBR addon is set up and not running."""
|
||||
@@ -391,16 +473,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Thread firmware."""
|
||||
if not await self._probe_firmware_info():
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if result := await self._ensure_thread_addon_setup():
|
||||
return result
|
||||
|
||||
return await self.async_step_install_thread_firmware()
|
||||
self._picked_firmware_type = PickedFirmwareType.THREAD
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
async def async_step_install_thread_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -572,6 +646,21 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
|
||||
|
||||
return await self.async_step_pick_firmware()
|
||||
|
||||
@callback
|
||||
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
|
||||
"""Continue the ZHA flow."""
|
||||
next_flow_id = zha_result["flow_id"]
|
||||
|
||||
result = self._async_flow_finished()
|
||||
return (
|
||||
self.async_create_entry(
|
||||
title=result["title"] or self._hardware_name,
|
||||
data=result["data"],
|
||||
next_flow=(FlowType.CONFIG_FLOW, next_flow_id),
|
||||
)
|
||||
| result # update all items with the child result
|
||||
)
|
||||
|
||||
|
||||
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
|
||||
"""Zigbee and Thread options flow handlers."""
|
||||
@@ -629,3 +718,10 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
|
||||
)
|
||||
|
||||
return await super().async_step_pick_firmware_thread(user_input)
|
||||
|
||||
@callback
|
||||
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
|
||||
"""Continue the ZHA flow."""
|
||||
# The options flow cannot return a next_flow yet, so we just finish here.
|
||||
# The options flow should be changed to a reconfigure flow.
|
||||
return self._async_flow_finished()
|
||||
|
@@ -3,11 +3,15 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"pick_firmware": {
|
||||
"title": "Pick your firmware",
|
||||
"description": "Let's get started with setting up your {model}. Do you want to use it to set up a Zigbee or Thread network?",
|
||||
"title": "Pick your protocol",
|
||||
"description": "You can use your {model} for a Zigbee or Thread network. Please check what type of devices you want to add to Home Assistant. You can always change this later.",
|
||||
"menu_options": {
|
||||
"pick_firmware_zigbee": "Zigbee",
|
||||
"pick_firmware_thread": "Thread"
|
||||
"pick_firmware_zigbee": "Use as Zigbee adapter",
|
||||
"pick_firmware_thread": "Use as Thread adapter"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "Most common protocol.",
|
||||
"pick_firmware_thread": "Often used for Matter over Thread devices."
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -29,6 +33,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "OpenThread Border Router setup complete",
|
||||
"description": "Your {model} is now an OpenThread Border Router and will show up in the Thread integration."
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "Set up Zigbee",
|
||||
"description": "Choose the installation type for the Zigbee adapter.",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "Recommended installation",
|
||||
"zigbee_intent_custom": "Custom"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "Automatically install and configure Zigbee.",
|
||||
"zigbee_intent_custom": "Manually install and configure Zigbee, for example with Zigbee2MQTT."
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "Select Zigbee method",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "Zigbee Home Automation",
|
||||
"zigbee_integration_other": "Other"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "Lets Home Assistant control a Zigbee network.",
|
||||
"zigbee_integration_other": "For example if you want to use the adapter with Zigbee2MQTT."
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
|
@@ -52,8 +52,12 @@
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
"menu_options": {
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -75,6 +79,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -112,6 +139,10 @@
|
||||
"menu_options": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -133,6 +164,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
|
@@ -92,7 +92,7 @@ class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
firmware_name="Zigbee",
|
||||
expected_installed_firmware_type=ApplicationType.EZSP,
|
||||
step_id="install_zigbee_firmware",
|
||||
next_step_id="confirm_zigbee",
|
||||
next_step_id="pre_confirm_zigbee",
|
||||
)
|
||||
|
||||
async def async_step_install_thread_firmware(
|
||||
|
@@ -75,8 +75,12 @@
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
"menu_options": {
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -98,6 +102,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
@@ -36,6 +36,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
|
||||
@@ -66,15 +67,13 @@ def to_percentage(value: float | None) -> float | None:
|
||||
return value * 100 if value is not None else None
|
||||
|
||||
|
||||
def time_to_datetime(value: int | None) -> datetime | None:
|
||||
"""Convert seconds to datetime when value is not None."""
|
||||
return (
|
||||
utcnow().replace(microsecond=0) - timedelta(seconds=value)
|
||||
if value is not None
|
||||
else None
|
||||
)
|
||||
def uptime_to_datetime(value: int) -> datetime:
|
||||
"""Convert seconds to datetime timestamp."""
|
||||
return utcnow().replace(microsecond=0) - timedelta(seconds=value)
|
||||
|
||||
|
||||
uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5))
|
||||
|
||||
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="smr_version",
|
||||
@@ -647,7 +646,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
lambda data: data.system is not None and data.system.uptime_s is not None
|
||||
),
|
||||
value_fn=(
|
||||
lambda data: time_to_datetime(data.system.uptime_s) if data.system else None
|
||||
lambda data: (
|
||||
uptime_to_stable_datetime(data.system.uptime_s)
|
||||
if data.system is not None and data.system.uptime_s is not None
|
||||
else None
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
@@ -18,6 +18,6 @@
|
||||
},
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiopvapi"],
|
||||
"requirements": ["aiopvapi==3.1.1"],
|
||||
"requirements": ["aiopvapi==3.2.1"],
|
||||
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
|
||||
}
|
||||
|
@@ -105,6 +105,20 @@ async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image:
|
||||
raise HomeAssistantError("Unable to get image")
|
||||
|
||||
|
||||
async def async_get_image(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
timeout: int = 10,
|
||||
) -> Image:
|
||||
"""Fetch an image from an image entity."""
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
|
||||
if (image := component.get_entity(entity_id)) is None:
|
||||
raise HomeAssistantError(f"Image entity {entity_id} not found")
|
||||
|
||||
return await _async_get_image(image, timeout)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the image component."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[ImageEntity](
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["imgw_pib==1.5.4"]
|
||||
"requirements": ["imgw_pib==1.5.6"]
|
||||
}
|
||||
|
@@ -42,7 +42,7 @@
|
||||
"characteristic_missing": "The device is either already connected to Wi-Fi, or no longer able to connect to Wi-Fi. If you want to connect it to another network, try factory resetting it first.",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"provision_successful": "The device has successfully connected to the Wi-Fi network.",
|
||||
"provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease visit {url} to finish setup.",
|
||||
"provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease finish the setup by following the [setup instructions]({url}).",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
}
|
||||
|
@@ -118,27 +118,31 @@ COVER_KNX_SCHEMA = AllSerializeFirst(
|
||||
vol.Schema(
|
||||
{
|
||||
"section_binary_control": KNXSectionFlat(),
|
||||
vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False, valid_dpt="1"),
|
||||
vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(),
|
||||
"section_stop_control": KNXSectionFlat(),
|
||||
vol.Optional(CONF_GA_STOP): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_STEP): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_STOP): GASelector(state=False, valid_dpt="1"),
|
||||
vol.Optional(CONF_GA_STEP): GASelector(state=False, valid_dpt="1"),
|
||||
"section_position_control": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False),
|
||||
vol.Optional(CONF_GA_POSITION_SET): GASelector(
|
||||
state=False, valid_dpt="5.001"
|
||||
),
|
||||
vol.Optional(CONF_GA_POSITION_STATE): GASelector(
|
||||
write=False, valid_dpt="5.001"
|
||||
),
|
||||
vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(),
|
||||
"section_tilt_control": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_ANGLE): GASelector(),
|
||||
vol.Optional(CONF_GA_ANGLE): GASelector(valid_dpt="5.001"),
|
||||
vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(),
|
||||
"section_travel_time": KNXSectionFlat(),
|
||||
vol.Optional(
|
||||
vol.Required(
|
||||
CoverConf.TRAVELLING_TIME_UP, default=25
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=1000, step=0.1, unit_of_measurement="s"
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
vol.Required(
|
||||
CoverConf.TRAVELLING_TIME_DOWN, default=25
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
@@ -310,7 +314,7 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
|
||||
SWITCH_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
"section_switch": KNXSectionFlat(),
|
||||
vol.Required(CONF_GA_SWITCH): GASelector(write_required=True),
|
||||
vol.Required(CONF_GA_SWITCH): GASelector(write_required=True, valid_dpt="1"),
|
||||
vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
|
@@ -2,7 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from packaging import version
|
||||
from pylamarzocco import (
|
||||
LaMarzoccoBluetoothClient,
|
||||
@@ -11,6 +13,7 @@ from pylamarzocco import (
|
||||
)
|
||||
from pylamarzocco.const import FirmwareType
|
||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||
from pylamarzocco.util import InstallationKey, generate_installation_key
|
||||
|
||||
from homeassistant.components.bluetooth import async_discovered_service_info
|
||||
from homeassistant.const import (
|
||||
@@ -19,13 +22,14 @@ from homeassistant.const import (
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
__version__,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .coordinator import (
|
||||
LaMarzoccoConfigEntry,
|
||||
LaMarzoccoConfigUpdateCoordinator,
|
||||
@@ -60,7 +64,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
client=async_create_clientsession(hass),
|
||||
installation_key=InstallationKey.from_json(entry.data[CONF_INSTALLATION_KEY]),
|
||||
client=create_client_session(hass),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -166,45 +171,50 @@ async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate config entry."""
|
||||
if entry.version > 3:
|
||||
if entry.version > 4:
|
||||
# guard against downgrade from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 1:
|
||||
if entry.version in (1, 2):
|
||||
_LOGGER.error(
|
||||
"Migration from version 1 is no longer supported, please remove and re-add the integration"
|
||||
"Migration from version 1 or 2 is no longer supported, please remove and re-add the integration"
|
||||
)
|
||||
return False
|
||||
|
||||
if entry.version == 2:
|
||||
if entry.version == 3:
|
||||
installation_key = generate_installation_key(str(uuid.uuid4()).lower())
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
installation_key=installation_key,
|
||||
client=create_client_session(hass),
|
||||
)
|
||||
try:
|
||||
things = await cloud_client.list_things()
|
||||
await cloud_client.async_register_client()
|
||||
except (AuthFail, RequestNotSuccessful) as exc:
|
||||
_LOGGER.error("Migration failed with error %s", exc)
|
||||
return False
|
||||
v3_data = {
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry.data[CONF_PASSWORD],
|
||||
CONF_TOKEN: next(
|
||||
(
|
||||
thing.ble_auth_token
|
||||
for thing in things
|
||||
if thing.serial_number == entry.unique_id
|
||||
),
|
||||
None,
|
||||
),
|
||||
}
|
||||
if CONF_MAC in entry.data:
|
||||
v3_data[CONF_MAC] = entry.data[CONF_MAC]
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=v3_data,
|
||||
version=3,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_INSTALLATION_KEY: installation_key.to_json(),
|
||||
},
|
||||
version=4,
|
||||
)
|
||||
_LOGGER.debug("Migrated La Marzocco config entry to version 2")
|
||||
_LOGGER.debug("Migrated La Marzocco config entry to version 4")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_client_session(hass: HomeAssistant) -> ClientSession:
|
||||
"""Create a ClientSession with La Marzocco specific headers."""
|
||||
|
||||
return async_create_clientsession(
|
||||
hass,
|
||||
headers={
|
||||
"X-Client": "HOME_ASSISTANT",
|
||||
"X-Client-Build": __version__,
|
||||
},
|
||||
)
|
||||
|
@@ -5,11 +5,13 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from pylamarzocco import LaMarzoccoCloudClient
|
||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||
from pylamarzocco.models import Thing
|
||||
from pylamarzocco.util import InstallationKey, generate_installation_key
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
@@ -33,7 +35,6 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
@@ -45,7 +46,8 @@ from homeassistant.helpers.selector import (
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_USE_BLUETOOTH, DOMAIN
|
||||
from . import create_client_session
|
||||
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .coordinator import LaMarzoccoConfigEntry
|
||||
|
||||
CONF_MACHINE = "machine"
|
||||
@@ -57,9 +59,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for La Marzocco."""
|
||||
|
||||
VERSION = 3
|
||||
VERSION = 4
|
||||
|
||||
_client: ClientSession
|
||||
_installation_key: InstallationKey
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
@@ -83,13 +86,18 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
**user_input,
|
||||
}
|
||||
|
||||
self._client = async_create_clientsession(self.hass)
|
||||
self._client = create_client_session(self.hass)
|
||||
self._installation_key = generate_installation_key(
|
||||
str(uuid.uuid4()).lower()
|
||||
)
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=data[CONF_USERNAME],
|
||||
password=data[CONF_PASSWORD],
|
||||
client=self._client,
|
||||
installation_key=self._installation_key,
|
||||
)
|
||||
try:
|
||||
await cloud_client.async_register_client()
|
||||
things = await cloud_client.list_things()
|
||||
except AuthFail:
|
||||
_LOGGER.debug("Server rejected login credentials")
|
||||
@@ -184,6 +192,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=selected_device.name,
|
||||
data={
|
||||
**self._config,
|
||||
CONF_INSTALLATION_KEY: self._installation_key.to_json(),
|
||||
CONF_TOKEN: self._things[serial_number].ble_auth_token,
|
||||
},
|
||||
)
|
||||
|
@@ -5,3 +5,4 @@ from typing import Final
|
||||
DOMAIN: Final = "lamarzocco"
|
||||
|
||||
CONF_USE_BLUETOOTH: Final = "use_bluetooth"
|
||||
CONF_INSTALLATION_KEY: Final = "installation_key"
|
||||
|
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.0.11"]
|
||||
"requirements": ["pylamarzocco==2.1.0"]
|
||||
}
|
||||
|
@@ -209,5 +209,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_entity": {
|
||||
"title": "{name} is deprecated",
|
||||
"description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -6,13 +6,24 @@ from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic
|
||||
|
||||
from pylitterbot import FeederRobot, LitterRobot, Robot
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.components.switch import (
|
||||
DOMAIN as SWITCH_DOMAIN,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LitterRobotConfigEntry
|
||||
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||
|
||||
@@ -26,6 +37,15 @@ class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEnti
|
||||
value_fn: Callable[[_WhiskerEntityT], bool]
|
||||
|
||||
|
||||
NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION = RobotSwitchEntityDescription[
|
||||
LitterRobot | FeederRobot
|
||||
](
|
||||
key="night_light_mode_enabled",
|
||||
translation_key="night_light_mode",
|
||||
set_fn=lambda robot, value: robot.set_night_light(value),
|
||||
value_fn=lambda robot: robot.night_light_mode_enabled,
|
||||
)
|
||||
|
||||
SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = {
|
||||
FeederRobot: (
|
||||
RobotSwitchEntityDescription[FeederRobot](
|
||||
@@ -34,14 +54,10 @@ SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = {
|
||||
set_fn=lambda robot, value: robot.set_gravity_mode(value),
|
||||
value_fn=lambda robot: robot.gravity_mode_enabled,
|
||||
),
|
||||
NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,
|
||||
),
|
||||
LitterRobot3: (NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,),
|
||||
Robot: ( # type: ignore[type-abstract] # only used for isinstance check
|
||||
RobotSwitchEntityDescription[LitterRobot | FeederRobot](
|
||||
key="night_light_mode_enabled",
|
||||
translation_key="night_light_mode",
|
||||
set_fn=lambda robot, value: robot.set_night_light(value),
|
||||
value_fn=lambda robot: robot.night_light_mode_enabled,
|
||||
),
|
||||
RobotSwitchEntityDescription[LitterRobot | FeederRobot](
|
||||
key="panel_lock_enabled",
|
||||
translation_key="panel_lockout",
|
||||
@@ -59,13 +75,54 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Litter-Robot switches using config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
entities = [
|
||||
RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description)
|
||||
for robot in coordinator.account.robots
|
||||
for robot_type, entity_descriptions in SWITCH_MAP.items()
|
||||
if isinstance(robot, robot_type)
|
||||
for description in entity_descriptions
|
||||
)
|
||||
]
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
def add_deprecated_entity(
|
||||
robot: LitterRobot4,
|
||||
description: RobotSwitchEntityDescription,
|
||||
entity_cls: type[RobotSwitchEntity],
|
||||
) -> None:
|
||||
"""Add deprecated entities."""
|
||||
unique_id = f"{robot.serial}-{description.key}"
|
||||
if entity_id := ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id):
|
||||
entity_entry = ent_reg.async_get(entity_id)
|
||||
if entity_entry and entity_entry.disabled:
|
||||
ent_reg.async_remove(entity_id)
|
||||
async_delete_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_entity_{unique_id}",
|
||||
)
|
||||
elif entity_entry:
|
||||
entities.append(entity_cls(robot, coordinator, description))
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_entity_{unique_id}",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_entity",
|
||||
translation_placeholders={
|
||||
"name": f"{robot.name} {entity_entry.name or entity_entry.original_name}",
|
||||
"entity": entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
for robot in coordinator.account.get_robots(LitterRobot4):
|
||||
add_deprecated_entity(
|
||||
robot, NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, RobotSwitchEntity
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
|
||||
|
@@ -8,7 +8,7 @@ import logging
|
||||
import ssl
|
||||
from typing import Any, cast
|
||||
|
||||
from pylutron_caseta import BUTTON_STATUS_PRESSED
|
||||
from pylutron_caseta import BUTTON_STATUS_MULTITAP, BUTTON_STATUS_PRESSED
|
||||
from pylutron_caseta.smartbridge import Smartbridge
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -25,6 +25,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ACTION_MULTITAP,
|
||||
ACTION_PRESS,
|
||||
ACTION_RELEASE,
|
||||
ATTR_ACTION,
|
||||
@@ -448,6 +449,8 @@ def _async_subscribe_keypad_events(
|
||||
|
||||
if event_type == BUTTON_STATUS_PRESSED:
|
||||
action = ACTION_PRESS
|
||||
elif event_type == BUTTON_STATUS_MULTITAP:
|
||||
action = ACTION_MULTITAP
|
||||
else:
|
||||
action = ACTION_RELEASE
|
||||
|
||||
|
@@ -29,6 +29,7 @@ ATTR_DEVICE_NAME = "device_name"
|
||||
ATTR_AREA_NAME = "area_name"
|
||||
ATTR_ACTION = "action"
|
||||
|
||||
ACTION_MULTITAP = "multi_tap"
|
||||
ACTION_PRESS = "press"
|
||||
ACTION_RELEASE = "release"
|
||||
|
||||
|
@@ -21,6 +21,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ACTION_MULTITAP,
|
||||
ACTION_PRESS,
|
||||
ACTION_RELEASE,
|
||||
ATTR_ACTION,
|
||||
@@ -39,7 +40,7 @@ def _reverse_dict(forward_dict: dict) -> dict:
|
||||
return {v: k for k, v in forward_dict.items()}
|
||||
|
||||
|
||||
SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_RELEASE]
|
||||
SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_MULTITAP, ACTION_RELEASE]
|
||||
|
||||
LUTRON_BUTTON_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
|
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pylutron_caseta"],
|
||||
"requirements": ["pylutron-caseta==0.24.0"],
|
||||
"requirements": ["pylutron-caseta==0.25.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_lutron._tcp.local.",
|
||||
|
@@ -15,8 +15,3 @@ ID_TYPE_DEVICE_ID = "deviceid"
|
||||
ID_TYPE_SERIAL = "serial"
|
||||
|
||||
FEATUREMAP_ATTRIBUTE_ID = 65532
|
||||
|
||||
# vacuum entity service actions
|
||||
SERVICE_GET_AREAS = "get_areas" # get SupportedAreas and SupportedMaps
|
||||
SERVICE_SELECT_AREAS = "select_areas" # call SelectAreas Matter command
|
||||
SERVICE_CLEAN_AREAS = "clean_areas" # call SelectAreas Matter command and start RVC
|
||||
|
@@ -150,16 +150,5 @@
|
||||
"default": "mdi:ev-station"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"clean_areas": {
|
||||
"service": "mdi:robot-vacuum"
|
||||
},
|
||||
"get_areas": {
|
||||
"service": "mdi:map"
|
||||
},
|
||||
"select_areas": {
|
||||
"service": "mdi:map"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,24 +0,0 @@
|
||||
# Service descriptions for Matter integration
|
||||
|
||||
get_areas:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
|
||||
select_areas:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
areas:
|
||||
required: true
|
||||
example: [1, 3]
|
||||
|
||||
clean_areas:
|
||||
target:
|
||||
entity:
|
||||
domain: vacuum
|
||||
fields:
|
||||
areas:
|
||||
required: true
|
||||
example: [1, 3]
|
@@ -548,30 +548,6 @@
|
||||
"description": "The Matter device to add to the other Matter network."
|
||||
}
|
||||
}
|
||||
},
|
||||
"get_areas": {
|
||||
"name": "Get areas",
|
||||
"description": "Returns a list of available areas and maps for robot vacuum cleaners."
|
||||
},
|
||||
"select_areas": {
|
||||
"name": "Select areas",
|
||||
"description": "Selects the specified areas for cleaning. The areas must be specified as a list of area IDs.",
|
||||
"fields": {
|
||||
"areas": {
|
||||
"name": "Areas",
|
||||
"description": "A list of area IDs to select."
|
||||
}
|
||||
}
|
||||
},
|
||||
"clean_areas": {
|
||||
"name": "Clean areas",
|
||||
"description": "Instructs the Matter vacuum cleaner to clean the specified areas.",
|
||||
"fields": {
|
||||
"areas": {
|
||||
"name": "Areas",
|
||||
"description": "A list of area IDs to clean."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,12 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
from chip.clusters.Objects import NullValue
|
||||
from matter_server.client.models import device_types
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
StateVacuumEntity,
|
||||
@@ -18,25 +16,14 @@ from homeassistant.components.vacuum import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import SERVICE_CLEAN_AREAS, SERVICE_GET_AREAS, SERVICE_SELECT_AREAS
|
||||
from .entity import MatterEntity
|
||||
from .helpers import get_matter
|
||||
from .models import MatterDiscoverySchema
|
||||
|
||||
ATTR_CURRENT_AREA = "current_area"
|
||||
ATTR_CURRENT_AREA_NAME = "current_area_name"
|
||||
ATTR_SELECTED_AREAS = "selected_areas"
|
||||
|
||||
|
||||
class OperationalState(IntEnum):
|
||||
"""Operational State of the vacuum cleaner.
|
||||
@@ -69,33 +56,6 @@ async def async_setup_entry(
|
||||
"""Set up Matter vacuum platform from Config Entry."""
|
||||
matter = get_matter(hass)
|
||||
matter.register_platform_handler(Platform.VACUUM, async_add_entities)
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
# This will call Entity.async_handle_get_areas
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_GET_AREAS,
|
||||
schema=None,
|
||||
func="async_handle_get_areas",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
# This will call Entity.async_handle_clean_areas
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CLEAN_AREAS,
|
||||
schema={
|
||||
vol.Required("areas"): vol.All(cv.ensure_list, [cv.positive_int]),
|
||||
},
|
||||
func="async_handle_clean_areas",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
# This will call Entity.async_handle_select_areas
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SELECT_AREAS,
|
||||
schema={
|
||||
vol.Required("areas"): vol.All(cv.ensure_list, [cv.positive_int]),
|
||||
},
|
||||
func="async_handle_select_areas",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
@@ -105,23 +65,9 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
_supported_run_modes: (
|
||||
dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None
|
||||
) = None
|
||||
_attr_matter_areas: dict[str, Any] | None = None
|
||||
_attr_current_area: int | None = None
|
||||
_attr_current_area_name: str | None = None
|
||||
_attr_selected_areas: list[int] | None = None
|
||||
_attr_supported_maps: list[dict[str, Any]] | None = None
|
||||
entity_description: StateVacuumEntityDescription
|
||||
_platform_translation_key = "vacuum"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the state attributes of the entity."""
|
||||
return {
|
||||
ATTR_CURRENT_AREA: self._attr_current_area,
|
||||
ATTR_CURRENT_AREA_NAME: self._attr_current_area_name,
|
||||
ATTR_SELECTED_AREAS: self._attr_selected_areas,
|
||||
}
|
||||
|
||||
def _get_run_mode_by_tag(
|
||||
self, tag: ModeTag
|
||||
) -> clusters.RvcRunMode.Structs.ModeOptionStruct | None:
|
||||
@@ -190,160 +136,10 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
"""Pause the cleaning task."""
|
||||
await self.send_device_command(clusters.RvcOperationalState.Commands.Pause())
|
||||
|
||||
def async_get_areas(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Get available area and map IDs from vacuum appliance."""
|
||||
|
||||
supported_areas = self.get_matter_attribute_value(
|
||||
clusters.ServiceArea.Attributes.SupportedAreas
|
||||
)
|
||||
if not supported_areas:
|
||||
raise HomeAssistantError("Can't get areas from the device.")
|
||||
|
||||
# Group by area_id: {area_id: {"map_id": ..., "name": ...}}
|
||||
areas = {}
|
||||
for area in supported_areas:
|
||||
area_id = getattr(area, "areaID", None)
|
||||
map_id = getattr(area, "mapID", None)
|
||||
location_name = None
|
||||
area_info = getattr(area, "areaInfo", None)
|
||||
if area_info is not None:
|
||||
location_info = getattr(area_info, "locationInfo", None)
|
||||
if location_info is not None:
|
||||
location_name = getattr(location_info, "locationName", None)
|
||||
if area_id is not None:
|
||||
areas[area_id] = {"map_id": map_id, "name": location_name}
|
||||
|
||||
# Optionally, also extract supported maps if available
|
||||
supported_maps = self.get_matter_attribute_value(
|
||||
clusters.ServiceArea.Attributes.SupportedMaps
|
||||
)
|
||||
maps = []
|
||||
if supported_maps:
|
||||
maps = [
|
||||
{
|
||||
"map_id": getattr(m, "mapID", None),
|
||||
"name": getattr(m, "name", None),
|
||||
}
|
||||
for m in supported_maps
|
||||
]
|
||||
|
||||
return {
|
||||
"areas": areas,
|
||||
"maps": maps,
|
||||
}
|
||||
|
||||
async def async_handle_get_areas(self, **kwargs: Any) -> ServiceResponse:
|
||||
"""Get available area and map IDs from vacuum appliance."""
|
||||
# Group by area_id: {area_id: {"map_id": ..., "name": ...}}
|
||||
areas = {}
|
||||
if self._attr_matter_areas is not None:
|
||||
for area in self._attr_matter_areas:
|
||||
area_id = getattr(area, "areaID", None)
|
||||
map_id = getattr(area, "mapID", None)
|
||||
location_name = None
|
||||
area_info = getattr(area, "areaInfo", None)
|
||||
if area_info is not None:
|
||||
location_info = getattr(area_info, "locationInfo", None)
|
||||
if location_info is not None:
|
||||
location_name = getattr(location_info, "locationName", None)
|
||||
if area_id is not None:
|
||||
if map_id is NullValue:
|
||||
areas[area_id] = {"name": location_name}
|
||||
else:
|
||||
areas[area_id] = {"map_id": map_id, "name": location_name}
|
||||
|
||||
# Optionally, also extract supported maps if available
|
||||
supported_maps = self.get_matter_attribute_value(
|
||||
clusters.ServiceArea.Attributes.SupportedMaps
|
||||
)
|
||||
maps = []
|
||||
if supported_maps != NullValue: # chip.clusters.Types.Nullable
|
||||
maps = [
|
||||
{
|
||||
"map_id": getattr(m, "mapID", None)
|
||||
if getattr(m, "mapID", None) != NullValue
|
||||
else None,
|
||||
"name": getattr(m, "name", None),
|
||||
}
|
||||
for m in supported_maps
|
||||
]
|
||||
|
||||
return cast(
|
||||
ServiceResponse,
|
||||
{
|
||||
"areas": areas,
|
||||
"maps": maps,
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
async def async_handle_select_areas(
|
||||
self, areas: list[int], **kwargs: Any
|
||||
) -> ServiceResponse:
|
||||
"""Select areas to clean."""
|
||||
selected_areas = areas
|
||||
# Matter command to the vacuum cleaner to select the areas.
|
||||
await self.send_device_command(
|
||||
clusters.ServiceArea.Commands.SelectAreas(newAreas=selected_areas)
|
||||
)
|
||||
# Return response indicating selected areas.
|
||||
return cast(
|
||||
ServiceResponse, {"status": "areas selected", "areas": selected_areas}
|
||||
)
|
||||
|
||||
async def async_handle_clean_areas(
|
||||
self, areas: list[int], **kwargs: Any
|
||||
) -> ServiceResponse:
|
||||
"""Start cleaning the specified areas."""
|
||||
# Matter command to the vacuum cleaner to select the areas.
|
||||
await self.send_device_command(
|
||||
clusters.ServiceArea.Commands.SelectAreas(newAreas=areas)
|
||||
)
|
||||
# Start the vacuum cleaner after selecting areas.
|
||||
await self.async_start()
|
||||
# Return response indicating selected areas.
|
||||
return cast(
|
||||
ServiceResponse, {"status": "cleaning areas selected", "areas": areas}
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_device(self) -> None:
|
||||
"""Update from device."""
|
||||
self._calculate_features()
|
||||
# ServiceArea: get areas from the device
|
||||
self._attr_matter_areas = self.get_matter_attribute_value(
|
||||
clusters.ServiceArea.Attributes.SupportedAreas
|
||||
)
|
||||
# optional CurrentArea attribute
|
||||
# pylint: disable=too-many-nested-blocks
|
||||
if self.get_matter_attribute_value(clusters.ServiceArea.Attributes.CurrentArea):
|
||||
current_area = self.get_matter_attribute_value(
|
||||
clusters.ServiceArea.Attributes.CurrentArea
|
||||
)
|
||||
# get areaInfo.locationInfo.locationName for current_area in SupportedAreas list
|
||||
area_name = None
|
||||
if self._attr_matter_areas:
|
||||
for area in self._attr_matter_areas:
|
||||
if getattr(area, "areaID", None) == current_area:
|
||||
area_info = getattr(area, "areaInfo", None)
|
||||
if area_info is not None:
|
||||
location_info = getattr(area_info, "locationInfo", None)
|
||||
if location_info is not None:
|
||||
area_name = getattr(location_info, "locationName", None)
|
||||
break
|
||||
self._attr_current_area = current_area
|
||||
self._attr_current_area_name = area_name
|
||||
else:
|
||||
self._attr_current_area = None
|
||||
self._attr_current_area_name = None
|
||||
|
||||
# optional SelectedAreas attribute
|
||||
if self.get_matter_attribute_value(
|
||||
clusters.ServiceArea.Attributes.SelectedAreas
|
||||
):
|
||||
self._attr_selected_areas = self.get_matter_attribute_value(
|
||||
clusters.ServiceArea.Attributes.SelectedAreas
|
||||
)
|
||||
# derive state from the run mode + operational state
|
||||
run_mode_raw: int = self.get_matter_attribute_value(
|
||||
clusters.RvcRunMode.Attributes.CurrentMode
|
||||
@@ -424,10 +220,6 @@ DISCOVERY_SCHEMAS = [
|
||||
clusters.RvcRunMode.Attributes.CurrentMode,
|
||||
clusters.RvcOperationalState.Attributes.OperationalState,
|
||||
),
|
||||
optional_attributes=(
|
||||
clusters.ServiceArea.Attributes.SelectedAreas,
|
||||
clusters.ServiceArea.Attributes.CurrentArea,
|
||||
),
|
||||
device_type=(device_types.RoboticVacuumCleaner,),
|
||||
allow_none_value=True,
|
||||
),
|
||||
|
@@ -338,7 +338,7 @@ STATE_PROGRAM_PHASE: dict[int, dict[int, str]] = {
|
||||
}
|
||||
|
||||
|
||||
class StateProgramType(MieleEnum):
|
||||
class StateProgramType(MieleEnum, missing_to_none=True):
|
||||
"""Defines program types."""
|
||||
|
||||
normal_operation_mode = 0
|
||||
@@ -346,10 +346,9 @@ class StateProgramType(MieleEnum):
|
||||
automatic_program = 2
|
||||
cleaning_care_program = 3
|
||||
maintenance_program = 4
|
||||
missing2none = -9999
|
||||
|
||||
|
||||
class StateDryingStep(MieleEnum):
|
||||
class StateDryingStep(MieleEnum, missing_to_none=True):
|
||||
"""Defines drying steps."""
|
||||
|
||||
extra_dry = 0
|
||||
@@ -360,7 +359,6 @@ class StateDryingStep(MieleEnum):
|
||||
hand_iron_2 = 5
|
||||
machine_iron = 6
|
||||
smoothing = 7
|
||||
missing2none = -9999
|
||||
|
||||
|
||||
WASHING_MACHINE_PROGRAM_ID: dict[int, str] = {
|
||||
@@ -1314,7 +1312,7 @@ STATE_PROGRAM_ID: dict[int, dict[int, str]] = {
|
||||
}
|
||||
|
||||
|
||||
class PlatePowerStep(MieleEnum):
|
||||
class PlatePowerStep(MieleEnum, missing_to_none=True):
|
||||
"""Plate power settings."""
|
||||
|
||||
plate_step_0 = 0
|
||||
@@ -1339,4 +1337,3 @@ class PlatePowerStep(MieleEnum):
|
||||
plate_step_18 = 18
|
||||
plate_step_boost = 117, 118, 218
|
||||
plate_step_boost_2 = 217
|
||||
missing2none = -9999
|
||||
|
@@ -8,7 +8,7 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pymiele"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pymiele==0.5.4"],
|
||||
"requirements": ["pymiele==0.5.5"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_mieleathome._tcp.local."]
|
||||
}
|
||||
|
@@ -64,7 +64,7 @@ PROGRAM_TO_SPEED: dict[int, str] = {
|
||||
}
|
||||
|
||||
|
||||
class MieleVacuumStateCode(MieleEnum):
|
||||
class MieleVacuumStateCode(MieleEnum, missing_to_none=True):
|
||||
"""Define vacuum state codes."""
|
||||
|
||||
idle = 0
|
||||
@@ -82,7 +82,6 @@ class MieleVacuumStateCode(MieleEnum):
|
||||
blocked_front_wheel = 5900
|
||||
docked = 5903, 5904
|
||||
remote_controlled = 5910
|
||||
missing2none = -9999
|
||||
|
||||
|
||||
SUPPORTED_FEATURES = (
|
||||
|
@@ -73,7 +73,6 @@ ABBREVIATIONS = {
|
||||
"fan_mode_stat_t": "fan_mode_state_topic",
|
||||
"frc_upd": "force_update",
|
||||
"g_tpl": "green_template",
|
||||
"grp": "group",
|
||||
"hs_cmd_t": "hs_command_topic",
|
||||
"hs_cmd_tpl": "hs_command_template",
|
||||
"hs_stat_t": "hs_state_topic",
|
||||
|
@@ -10,7 +10,6 @@ from homeassistant.helpers import config_validation as cv
|
||||
from .const import (
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_GROUP,
|
||||
CONF_QOS,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_TOPIC,
|
||||
@@ -24,7 +23,6 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
|
||||
SCHEMA_BASE = {
|
||||
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
|
||||
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
|
||||
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
|
||||
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)
|
||||
|
@@ -106,7 +106,6 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
|
||||
CONF_GET_POSITION_TEMPLATE = "position_template"
|
||||
CONF_GET_POSITION_TOPIC = "position_topic"
|
||||
CONF_GREEN_TEMPLATE = "green_template"
|
||||
CONF_GROUP = "group"
|
||||
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
|
||||
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
|
||||
CONF_HS_STATE_TOPIC = "hs_state_topic"
|
||||
|
@@ -13,7 +13,6 @@ import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_CONFIGURATION_URL,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_HW_VERSION,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
@@ -33,13 +32,7 @@ from homeassistant.const import (
|
||||
CONF_URL,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
HassJobType,
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.core import Event, HassJobType, HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import (
|
||||
DeviceEntry,
|
||||
@@ -86,7 +79,6 @@ from .const import (
|
||||
CONF_ENABLED_BY_DEFAULT,
|
||||
CONF_ENCODING,
|
||||
CONF_ENTITY_PICTURE,
|
||||
CONF_GROUP,
|
||||
CONF_HW_VERSION,
|
||||
CONF_IDENTIFIERS,
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
@@ -144,7 +136,6 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"device_class",
|
||||
"device_info",
|
||||
"entity_category",
|
||||
"entity_id",
|
||||
"entity_picture",
|
||||
"entity_registry_enabled_default",
|
||||
"extra_state_attributes",
|
||||
@@ -476,74 +467,19 @@ class MqttAttributesMixin(Entity):
|
||||
|
||||
_attributes_extra_blocked: frozenset[str] = frozenset()
|
||||
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
|
||||
_default_group_icon: str | None = None
|
||||
_group_entity_ids: list[str] | None = None
|
||||
_message_callback: Callable[
|
||||
[MessageCallbackType, set[str] | None, ReceiveMessage], None
|
||||
]
|
||||
_process_update_extra_state_attributes: Callable[[dict[str, Any]], None]
|
||||
_monitor_member_updates_callback: CALLBACK_TYPE
|
||||
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize the JSON attributes mixin."""
|
||||
self._attributes_sub_state: dict[str, EntitySubscription] = {}
|
||||
self._attributes_config = config
|
||||
|
||||
def _monitor_member_updates(self) -> None:
|
||||
"""Update the group members if the entity registry is updated."""
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
async def _handle_entity_registry_updated(event: Event[Any]) -> None:
|
||||
"""Handle registry update event."""
|
||||
if (
|
||||
event.data["action"] in {"create", "update"}
|
||||
and (entry := entity_registry.async_get(event.data["entity_id"]))
|
||||
and entry.unique_id in self._attributes_config[CONF_GROUP]
|
||||
) or (
|
||||
event.data["action"] == "remove"
|
||||
and self._group_entity_ids is not None
|
||||
and event.data["entity_id"] in self._group_entity_ids
|
||||
):
|
||||
self._update_group_entity_ids()
|
||||
self._attr_extra_state_attributes[ATTR_ENTITY_ID] = (
|
||||
self._group_entity_ids
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
self.hass.bus.async_listen(
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
_handle_entity_registry_updated,
|
||||
)
|
||||
)
|
||||
|
||||
def _update_group_entity_ids(self) -> None:
|
||||
"""Set the entity_id property if the entity represents a group of entities.
|
||||
|
||||
Setting entity_id in the extra state attributes will show the discovered entity
|
||||
as a group and will show the member entities in the UI.
|
||||
"""
|
||||
if CONF_GROUP not in self._attributes_config:
|
||||
self._default_entity_icon = None
|
||||
return
|
||||
self._attr_icon = self._attr_icon or self._default_group_icon
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
self._group_entity_ids = []
|
||||
for resource_id in self._attributes_config[CONF_GROUP]:
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
self.entity_id.split(".")[0], DOMAIN, resource_id
|
||||
):
|
||||
self._group_entity_ids.append(entity_id)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe MQTT events."""
|
||||
await super().async_added_to_hass()
|
||||
self._update_group_entity_ids()
|
||||
if self._group_entity_ids is not None:
|
||||
self._monitor_member_updates()
|
||||
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: self._group_entity_ids}
|
||||
|
||||
self._attributes_prepare_subscribe_topics()
|
||||
self._attributes_subscribe_topics()
|
||||
|
||||
@@ -610,14 +546,12 @@ class MqttAttributesMixin(Entity):
|
||||
_LOGGER.warning("Erroneous JSON: %s", payload)
|
||||
else:
|
||||
if isinstance(json_dict, dict):
|
||||
filtered_dict: dict[str, Any] = {
|
||||
filtered_dict = {
|
||||
k: v
|
||||
for k, v in json_dict.items()
|
||||
if k not in MQTT_ATTRIBUTES_BLOCKED
|
||||
and k not in self._attributes_extra_blocked
|
||||
}
|
||||
if self._group_entity_ids is not None:
|
||||
filtered_dict[ATTR_ENTITY_ID] = self._group_entity_ids
|
||||
if hasattr(self, "_process_update_extra_state_attributes"):
|
||||
self._process_update_extra_state_attributes(filtered_dict)
|
||||
else:
|
||||
|
@@ -239,7 +239,6 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
|
||||
"""Representation of a MQTT light."""
|
||||
|
||||
_default_name = DEFAULT_NAME
|
||||
_default_group_icon = "mdi:lightbulb-group"
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
_attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED
|
||||
_topic: dict[str, str | None]
|
||||
|
@@ -164,7 +164,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
|
||||
"""Representation of a MQTT JSON light."""
|
||||
|
||||
_default_name = DEFAULT_NAME
|
||||
_default_group_icon = "mdi:lightbulb-group"
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
_attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED
|
||||
|
||||
|
@@ -121,7 +121,6 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
|
||||
"""Representation of a MQTT Template light."""
|
||||
|
||||
_default_name = DEFAULT_NAME
|
||||
_default_group_icon = "mdi:lightbulb-group"
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
_attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED
|
||||
_optimistic: bool
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aionfty"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiontfy==0.5.5"]
|
||||
"requirements": ["aiontfy==0.6.0"]
|
||||
}
|
||||
|
@@ -371,7 +371,11 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@final
|
||||
@property
|
||||
def __native_unit_of_measurement_compat(self) -> str | None:
|
||||
"""Process ambiguous units."""
|
||||
"""Handle wrong character coding in unit provided by integrations.
|
||||
|
||||
NumberEntity should read the number's native unit through this property instead
|
||||
of through native_unit_of_measurement.
|
||||
"""
|
||||
native_unit_of_measurement = self.native_unit_of_measurement
|
||||
return AMBIGUOUS_UNITS.get(
|
||||
native_unit_of_measurement, native_unit_of_measurement
|
||||
|
@@ -124,7 +124,7 @@ class NumberDeviceClass(StrEnum):
|
||||
CO = "carbon_monoxide"
|
||||
"""Carbon Monoxide gas concentration.
|
||||
|
||||
Unit of measurement: `ppm` (parts per million)
|
||||
Unit of measurement: `ppm` (parts per million), mg/m³
|
||||
"""
|
||||
|
||||
CO2 = "carbon_dioxide"
|
||||
@@ -469,7 +469,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
|
||||
NumberDeviceClass.BATTERY: {PERCENTAGE},
|
||||
NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
|
||||
NumberDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION},
|
||||
NumberDeviceClass.CO: {
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION},
|
||||
NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity),
|
||||
NumberDeviceClass.CURRENT: set(UnitOfElectricCurrent),
|
||||
|
@@ -148,7 +148,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
content.extend(
|
||||
await async_prepare_files_for_prompt(
|
||||
hass, [Path(filename) for filename in filenames]
|
||||
hass, [(Path(filename), None) for filename in filenames]
|
||||
)
|
||||
)
|
||||
|
||||
|
@@ -223,15 +223,17 @@ def _convert_content_to_param(
|
||||
ResponseReasoningItemParam(
|
||||
type="reasoning",
|
||||
id=content.native.id,
|
||||
summary=[
|
||||
{
|
||||
"type": "summary_text",
|
||||
"text": summary,
|
||||
}
|
||||
for summary in reasoning_summary
|
||||
]
|
||||
if content.thinking_content
|
||||
else [],
|
||||
summary=(
|
||||
[
|
||||
{
|
||||
"type": "summary_text",
|
||||
"text": summary,
|
||||
}
|
||||
for summary in reasoning_summary
|
||||
]
|
||||
if content.thinking_content
|
||||
else []
|
||||
),
|
||||
encrypted_content=content.native.encrypted_content,
|
||||
)
|
||||
)
|
||||
@@ -308,9 +310,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
"tool_call_id": event.item.id,
|
||||
"tool_name": "code_interpreter",
|
||||
"tool_result": {
|
||||
"output": [output.to_dict() for output in event.item.outputs] # type: ignore[misc]
|
||||
if event.item.outputs is not None
|
||||
else None
|
||||
"output": (
|
||||
[output.to_dict() for output in event.item.outputs] # type: ignore[misc]
|
||||
if event.item.outputs is not None
|
||||
else None
|
||||
)
|
||||
},
|
||||
}
|
||||
last_role = "tool_result"
|
||||
@@ -529,7 +533,7 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
if last_content.role == "user" and last_content.attachments:
|
||||
files = await async_prepare_files_for_prompt(
|
||||
self.hass,
|
||||
[a.path for a in last_content.attachments],
|
||||
[(a.path, a.mime_type) for a in last_content.attachments],
|
||||
)
|
||||
last_message = messages[-1]
|
||||
assert (
|
||||
@@ -601,7 +605,7 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
|
||||
|
||||
async def async_prepare_files_for_prompt(
|
||||
hass: HomeAssistant, files: list[Path]
|
||||
hass: HomeAssistant, files: list[tuple[Path, str | None]]
|
||||
) -> ResponseInputMessageContentListParam:
|
||||
"""Append files to a prompt.
|
||||
|
||||
@@ -611,11 +615,12 @@ async def async_prepare_files_for_prompt(
|
||||
def append_files_to_content() -> ResponseInputMessageContentListParam:
|
||||
content: ResponseInputMessageContentListParam = []
|
||||
|
||||
for file_path in files:
|
||||
for file_path, mime_type in files:
|
||||
if not file_path.exists():
|
||||
raise HomeAssistantError(f"`{file_path}` does not exist")
|
||||
|
||||
mime_type, _ = guess_file_type(file_path)
|
||||
if mime_type is None:
|
||||
mime_type = guess_file_type(file_path)[0]
|
||||
|
||||
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
|
||||
raise HomeAssistantError(
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.15.4"]
|
||||
"requirements": ["opower==0.15.5"]
|
||||
}
|
||||
|
@@ -40,7 +40,7 @@ class P1MonitorFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
port=user_input[CONF_PORT],
|
||||
session=session,
|
||||
) as client:
|
||||
await client.smartmeter()
|
||||
await client.settings()
|
||||
except P1MonitorError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
|
3
homeassistant/components/prowl/const.py
Normal file
3
homeassistant/components/prowl/const.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Prowl Notification service."""
|
||||
|
||||
DOMAIN = "prowl"
|
@@ -3,6 +3,9 @@
|
||||
"name": "Prowl",
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/prowl",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "legacy"
|
||||
"loggers": ["prowl"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["prowlpy==1.0.2"]
|
||||
}
|
||||
|
@@ -3,9 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import prowlpy
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
@@ -17,12 +19,11 @@ from homeassistant.components.notify import (
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_RESOURCE = "https://api.prowlapp.com/publicapi/"
|
||||
|
||||
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string})
|
||||
|
||||
@@ -33,46 +34,49 @@ async def async_get_service(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> ProwlNotificationService:
|
||||
"""Get the Prowl notification service."""
|
||||
return ProwlNotificationService(hass, config[CONF_API_KEY])
|
||||
prowl = await hass.async_add_executor_job(
|
||||
partial(prowlpy.Prowl, apikey=config[CONF_API_KEY])
|
||||
)
|
||||
return ProwlNotificationService(hass, prowl)
|
||||
|
||||
|
||||
class ProwlNotificationService(BaseNotificationService):
|
||||
"""Implement the notification service for Prowl."""
|
||||
|
||||
def __init__(self, hass, api_key):
|
||||
def __init__(self, hass: HomeAssistant, prowl: prowlpy.Prowl) -> None:
|
||||
"""Initialize the service."""
|
||||
self._hass = hass
|
||||
self._api_key = api_key
|
||||
self._prowl = prowl
|
||||
|
||||
async def async_send_message(self, message, **kwargs):
|
||||
async def async_send_message(self, message: str, **kwargs: Any) -> None:
|
||||
"""Send the message to the user."""
|
||||
response = None
|
||||
session = None
|
||||
url = f"{_RESOURCE}add"
|
||||
data = kwargs.get(ATTR_DATA)
|
||||
payload = {
|
||||
"apikey": self._api_key,
|
||||
"application": "Home-Assistant",
|
||||
"event": kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
|
||||
"description": message,
|
||||
"priority": data["priority"] if data and "priority" in data else 0,
|
||||
}
|
||||
if data and data.get("url"):
|
||||
payload["url"] = data["url"]
|
||||
|
||||
_LOGGER.debug("Attempting call Prowl service at %s", url)
|
||||
session = async_get_clientsession(self._hass)
|
||||
data = kwargs.get(ATTR_DATA, {})
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
response = await session.post(url, data=payload)
|
||||
result = await response.text()
|
||||
|
||||
if response.status != HTTPStatus.OK or "error" in result:
|
||||
_LOGGER.error(
|
||||
"Prowl service returned http status %d, response %s",
|
||||
response.status,
|
||||
result,
|
||||
await self._hass.async_add_executor_job(
|
||||
partial(
|
||||
self._prowl.send,
|
||||
application="Home-Assistant",
|
||||
event=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
|
||||
description=message,
|
||||
priority=data.get("priority", 0),
|
||||
url=data.get("url"),
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
_LOGGER.error("Timeout accessing Prowl at %s", url)
|
||||
except TimeoutError as ex:
|
||||
_LOGGER.error("Timeout accessing Prowl API")
|
||||
raise HomeAssistantError("Timeout accessing Prowl API") from ex
|
||||
except prowlpy.APIError as ex:
|
||||
if str(ex).startswith("Invalid API key"):
|
||||
_LOGGER.error("Invalid API key for Prowl service")
|
||||
raise HomeAssistantError("Invalid API key for Prowl service") from ex
|
||||
if str(ex).startswith("Not accepted"):
|
||||
_LOGGER.error("Prowl returned: exceeded rate limit")
|
||||
raise HomeAssistantError(
|
||||
"Prowl service reported: exceeded rate limit"
|
||||
) from ex
|
||||
_LOGGER.error("Unexpected error when calling Prowl API: %s", str(ex))
|
||||
raise HomeAssistantError("Unexpected error when calling Prowl API") from ex
|
||||
|
@@ -16,6 +16,7 @@ from homeassistant.components.media_source import (
|
||||
Unresolvable,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util.location import vincenty
|
||||
|
||||
from . import RadioBrowserConfigEntry
|
||||
from .const import DOMAIN
|
||||
@@ -88,6 +89,7 @@ class RadioMediaSource(MediaSource):
|
||||
*await self._async_build_popular(radios, item),
|
||||
*await self._async_build_by_tag(radios, item),
|
||||
*await self._async_build_by_language(radios, item),
|
||||
*await self._async_build_local(radios, item),
|
||||
*await self._async_build_by_country(radios, item),
|
||||
],
|
||||
)
|
||||
@@ -292,3 +294,63 @@ class RadioMediaSource(MediaSource):
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
def _filter_local_stations(
|
||||
self, stations: list[Station], latitude: float, longitude: float
|
||||
) -> list[Station]:
|
||||
return [
|
||||
station
|
||||
for station in stations
|
||||
if station.latitude is not None
|
||||
and station.longitude is not None
|
||||
and (
|
||||
(
|
||||
dist := vincenty(
|
||||
(latitude, longitude),
|
||||
(station.latitude, station.longitude),
|
||||
False,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
and dist < 100
|
||||
]
|
||||
|
||||
async def _async_build_local(
|
||||
self, radios: RadioBrowser, item: MediaSourceItem
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing local radio stations."""
|
||||
|
||||
if item.identifier == "local":
|
||||
country = self.hass.config.country
|
||||
stations = await radios.stations(
|
||||
filter_by=FilterBy.COUNTRY_CODE_EXACT,
|
||||
filter_term=country,
|
||||
hide_broken=True,
|
||||
order=Order.NAME,
|
||||
reverse=False,
|
||||
)
|
||||
|
||||
local_stations = await self.hass.async_add_executor_job(
|
||||
self._filter_local_stations,
|
||||
stations,
|
||||
self.hass.config.latitude,
|
||||
self.hass.config.longitude,
|
||||
)
|
||||
|
||||
return self._async_build_stations(radios, local_stations)
|
||||
|
||||
if not item.identifier:
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier="local",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.MUSIC,
|
||||
title="Local stations",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
)
|
||||
]
|
||||
|
||||
return []
|
||||
|
@@ -46,6 +46,7 @@ from homeassistant.util.unit_conversion import (
|
||||
AreaConverter,
|
||||
BaseUnitConverter,
|
||||
BloodGlucoseConcentrationConverter,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
ConductivityConverter,
|
||||
DataRateConverter,
|
||||
DistanceConverter,
|
||||
@@ -204,6 +205,10 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
|
||||
**dict.fromkeys(
|
||||
MassVolumeConcentrationConverter.VALID_UNITS, MassVolumeConcentrationConverter
|
||||
),
|
||||
**dict.fromkeys(
|
||||
CarbonMonoxideConcentrationConverter.VALID_UNITS,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
**dict.fromkeys(ConductivityConverter.VALID_UNITS, ConductivityConverter),
|
||||
**dict.fromkeys(DataRateConverter.VALID_UNITS, DataRateConverter),
|
||||
**dict.fromkeys(DistanceConverter.VALID_UNITS, DistanceConverter),
|
||||
|
@@ -19,6 +19,7 @@ from homeassistant.util.unit_conversion import (
|
||||
ApparentPowerConverter,
|
||||
AreaConverter,
|
||||
BloodGlucoseConcentrationConverter,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
ConductivityConverter,
|
||||
DataRateConverter,
|
||||
DistanceConverter,
|
||||
@@ -66,6 +67,9 @@ UNIT_SCHEMA = vol.Schema(
|
||||
vol.Optional("blood_glucose_concentration"): vol.In(
|
||||
BloodGlucoseConcentrationConverter.VALID_UNITS
|
||||
),
|
||||
vol.Optional("carbon_monoxide"): vol.In(
|
||||
CarbonMonoxideConcentrationConverter.VALID_UNITS
|
||||
),
|
||||
vol.Optional("concentration"): vol.In(
|
||||
MassVolumeConcentrationConverter.VALID_UNITS
|
||||
),
|
||||
|
@@ -19,5 +19,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.15.0"]
|
||||
"requirements": ["reolink-aio==0.15.1"]
|
||||
}
|
||||
|
@@ -502,7 +502,7 @@ NUMBER_ENTITIES = (
|
||||
ReolinkNumberEntityDescription(
|
||||
key="image_brightness",
|
||||
cmd_key="GetImage",
|
||||
cmd_id=26,
|
||||
cmd_id=[26, 78],
|
||||
translation_key="image_brightness",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -516,7 +516,7 @@ NUMBER_ENTITIES = (
|
||||
ReolinkNumberEntityDescription(
|
||||
key="image_contrast",
|
||||
cmd_key="GetImage",
|
||||
cmd_id=26,
|
||||
cmd_id=[26, 78],
|
||||
translation_key="image_contrast",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -530,7 +530,7 @@ NUMBER_ENTITIES = (
|
||||
ReolinkNumberEntityDescription(
|
||||
key="image_saturation",
|
||||
cmd_key="GetImage",
|
||||
cmd_id=26,
|
||||
cmd_id=[26, 78],
|
||||
translation_key="image_saturation",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -544,7 +544,7 @@ NUMBER_ENTITIES = (
|
||||
ReolinkNumberEntityDescription(
|
||||
key="image_sharpness",
|
||||
cmd_key="GetImage",
|
||||
cmd_id=26,
|
||||
cmd_id=[26, 78],
|
||||
translation_key="image_sharpness",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -558,7 +558,7 @@ NUMBER_ENTITIES = (
|
||||
ReolinkNumberEntityDescription(
|
||||
key="image_hue",
|
||||
cmd_key="GetImage",
|
||||
cmd_id=26,
|
||||
cmd_id=[26, 78],
|
||||
translation_key="image_hue",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
|
@@ -140,6 +140,7 @@ SENSORS = (
|
||||
HOST_SENSORS = (
|
||||
ReolinkHostSensorEntityDescription(
|
||||
key="wifi_signal",
|
||||
cmd_id=464,
|
||||
cmd_key="115",
|
||||
translation_key="wifi_signal",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioridwell"],
|
||||
"requirements": ["aioridwell==2024.01.0"]
|
||||
"requirements": ["aioridwell==2025.09.0"]
|
||||
}
|
||||
|
@@ -1,59 +1,67 @@
|
||||
"""Support for Satel Integra devices."""
|
||||
|
||||
import collections
|
||||
import logging
|
||||
|
||||
from satel_integra.satel_integra import AsyncSatel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import (
|
||||
CONF_CODE,
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PORT,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DEFAULT_ALARM_NAME = "satel_integra"
|
||||
DEFAULT_PORT = 7094
|
||||
DEFAULT_CONF_ARM_HOME_MODE = 1
|
||||
DEFAULT_DEVICE_PARTITION = 1
|
||||
DEFAULT_ZONE_TYPE = "motion"
|
||||
from .const import (
|
||||
CONF_ARM_HOME_MODE,
|
||||
CONF_DEVICE_PARTITIONS,
|
||||
CONF_OUTPUT_NUMBER,
|
||||
CONF_OUTPUTS,
|
||||
CONF_PARTITION_NUMBER,
|
||||
CONF_SWITCHABLE_OUTPUT_NUMBER,
|
||||
CONF_SWITCHABLE_OUTPUTS,
|
||||
CONF_ZONE_NUMBER,
|
||||
CONF_ZONE_TYPE,
|
||||
CONF_ZONES,
|
||||
DEFAULT_CONF_ARM_HOME_MODE,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_ZONE_TYPE,
|
||||
DOMAIN,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
SIGNAL_PANEL_MESSAGE,
|
||||
SIGNAL_ZONES_UPDATED,
|
||||
SUBENTRY_TYPE_OUTPUT,
|
||||
SUBENTRY_TYPE_PARTITION,
|
||||
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
|
||||
SUBENTRY_TYPE_ZONE,
|
||||
ZONES,
|
||||
SatelConfigEntry,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "satel_integra"
|
||||
PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SWITCH]
|
||||
|
||||
DATA_SATEL = "satel_integra"
|
||||
|
||||
CONF_DEVICE_CODE = "code"
|
||||
CONF_DEVICE_PARTITIONS = "partitions"
|
||||
CONF_ARM_HOME_MODE = "arm_home_mode"
|
||||
CONF_ZONE_NAME = "name"
|
||||
CONF_ZONE_TYPE = "type"
|
||||
CONF_ZONES = "zones"
|
||||
CONF_OUTPUTS = "outputs"
|
||||
CONF_SWITCHABLE_OUTPUTS = "switchable_outputs"
|
||||
|
||||
ZONES = "zones"
|
||||
|
||||
SIGNAL_PANEL_MESSAGE = "satel_integra.panel_message"
|
||||
SIGNAL_PANEL_ARM_AWAY = "satel_integra.panel_arm_away"
|
||||
SIGNAL_PANEL_ARM_HOME = "satel_integra.panel_arm_home"
|
||||
SIGNAL_PANEL_DISARM = "satel_integra.panel_disarm"
|
||||
|
||||
SIGNAL_ZONES_UPDATED = "satel_integra.zones_updated"
|
||||
SIGNAL_OUTPUTS_UPDATED = "satel_integra.outputs_updated"
|
||||
|
||||
ZONE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE_NAME): cv.string,
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string,
|
||||
}
|
||||
)
|
||||
EDITABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_ZONE_NAME): cv.string})
|
||||
EDITABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
|
||||
PARTITION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE_NAME): cv.string,
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In(
|
||||
[1, 2, 3]
|
||||
),
|
||||
@@ -63,7 +71,7 @@ PARTITION_SCHEMA = vol.Schema(
|
||||
|
||||
def is_alarm_code_necessary(value):
|
||||
"""Check if alarm code must be configured."""
|
||||
if value.get(CONF_SWITCHABLE_OUTPUTS) and CONF_DEVICE_CODE not in value:
|
||||
if value.get(CONF_SWITCHABLE_OUTPUTS) and CONF_CODE not in value:
|
||||
raise vol.Invalid("You need to specify alarm code to use switchable_outputs")
|
||||
|
||||
return value
|
||||
@@ -75,7 +83,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_DEVICE_CODE): cv.string,
|
||||
vol.Optional(CONF_CODE): cv.string,
|
||||
vol.Optional(CONF_DEVICE_PARTITIONS, default={}): {
|
||||
vol.Coerce(int): PARTITION_SCHEMA
|
||||
},
|
||||
@@ -92,64 +100,106 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Satel Integra component."""
|
||||
conf = config[DOMAIN]
|
||||
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
|
||||
"""Set up Satel Integra from YAML."""
|
||||
|
||||
zones = conf.get(CONF_ZONES)
|
||||
outputs = conf.get(CONF_OUTPUTS)
|
||||
switchable_outputs = conf.get(CONF_SWITCHABLE_OUTPUTS)
|
||||
host = conf.get(CONF_HOST)
|
||||
port = conf.get(CONF_PORT)
|
||||
partitions = conf.get(CONF_DEVICE_PARTITIONS)
|
||||
if config := hass_config.get(DOMAIN):
|
||||
hass.async_create_task(_async_import(hass, config))
|
||||
|
||||
monitored_outputs = collections.OrderedDict(
|
||||
list(outputs.items()) + list(switchable_outputs.items())
|
||||
return True
|
||||
|
||||
|
||||
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
|
||||
"""Process YAML import."""
|
||||
|
||||
if not hass.config_entries.async_entries(DOMAIN):
|
||||
# Start import flow
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||
)
|
||||
|
||||
if result.get("type") == FlowResultType.ABORT:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml_import_issue_cannot_connect",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml_import_issue_cannot_connect",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Satel Integra",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Satel Integra",
|
||||
},
|
||||
)
|
||||
|
||||
controller = AsyncSatel(host, port, hass.loop, zones, monitored_outputs, partitions)
|
||||
|
||||
hass.data[DATA_SATEL] = controller
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bool:
|
||||
"""Set up Satel Integra from a config entry."""
|
||||
|
||||
host = entry.data[CONF_HOST]
|
||||
port = entry.data[CONF_PORT]
|
||||
|
||||
# Make sure we initialize the Satel controller with the configured entries to monitor
|
||||
partitions = [
|
||||
subentry.data[CONF_PARTITION_NUMBER]
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == SUBENTRY_TYPE_PARTITION
|
||||
]
|
||||
|
||||
zones = [
|
||||
subentry.data[CONF_ZONE_NUMBER]
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == SUBENTRY_TYPE_ZONE
|
||||
]
|
||||
|
||||
outputs = [
|
||||
subentry.data[CONF_OUTPUT_NUMBER]
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == SUBENTRY_TYPE_OUTPUT
|
||||
]
|
||||
|
||||
switchable_outputs = [
|
||||
subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == SUBENTRY_TYPE_SWITCHABLE_OUTPUT
|
||||
]
|
||||
|
||||
monitored_outputs = outputs + switchable_outputs
|
||||
|
||||
controller = AsyncSatel(host, port, hass.loop, zones, monitored_outputs, partitions)
|
||||
|
||||
result = await controller.connect()
|
||||
|
||||
if not result:
|
||||
return False
|
||||
raise ConfigEntryNotReady("Controller failed to connect")
|
||||
|
||||
entry.runtime_data = controller
|
||||
|
||||
@callback
|
||||
def _close(*_):
|
||||
controller.close()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)
|
||||
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
|
||||
|
||||
_LOGGER.debug("Arm home config: %s, mode: %s ", conf, conf.get(CONF_ARM_HOME_MODE))
|
||||
|
||||
hass.async_create_task(
|
||||
async_load_platform(hass, Platform.ALARM_CONTROL_PANEL, DOMAIN, conf, config)
|
||||
)
|
||||
|
||||
hass.async_create_task(
|
||||
async_load_platform(
|
||||
hass,
|
||||
Platform.BINARY_SENSOR,
|
||||
DOMAIN,
|
||||
{CONF_ZONES: zones, CONF_OUTPUTS: outputs},
|
||||
config,
|
||||
)
|
||||
)
|
||||
|
||||
hass.async_create_task(
|
||||
async_load_platform(
|
||||
hass,
|
||||
Platform.SWITCH,
|
||||
DOMAIN,
|
||||
{
|
||||
CONF_SWITCHABLE_OUTPUTS: switchable_outputs,
|
||||
CONF_DEVICE_CODE: conf.get(CONF_DEVICE_CODE),
|
||||
},
|
||||
config,
|
||||
)
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@callback
|
||||
def alarm_status_update_callback():
|
||||
@@ -179,3 +229,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bool:
|
||||
"""Unloading the Satel platforms."""
|
||||
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
controller = entry.runtime_data
|
||||
controller.close()
|
||||
|
||||
return unload_ok
|
||||
|
@@ -14,46 +14,49 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
CodeFormat,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import (
|
||||
from .const import (
|
||||
CONF_ARM_HOME_MODE,
|
||||
CONF_DEVICE_PARTITIONS,
|
||||
CONF_ZONE_NAME,
|
||||
DATA_SATEL,
|
||||
CONF_PARTITION_NUMBER,
|
||||
SIGNAL_PANEL_MESSAGE,
|
||||
SUBENTRY_TYPE_PARTITION,
|
||||
SatelConfigEntry,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
config_entry: SatelConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up for Satel Integra alarm panels."""
|
||||
if not discovery_info:
|
||||
return
|
||||
|
||||
configured_partitions = discovery_info[CONF_DEVICE_PARTITIONS]
|
||||
controller = hass.data[DATA_SATEL]
|
||||
controller = config_entry.runtime_data
|
||||
|
||||
devices = []
|
||||
partition_subentries = filter(
|
||||
lambda entry: entry.subentry_type == SUBENTRY_TYPE_PARTITION,
|
||||
config_entry.subentries.values(),
|
||||
)
|
||||
|
||||
for partition_num, device_config_data in configured_partitions.items():
|
||||
zone_name = device_config_data[CONF_ZONE_NAME]
|
||||
arm_home_mode = device_config_data.get(CONF_ARM_HOME_MODE)
|
||||
device = SatelIntegraAlarmPanel(
|
||||
controller, zone_name, arm_home_mode, partition_num
|
||||
for subentry in partition_subentries:
|
||||
partition_num = subentry.data[CONF_PARTITION_NUMBER]
|
||||
zone_name = subentry.data[CONF_NAME]
|
||||
arm_home_mode = subentry.data[CONF_ARM_HOME_MODE]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SatelIntegraAlarmPanel(
|
||||
controller, zone_name, arm_home_mode, partition_num
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
devices.append(device)
|
||||
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
||||
@@ -66,7 +69,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
)
|
||||
|
||||
def __init__(self, controller, name, arm_home_mode, partition_id):
|
||||
def __init__(self, controller, name, arm_home_mode, partition_id) -> None:
|
||||
"""Initialize the alarm panel."""
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = f"satel_alarm_panel_{partition_id}"
|
||||
|
@@ -6,61 +6,79 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import (
|
||||
CONF_OUTPUTS,
|
||||
CONF_ZONE_NAME,
|
||||
from .const import (
|
||||
CONF_OUTPUT_NUMBER,
|
||||
CONF_ZONE_NUMBER,
|
||||
CONF_ZONE_TYPE,
|
||||
CONF_ZONES,
|
||||
DATA_SATEL,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
SIGNAL_ZONES_UPDATED,
|
||||
SUBENTRY_TYPE_OUTPUT,
|
||||
SUBENTRY_TYPE_ZONE,
|
||||
SatelConfigEntry,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
config_entry: SatelConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Satel Integra binary sensor devices."""
|
||||
if not discovery_info:
|
||||
return
|
||||
|
||||
configured_zones = discovery_info[CONF_ZONES]
|
||||
controller = hass.data[DATA_SATEL]
|
||||
controller = config_entry.runtime_data
|
||||
|
||||
devices = []
|
||||
zone_subentries = filter(
|
||||
lambda entry: entry.subentry_type == SUBENTRY_TYPE_ZONE,
|
||||
config_entry.subentries.values(),
|
||||
)
|
||||
|
||||
for zone_num, device_config_data in configured_zones.items():
|
||||
zone_type = device_config_data[CONF_ZONE_TYPE]
|
||||
zone_name = device_config_data[CONF_ZONE_NAME]
|
||||
device = SatelIntegraBinarySensor(
|
||||
controller, zone_num, zone_name, zone_type, CONF_ZONES, SIGNAL_ZONES_UPDATED
|
||||
for subentry in zone_subentries:
|
||||
zone_num = subentry.data[CONF_ZONE_NUMBER]
|
||||
zone_type = subentry.data[CONF_ZONE_TYPE]
|
||||
zone_name = subentry.data[CONF_NAME]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SatelIntegraBinarySensor(
|
||||
controller,
|
||||
zone_num,
|
||||
zone_name,
|
||||
zone_type,
|
||||
SUBENTRY_TYPE_ZONE,
|
||||
SIGNAL_ZONES_UPDATED,
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
devices.append(device)
|
||||
|
||||
configured_outputs = discovery_info[CONF_OUTPUTS]
|
||||
output_subentries = filter(
|
||||
lambda entry: entry.subentry_type == SUBENTRY_TYPE_OUTPUT,
|
||||
config_entry.subentries.values(),
|
||||
)
|
||||
|
||||
for zone_num, device_config_data in configured_outputs.items():
|
||||
zone_type = device_config_data[CONF_ZONE_TYPE]
|
||||
zone_name = device_config_data[CONF_ZONE_NAME]
|
||||
device = SatelIntegraBinarySensor(
|
||||
controller,
|
||||
zone_num,
|
||||
zone_name,
|
||||
zone_type,
|
||||
CONF_OUTPUTS,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
for subentry in output_subentries:
|
||||
output_num = subentry.data[CONF_OUTPUT_NUMBER]
|
||||
ouput_type = subentry.data[CONF_ZONE_TYPE]
|
||||
output_name = subentry.data[CONF_NAME]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
SatelIntegraBinarySensor(
|
||||
controller,
|
||||
output_num,
|
||||
output_name,
|
||||
ouput_type,
|
||||
SUBENTRY_TYPE_OUTPUT,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
devices.append(device)
|
||||
|
||||
async_add_entities(devices)
|
||||
|
||||
|
||||
class SatelIntegraBinarySensor(BinarySensorEntity):
|
||||
|
496
homeassistant/components/satel_integra/config_flow.py
Normal file
496
homeassistant/components/satel_integra/config_flow.py
Normal file
@@ -0,0 +1,496 @@
|
||||
"""Config flow for Satel Integra."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from satel_integra.satel_integra import AsyncSatel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryData,
|
||||
ConfigSubentryFlow,
|
||||
OptionsFlowWithReload,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
|
||||
from .const import (
|
||||
CONF_ARM_HOME_MODE,
|
||||
CONF_DEVICE_PARTITIONS,
|
||||
CONF_OUTPUT_NUMBER,
|
||||
CONF_OUTPUTS,
|
||||
CONF_PARTITION_NUMBER,
|
||||
CONF_SWITCHABLE_OUTPUT_NUMBER,
|
||||
CONF_SWITCHABLE_OUTPUTS,
|
||||
CONF_ZONE_NUMBER,
|
||||
CONF_ZONE_TYPE,
|
||||
CONF_ZONES,
|
||||
DEFAULT_CONF_ARM_HOME_MODE,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_OUTPUT,
|
||||
SUBENTRY_TYPE_PARTITION,
|
||||
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
|
||||
SUBENTRY_TYPE_ZONE,
|
||||
SatelConfigEntry,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__package__)
|
||||
|
||||
CONNECTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_CODE): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
CODE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_CODE): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
PARTITION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In(
|
||||
[1, 2, 3]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
ZONE_AND_OUTPUT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(
|
||||
CONF_ZONE_TYPE, default=BinarySensorDeviceClass.MOTION
|
||||
): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[cls.value for cls in BinarySensorDeviceClass],
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
translation_key="binary_sensor_device_class",
|
||||
sort=True,
|
||||
),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SWITCHABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
|
||||
|
||||
|
||||
class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Satel Integra config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: SatelConfigEntry,
|
||||
) -> SatelOptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return SatelOptionsFlow()
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {
|
||||
SUBENTRY_TYPE_PARTITION: PartitionSubentryFlowHandler,
|
||||
SUBENTRY_TYPE_ZONE: ZoneSubentryFlowHandler,
|
||||
SUBENTRY_TYPE_OUTPUT: OutputSubentryFlowHandler,
|
||||
SUBENTRY_TYPE_SWITCHABLE_OUTPUT: SwitchableOutputSubentryFlowHandler,
|
||||
}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
valid = await self.test_connection(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
)
|
||||
|
||||
if valid:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST],
|
||||
data={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
},
|
||||
options={CONF_CODE: user_input.get(CONF_CODE)},
|
||||
)
|
||||
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=CONNECTION_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_import(
|
||||
self, import_config: dict[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by import."""
|
||||
|
||||
valid = await self.test_connection(
|
||||
import_config[CONF_HOST], import_config.get(CONF_PORT, DEFAULT_PORT)
|
||||
)
|
||||
|
||||
if valid:
|
||||
subentries: list[ConfigSubentryData] = []
|
||||
|
||||
for partition_number, partition_data in import_config.get(
|
||||
CONF_DEVICE_PARTITIONS, {}
|
||||
).items():
|
||||
subentries.append(
|
||||
{
|
||||
"subentry_type": SUBENTRY_TYPE_PARTITION,
|
||||
"title": partition_data[CONF_NAME],
|
||||
"unique_id": f"{SUBENTRY_TYPE_PARTITION}_{partition_number}",
|
||||
"data": {
|
||||
CONF_NAME: partition_data[CONF_NAME],
|
||||
CONF_ARM_HOME_MODE: partition_data.get(
|
||||
CONF_ARM_HOME_MODE, DEFAULT_CONF_ARM_HOME_MODE
|
||||
),
|
||||
CONF_PARTITION_NUMBER: partition_number,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for zone_number, zone_data in import_config.get(CONF_ZONES, {}).items():
|
||||
subentries.append(
|
||||
{
|
||||
"subentry_type": SUBENTRY_TYPE_ZONE,
|
||||
"title": zone_data[CONF_NAME],
|
||||
"unique_id": f"{SUBENTRY_TYPE_ZONE}_{zone_number}",
|
||||
"data": {
|
||||
CONF_NAME: zone_data[CONF_NAME],
|
||||
CONF_ZONE_NUMBER: zone_number,
|
||||
CONF_ZONE_TYPE: zone_data.get(
|
||||
CONF_ZONE_TYPE, BinarySensorDeviceClass.MOTION
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for output_number, output_data in import_config.get(
|
||||
CONF_OUTPUTS, {}
|
||||
).items():
|
||||
subentries.append(
|
||||
{
|
||||
"subentry_type": SUBENTRY_TYPE_OUTPUT,
|
||||
"title": output_data[CONF_NAME],
|
||||
"unique_id": f"{SUBENTRY_TYPE_OUTPUT}_{output_number}",
|
||||
"data": {
|
||||
CONF_NAME: output_data[CONF_NAME],
|
||||
CONF_OUTPUT_NUMBER: output_number,
|
||||
CONF_ZONE_TYPE: output_data.get(
|
||||
CONF_ZONE_TYPE, BinarySensorDeviceClass.MOTION
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for switchable_output_number, switchable_output_data in import_config.get(
|
||||
CONF_SWITCHABLE_OUTPUTS, {}
|
||||
).items():
|
||||
subentries.append(
|
||||
{
|
||||
"subentry_type": SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
|
||||
"title": switchable_output_data[CONF_NAME],
|
||||
"unique_id": f"{SUBENTRY_TYPE_SWITCHABLE_OUTPUT}_{switchable_output_number}",
|
||||
"data": {
|
||||
CONF_NAME: switchable_output_data[CONF_NAME],
|
||||
CONF_SWITCHABLE_OUTPUT_NUMBER: switchable_output_number,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=import_config[CONF_HOST],
|
||||
data={
|
||||
CONF_HOST: import_config[CONF_HOST],
|
||||
CONF_PORT: import_config.get(CONF_PORT, DEFAULT_PORT),
|
||||
},
|
||||
options={CONF_CODE: import_config.get(CONF_CODE)},
|
||||
subentries=subentries,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
async def test_connection(self, host: str, port: int) -> bool:
|
||||
"""Test a connection to the Satel alarm."""
|
||||
controller = AsyncSatel(host, port, self.hass.loop)
|
||||
|
||||
result = await controller.connect()
|
||||
|
||||
# Make sure we close the connection again
|
||||
controller.close()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class SatelOptionsFlow(OptionsFlowWithReload):
|
||||
"""Handle Satel options flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Init step."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(data={CONF_CODE: user_input.get(CONF_CODE)})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
CODE_SCHEMA, self.config_entry.options
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PartitionSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding and modifying a partition."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add new partition."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = f"{SUBENTRY_TYPE_PARTITION}_{user_input[CONF_PARTITION_NUMBER]}"
|
||||
|
||||
for existing_subentry in self._get_entry().subentries.values():
|
||||
if existing_subentry.unique_id == unique_id:
|
||||
errors[CONF_PARTITION_NUMBER] = "already_configured"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=user_input, unique_id=unique_id
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PARTITION_NUMBER): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
).extend(PARTITION_SCHEMA.schema),
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Reconfigure existing partition."""
|
||||
subconfig_entry = self._get_reconfigure_subentry()
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
subconfig_entry,
|
||||
title=user_input[CONF_NAME],
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
PARTITION_SCHEMA,
|
||||
subconfig_entry.data,
|
||||
),
|
||||
description_placeholders={
|
||||
CONF_PARTITION_NUMBER: subconfig_entry.data[CONF_PARTITION_NUMBER]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ZoneSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding and modifying a zone."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add new zone."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = f"{SUBENTRY_TYPE_ZONE}_{user_input[CONF_ZONE_NUMBER]}"
|
||||
|
||||
for existing_subentry in self._get_entry().subentries.values():
|
||||
if existing_subentry.unique_id == unique_id:
|
||||
errors[CONF_ZONE_NUMBER] = "already_configured"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=user_input, unique_id=unique_id
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ZONE_NUMBER): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
).extend(ZONE_AND_OUTPUT_SCHEMA.schema),
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Reconfigure existing zone."""
|
||||
subconfig_entry = self._get_reconfigure_subentry()
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
subconfig_entry,
|
||||
title=user_input[CONF_NAME],
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
ZONE_AND_OUTPUT_SCHEMA, subconfig_entry.data
|
||||
),
|
||||
description_placeholders={
|
||||
CONF_ZONE_NUMBER: subconfig_entry.data[CONF_ZONE_NUMBER]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class OutputSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding and modifying a output."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add new output."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = f"{SUBENTRY_TYPE_OUTPUT}_{user_input[CONF_OUTPUT_NUMBER]}"
|
||||
|
||||
for existing_subentry in self._get_entry().subentries.values():
|
||||
if existing_subentry.unique_id == unique_id:
|
||||
errors[CONF_OUTPUT_NUMBER] = "already_configured"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=user_input, unique_id=unique_id
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OUTPUT_NUMBER): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
).extend(ZONE_AND_OUTPUT_SCHEMA.schema),
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Reconfigure existing output."""
|
||||
subconfig_entry = self._get_reconfigure_subentry()
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
subconfig_entry,
|
||||
title=user_input[CONF_NAME],
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
ZONE_AND_OUTPUT_SCHEMA, subconfig_entry.data
|
||||
),
|
||||
description_placeholders={
|
||||
CONF_OUTPUT_NUMBER: subconfig_entry.data[CONF_OUTPUT_NUMBER]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SwitchableOutputSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding and modifying a switchable output."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add new switchable output."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = f"{SUBENTRY_TYPE_SWITCHABLE_OUTPUT}_{user_input[CONF_SWITCHABLE_OUTPUT_NUMBER]}"
|
||||
|
||||
for existing_subentry in self._get_entry().subentries.values():
|
||||
if existing_subentry.unique_id == unique_id:
|
||||
errors[CONF_SWITCHABLE_OUTPUT_NUMBER] = "already_configured"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=user_input, unique_id=unique_id
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SWITCHABLE_OUTPUT_NUMBER): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
}
|
||||
).extend(SWITCHABLE_OUTPUT_SCHEMA.schema),
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Reconfigure existing switchable output."""
|
||||
subconfig_entry = self._get_reconfigure_subentry()
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
subconfig_entry,
|
||||
title=user_input[CONF_NAME],
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
SWITCHABLE_OUTPUT_SCHEMA, subconfig_entry.data
|
||||
),
|
||||
description_placeholders={
|
||||
CONF_SWITCHABLE_OUTPUT_NUMBER: subconfig_entry.data[
|
||||
CONF_SWITCHABLE_OUTPUT_NUMBER
|
||||
]
|
||||
},
|
||||
)
|
38
homeassistant/components/satel_integra/const.py
Normal file
38
homeassistant/components/satel_integra/const.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Constants for the Satel Integra integration."""
|
||||
|
||||
from satel_integra.satel_integra import AsyncSatel
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
DEFAULT_CONF_ARM_HOME_MODE = 1
|
||||
DEFAULT_PORT = 7094
|
||||
DEFAULT_ZONE_TYPE = "motion"
|
||||
|
||||
DOMAIN = "satel_integra"
|
||||
|
||||
SUBENTRY_TYPE_PARTITION = "partition"
|
||||
SUBENTRY_TYPE_ZONE = "zone"
|
||||
SUBENTRY_TYPE_OUTPUT = "output"
|
||||
SUBENTRY_TYPE_SWITCHABLE_OUTPUT = "switchable_output"
|
||||
|
||||
CONF_PARTITION_NUMBER = "partition_number"
|
||||
CONF_ZONE_NUMBER = "zone_number"
|
||||
CONF_OUTPUT_NUMBER = "output_number"
|
||||
CONF_SWITCHABLE_OUTPUT_NUMBER = "switchable_output_number"
|
||||
|
||||
CONF_DEVICE_PARTITIONS = "partitions"
|
||||
CONF_ARM_HOME_MODE = "arm_home_mode"
|
||||
CONF_ZONE_TYPE = "type"
|
||||
CONF_ZONES = "zones"
|
||||
CONF_OUTPUTS = "outputs"
|
||||
CONF_SWITCHABLE_OUTPUTS = "switchable_outputs"
|
||||
|
||||
ZONES = "zones"
|
||||
|
||||
|
||||
SIGNAL_PANEL_MESSAGE = "satel_integra.panel_message"
|
||||
|
||||
SIGNAL_ZONES_UPDATED = "satel_integra.zones_updated"
|
||||
SIGNAL_OUTPUTS_UPDATED = "satel_integra.outputs_updated"
|
||||
|
||||
type SatelConfigEntry = ConfigEntry[AsyncSatel]
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user