Compare commits

..

4 commits

Author SHA1 Message Date
Marc Mueller
0d78ef5209 Force build 2024-11-10 14:55:16 +01:00
Marc Mueller
220e54c19f Speedup 2024-11-10 13:23:15 +01:00
Marc Mueller
d0982eab88 Remove charset-normalizer from skip-binary 2024-11-10 13:13:23 +01:00
Marc Mueller
c04ab5b041 Build wheels for mypyc projects 2024-11-10 13:13:23 +01:00
250 changed files with 2623 additions and 4915 deletions

View file

@ -10,7 +10,7 @@ on:
env: env:
BUILD_TYPE: core BUILD_TYPE: core
DEFAULT_PYTHON: "3.13" DEFAULT_PYTHON: "3.12"
PIP_TIMEOUT: 60 PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true" UV_SYSTEM_PYTHON: "true"

View file

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.27.3 uses: github/codeql-action/init@v3.27.0
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.27.3 uses: github/codeql-action/analyze@v3.27.0
with: with:
category: "/language:python" category: "/language:python"

View file

@ -76,6 +76,9 @@ jobs:
# Use C-Extension for SQLAlchemy # Use C-Extension for SQLAlchemy
echo "REQUIRE_SQLALCHEMY_CEXT=1" echo "REQUIRE_SQLALCHEMY_CEXT=1"
# Mypyc Extensions
echo "CHARSET_NORMALIZER_USE_MYPYC=1"
) > .env_file ) > .env_file
- name: Upload env_file - name: Upload env_file
@ -106,7 +109,7 @@ jobs:
core: core:
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2) name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
if: github.repository_owner == 'home-assistant' if: false && github.repository_owner == 'home-assistant'
needs: init needs: init
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
@ -208,6 +211,14 @@ jobs:
touch requirements_old-cython.txt touch requirements_old-cython.txt
cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt
- name: Create requirements for mypyc
id: mypyc-reqs
run: |
echo "mypy=$(cat requirements_test.txt | grep -e 'mypy.*==')" >> $GITHUB_OUTPUT
touch requirements_mypyc.txt
cat homeassistant/package_constraints.txt | grep 'charset-normalizer' >> requirements_mypyc.txt
- name: Build wheels (old cython) - name: Build wheels (old cython)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2024.11.0
if: matrix.abi == 'cp312' if: matrix.abi == 'cp312'
@ -218,12 +229,27 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl skip-binary: aiohttp;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_old-cython.txt" requirements: "requirements_old-cython.txt"
pip: "'cython<3'" pip: "'cython<3'"
- name: Build wheels (mypyc)
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_mypyc.txt"
pip: "${{ steps.mypyc-reqs.outputs.mypy }}"
- name: Build wheels (part 1) - name: Build wheels (part 1)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2024.11.0
with: with:
@ -233,7 +259,7 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtaa" requirements: "requirements_all.txtaa"
@ -247,7 +273,7 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtab" requirements: "requirements_all.txtab"
@ -261,7 +287,7 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtac" requirements: "requirements_all.txtac"

View file

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.3 rev: v0.7.2
hooks: hooks:
- id: ruff - id: ruff
args: args:
@ -90,7 +90,7 @@ repos:
pass_filenames: false pass_filenames: false
language: script language: script
types: [text] types: [text]
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$ files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$
- id: hassfest-mypy-config - id: hassfest-mypy-config
name: hassfest-mypy-config name: hassfest-mypy-config
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config

View file

@ -40,8 +40,6 @@ build.json @home-assistant/supervisor
# Integrations # Integrations
/homeassistant/components/abode/ @shred86 /homeassistant/components/abode/ @shred86
/tests/components/abode/ @shred86 /tests/components/abode/ @shred86
/homeassistant/components/acaia/ @zweckj
/tests/components/acaia/ @zweckj
/homeassistant/components/accuweather/ @bieniu /homeassistant/components/accuweather/ @bieniu
/tests/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray /homeassistant/components/acmeda/ @atmurray
@ -1346,8 +1344,6 @@ build.json @home-assistant/supervisor
/tests/components/siren/ @home-assistant/core @raman325 /tests/components/siren/ @home-assistant/core @raman325
/homeassistant/components/sisyphus/ @jkeljo /homeassistant/components/sisyphus/ @jkeljo
/homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/sky_hub/ @rogerselwyn
/homeassistant/components/sky_remote/ @dunnmj @saty9
/tests/components/sky_remote/ @dunnmj @saty9
/homeassistant/components/skybell/ @tkdrob /homeassistant/components/skybell/ @tkdrob
/tests/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @tkdrob @fletcherau /homeassistant/components/slack/ @tkdrob @fletcherau
@ -1489,8 +1485,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike /homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @home-assistant/core /homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/tests/components/template/ @PhracturedBlue @home-assistant/core /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks /homeassistant/components/tesla_wall_connector/ @einarhauks

View file

@ -55,7 +55,7 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \ "armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \ esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \ && chmod +x /bin/go2rtc \
# Verify go2rtc can be executed # Verify go2rtc can be executed
&& go2rtc --version && go2rtc --version

View file

@ -35,9 +35,6 @@ RUN \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
# Install uv # Install uv
RUN pip3 install uv RUN pip3 install uv

View file

@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0 i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.1
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io

View file

@ -515,7 +515,7 @@ async def async_from_config_dict(
issue_registry.async_create_issue( issue_registry.async_create_issue(
hass, hass,
core.DOMAIN, core.DOMAIN,
f"python_version_{required_python_version}", "python_version",
is_fixable=False, is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING, severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE, breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,

View file

@ -1,5 +0,0 @@
{
"domain": "sky",
"name": "Sky",
"integrations": ["sky_hub", "sky_remote"]
}

View file

@ -1,29 +0,0 @@
"""Initialize the Acaia component."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AcaiaConfigEntry, AcaiaCoordinator
PLATFORMS = [
Platform.BUTTON,
]
async def async_setup_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Set up acaia as config entry."""
coordinator = AcaiaCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -1,61 +0,0 @@
"""Button entities for Acaia scales."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from aioacaia.acaiascale import AcaiaScale
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@dataclass(kw_only=True, frozen=True)
class AcaiaButtonEntityDescription(ButtonEntityDescription):
"""Description for acaia button entities."""
press_fn: Callable[[AcaiaScale], Coroutine[Any, Any, None]]
BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = (
AcaiaButtonEntityDescription(
key="tare",
translation_key="tare",
press_fn=lambda scale: scale.tare(),
),
AcaiaButtonEntityDescription(
key="reset_timer",
translation_key="reset_timer",
press_fn=lambda scale: scale.reset_timer(),
),
AcaiaButtonEntityDescription(
key="start_stop",
translation_key="start_stop",
press_fn=lambda scale: scale.start_stop_timer(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button entities and services."""
coordinator = entry.runtime_data
async_add_entities(AcaiaButton(coordinator, description) for description in BUTTONS)
class AcaiaButton(AcaiaEntity, ButtonEntity):
"""Representation of an Acaia button."""
entity_description: AcaiaButtonEntityDescription
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(self._scale)

View file

@ -1,149 +0,0 @@
"""Config flow for Acaia integration."""
import logging
from typing import Any
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
from aioacaia.helpers import is_new_scale
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
_LOGGER = logging.getLogger(__name__)
class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for acaia."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, Any] = {}
self._discovered_devices: dict[str, str] = {}
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:
mac = format_mac(user_input[CONF_ADDRESS])
try:
is_new_style_scale = await is_new_scale(mac)
except AcaiaDeviceNotFound:
errors["base"] = "device_not_found"
except AcaiaError:
_LOGGER.exception("Error occurred while connecting to the scale")
errors["base"] = "unknown"
except AcaiaUnknownDevice:
return self.async_abort(reason="unsupported_device")
else:
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=self._discovered_devices[user_input[CONF_ADDRESS]],
data={
CONF_ADDRESS: mac,
CONF_IS_NEW_STYLE_SCALE: is_new_style_scale,
},
)
for device in async_discovered_service_info(self.hass):
self._discovered_devices[device.address] = device.name
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
options = [
SelectOptionDict(
value=device_mac,
label=f"{device_name} ({device_mac})",
)
for device_mac, device_name in self._discovered_devices.items()
]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
)
)
}
),
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a discovered Bluetooth device."""
self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address)
self._discovered[CONF_NAME] = discovery_info.name
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
try:
self._discovered[CONF_IS_NEW_STYLE_SCALE] = await is_new_scale(
discovery_info.address
)
except AcaiaDeviceNotFound:
_LOGGER.debug("Device not found during discovery")
return self.async_abort(reason="device_not_found")
except AcaiaError:
_LOGGER.debug(
"Error occurred while connecting to the scale during discovery",
exc_info=True,
)
return self.async_abort(reason="unknown")
except AcaiaUnknownDevice:
_LOGGER.debug("Unsupported device during discovery")
return self.async_abort(reason="unsupported_device")
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle confirmation of Bluetooth discovery."""
if user_input is not None:
return self.async_create_entry(
title=self._discovered[CONF_NAME],
data={
CONF_ADDRESS: self._discovered[CONF_ADDRESS],
CONF_IS_NEW_STYLE_SCALE: self._discovered[CONF_IS_NEW_STYLE_SCALE],
},
)
self.context["title_placeholders"] = placeholders = {
CONF_NAME: self._discovered[CONF_NAME]
}
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=placeholders,
)

View file

@ -1,4 +0,0 @@
"""Constants for component."""
DOMAIN = "acaia"
CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale"

View file

@ -1,86 +0,0 @@
"""Coordinator for Acaia integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from aioacaia.acaiascale import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_IS_NEW_STYLE_SCALE
SCAN_INTERVAL = timedelta(seconds=15)
_LOGGER = logging.getLogger(__name__)
type AcaiaConfigEntry = ConfigEntry[AcaiaCoordinator]
class AcaiaCoordinator(DataUpdateCoordinator[None]):
"""Class to handle fetching data from the scale."""
config_entry: AcaiaConfigEntry
def __init__(self, hass: HomeAssistant, entry: AcaiaConfigEntry) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name="acaia coordinator",
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
self._scale = AcaiaScale(
address_or_ble_device=entry.data[CONF_ADDRESS],
name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
notify_callback=self.async_update_listeners,
)
@property
def scale(self) -> AcaiaScale:
"""Return the scale object."""
return self._scale
async def _async_update_data(self) -> None:
"""Fetch data."""
# scale is already connected, return
if self._scale.connected:
return
# scale is not connected, try to connect
try:
await self._scale.connect(setup_tasks=False)
except (AcaiaDeviceNotFound, AcaiaError, TimeoutError) as ex:
_LOGGER.debug(
"Could not connect to scale: %s, Error: %s",
self.config_entry.data[CONF_ADDRESS],
ex,
)
self._scale.device_disconnected_handler(notify=False)
return
# connected, set up background tasks
if not self._scale.heartbeat_task or self._scale.heartbeat_task.done():
self._scale.heartbeat_task = self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.send_heartbeats(),
name="acaia_heartbeat_task",
)
if not self._scale.process_queue_task or self._scale.process_queue_task.done():
self._scale.process_queue_task = (
self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.process_queue(),
name="acaia_process_queue_task",
)
)

View file

@ -1,40 +0,0 @@
"""Base class for Acaia entities."""
from dataclasses import dataclass
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AcaiaCoordinator
@dataclass
class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]):
"""Common elements for all entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AcaiaCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._scale = coordinator.scale
self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._scale.mac)},
manufacturer="Acaia",
model=self._scale.model,
suggested_area="Kitchen",
)
@property
def available(self) -> bool:
"""Returns whether entity is available."""
return super().available and self._scale.connected

View file

@ -1,15 +0,0 @@
{
"entity": {
"button": {
"tare": {
"default": "mdi:scale-balance"
},
"reset_timer": {
"default": "mdi:timer-refresh"
},
"start_stop": {
"default": "mdi:timer-play"
}
}
}
}

View file

@ -1,29 +0,0 @@
{
"domain": "acaia",
"name": "Acaia",
"bluetooth": [
{
"manufacturer_id": 16962
},
{
"local_name": "ACAIA*"
},
{
"local_name": "PYXIS-*"
},
{
"local_name": "LUNAR-*"
},
{
"local_name": "PROCHBT001"
}
],
"codeowners": ["@zweckj"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/acaia",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioacaia"],
"requirements": ["aioacaia==0.1.6"]
}

View file

@ -1,38 +0,0 @@
{
"config": {
"flow_title": "{name}",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unsupported_device": "This device is not supported."
},
"error": {
"device_not_found": "Device could not be found.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
}
}
}
},
"entity": {
"button": {
"tare": {
"name": "Tare"
},
"reset_timer": {
"name": "Reset timer"
},
"start_stop": {
"name": "Start/stop timer"
}
}
}
}

View file

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone", "documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairzone"], "loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.6"] "requirements": ["aioairzone==0.9.5"]
} }

View file

@ -6,7 +6,7 @@ import asyncio
from datetime import timedelta from datetime import timedelta
from functools import partial from functools import partial
import logging import logging
from typing import TYPE_CHECKING, Any, Final, final from typing import Any, Final, final
from propcache import cached_property from propcache import cached_property
import voluptuous as vol import voluptuous as vol
@ -221,15 +221,9 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
@property @property
def state(self) -> str | None: def state(self) -> str | None:
"""Return the current state.""" """Return the current state."""
if (alarm_state := self.alarm_state) is not None: if (alarm_state := self.alarm_state) is None:
return alarm_state
if self._attr_state is not None:
# Backwards compatibility for integrations that set state directly
# Should be removed in 2025.11
if TYPE_CHECKING:
assert isinstance(self._attr_state, str)
return self._attr_state
return None return None
return alarm_state
@cached_property @cached_property
def alarm_state(self) -> AlarmControlPanelState | None: def alarm_state(self) -> AlarmControlPanelState | None:

View file

@ -32,9 +32,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_handle_create_service(call: ServiceCall) -> None: async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups.""" """Service handler for creating backups."""
await backup_manager.async_create_backup(on_progress=None) await backup_manager.async_create_backup()
if backup_task := backup_manager.backup_task:
await backup_task
hass.services.async_register(DOMAIN, "create", async_handle_create_service) hass.services.async_register(DOMAIN, "create", async_handle_create_service)

View file

@ -2,26 +2,23 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from http import HTTPStatus from http import HTTPStatus
from typing import cast
from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION from aiohttp.hdrs import CONTENT_DISPOSITION
from aiohttp.web import FileResponse, Request, Response from aiohttp.web import FileResponse, Request, Response
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify from homeassistant.util import slugify
from .const import DATA_MANAGER from .const import DOMAIN
from .manager import BaseBackupManager
@callback @callback
def async_register_http_views(hass: HomeAssistant) -> None: def async_register_http_views(hass: HomeAssistant) -> None:
"""Register the http views.""" """Register the http views."""
hass.http.register_view(DownloadBackupView) hass.http.register_view(DownloadBackupView)
hass.http.register_view(UploadBackupView)
class DownloadBackupView(HomeAssistantView): class DownloadBackupView(HomeAssistantView):
@ -39,7 +36,7 @@ class DownloadBackupView(HomeAssistantView):
if not request["hass_user"].is_admin: if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED) return Response(status=HTTPStatus.UNAUTHORIZED)
manager = request.app[KEY_HASS].data[DATA_MANAGER] manager: BaseBackupManager = request.app[KEY_HASS].data[DOMAIN]
backup = await manager.async_get_backup(slug=slug) backup = await manager.async_get_backup(slug=slug)
if backup is None or not backup.path.exists(): if backup is None or not backup.path.exists():
@ -51,29 +48,3 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}, },
) )
class UploadBackupView(HomeAssistantView):
"""Generate backup view."""
url = "/api/backup/upload"
name = "api:backup:upload"
@require_admin
async def post(self, request: Request) -> Response:
"""Upload a backup file."""
manager = request.app[KEY_HASS].data[DATA_MANAGER]
reader = await request.multipart()
contents = cast(BodyPartReader, await reader.next())
try:
await manager.async_receive_backup(contents=contents)
except OSError as err:
return Response(
body=f"Can't write backup file {err}",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
except asyncio.CancelledError:
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
return Response(status=HTTPStatus.CREATED)

View file

@ -4,21 +4,16 @@ from __future__ import annotations
import abc import abc
import asyncio import asyncio
from collections.abc import Callable
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
import hashlib import hashlib
import io import io
import json import json
from pathlib import Path from pathlib import Path
from queue import SimpleQueue
import shutil
import tarfile import tarfile
from tarfile import TarError from tarfile import TarError
from tempfile import TemporaryDirectory
import time import time
from typing import Any, Protocol, cast from typing import Any, Protocol, cast
import aiohttp
from securetar import SecureTarFile, atomic_contents_add from securetar import SecureTarFile, atomic_contents_add
from homeassistant.backup_restore import RESTORE_BACKUP_FILE from homeassistant.backup_restore import RESTORE_BACKUP_FILE
@ -35,13 +30,6 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
BUF_SIZE = 2**20 * 4 # 4MB BUF_SIZE = 2**20 * 4 # 4MB
@dataclass(slots=True)
class NewBackup:
"""New backup class."""
slug: str
@dataclass(slots=True) @dataclass(slots=True)
class Backup: class Backup:
"""Backup class.""" """Backup class."""
@ -57,15 +45,6 @@ class Backup:
return {**asdict(self), "path": self.path.as_posix()} return {**asdict(self), "path": self.path.as_posix()}
@dataclass(slots=True)
class BackupProgress:
"""Backup progress class."""
done: bool
stage: str | None
success: bool | None
class BackupPlatformProtocol(Protocol): class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have.""" """Define the format that backup platforms can have."""
@ -82,7 +61,7 @@ class BaseBackupManager(abc.ABC):
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager.""" """Initialize the backup manager."""
self.hass = hass self.hass = hass
self.backup_task: asyncio.Task | None = None self.backing_up = False
self.backups: dict[str, Backup] = {} self.backups: dict[str, Backup] = {}
self.loaded_platforms = False self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {} self.platforms: dict[str, BackupPlatformProtocol] = {}
@ -147,15 +126,10 @@ class BaseBackupManager(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: async def async_restore_backup(self, slug: str, **kwargs: Any) -> None:
"""Restore a backup.""" """Restpre a backup."""
@abc.abstractmethod @abc.abstractmethod
async def async_create_backup( async def async_create_backup(self, **kwargs: Any) -> Backup:
self,
*,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup.""" """Generate a backup."""
@abc.abstractmethod @abc.abstractmethod
@ -173,15 +147,6 @@ class BaseBackupManager(abc.ABC):
async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Remove a backup.""" """Remove a backup."""
@abc.abstractmethod
async def async_receive_backup(
self,
*,
contents: aiohttp.BodyPartReader,
**kwargs: Any,
) -> None:
"""Receive and store a backup file from upload."""
class BackupManager(BaseBackupManager): class BackupManager(BaseBackupManager):
"""Backup manager for the Backup integration.""" """Backup manager for the Backup integration."""
@ -257,93 +222,17 @@ class BackupManager(BaseBackupManager):
LOGGER.debug("Removed backup located at %s", backup.path) LOGGER.debug("Removed backup located at %s", backup.path)
self.backups.pop(slug) self.backups.pop(slug)
async def async_receive_backup( async def async_create_backup(self, **kwargs: Any) -> Backup:
self,
*,
contents: aiohttp.BodyPartReader,
**kwargs: Any,
) -> None:
"""Receive and store a backup file from upload."""
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = (
SimpleQueue()
)
temp_dir_handler = await self.hass.async_add_executor_job(TemporaryDirectory)
target_temp_file = Path(
temp_dir_handler.name, contents.filename or "backup.tar"
)
def _sync_queue_consumer() -> None:
with target_temp_file.open("wb") as file_handle:
while True:
if (_chunk_future := queue.get()) is None:
break
_chunk, _future = _chunk_future
if _future is not None:
self.hass.loop.call_soon_threadsafe(_future.set_result, None)
file_handle.write(_chunk)
fut: asyncio.Future[None] | None = None
try:
fut = self.hass.async_add_executor_job(_sync_queue_consumer)
megabytes_sending = 0
while chunk := await contents.read_chunk(BUF_SIZE):
megabytes_sending += 1
if megabytes_sending % 5 != 0:
queue.put_nowait((chunk, None))
continue
chunk_future = self.hass.loop.create_future()
queue.put_nowait((chunk, chunk_future))
await asyncio.wait(
(fut, chunk_future),
return_when=asyncio.FIRST_COMPLETED,
)
if fut.done():
# The executor job failed
break
queue.put_nowait(None) # terminate queue consumer
finally:
if fut is not None:
await fut
def _move_and_cleanup() -> None:
shutil.move(target_temp_file, self.backup_dir / target_temp_file.name)
temp_dir_handler.cleanup()
await self.hass.async_add_executor_job(_move_and_cleanup)
await self.load_backups()
async def async_create_backup(
self,
*,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup.""" """Generate a backup."""
if self.backup_task: if self.backing_up:
raise HomeAssistantError("Backup already in progress") raise HomeAssistantError("Backup already in progress")
try:
self.backing_up = True
await self.async_pre_backup_actions()
backup_name = f"Core {HAVERSION}" backup_name = f"Core {HAVERSION}"
date_str = dt_util.now().isoformat() date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name) slug = _generate_slug(date_str, backup_name)
self.backup_task = self.hass.async_create_task(
self._async_create_backup(backup_name, date_str, slug, on_progress),
name="backup_manager_create_backup",
eager_start=False, # To ensure the task is not started before we return
)
return NewBackup(slug=slug)
async def _async_create_backup(
self,
backup_name: str,
date_str: str,
slug: str,
on_progress: Callable[[BackupProgress], None] | None,
) -> Backup:
"""Generate a backup."""
success = False
try:
await self.async_pre_backup_actions()
backup_data = { backup_data = {
"slug": slug, "slug": slug,
@ -370,12 +259,9 @@ class BackupManager(BaseBackupManager):
if self.loaded_backups: if self.loaded_backups:
self.backups[slug] = backup self.backups[slug] = backup
LOGGER.debug("Generated new backup with slug %s", slug) LOGGER.debug("Generated new backup with slug %s", slug)
success = True
return backup return backup
finally: finally:
if on_progress: self.backing_up = False
on_progress(BackupProgress(done=True, stage=None, success=success))
self.backup_task = None
await self.async_post_backup_actions() await self.async_post_backup_actions()
def _mkdir_and_generate_backup_contents( def _mkdir_and_generate_backup_contents(

View file

@ -8,7 +8,6 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .const import DATA_MANAGER, LOGGER from .const import DATA_MANAGER, LOGGER
from .manager import BackupProgress
@callback @callback
@ -41,7 +40,7 @@ async def handle_info(
msg["id"], msg["id"],
{ {
"backups": list(backups.values()), "backups": list(backups.values()),
"backing_up": manager.backup_task is not None, "backing_up": manager.backing_up,
}, },
) )
@ -114,11 +113,7 @@ async def handle_create(
msg: dict[str, Any], msg: dict[str, Any],
) -> None: ) -> None:
"""Generate a backup.""" """Generate a backup."""
backup = await hass.data[DATA_MANAGER].async_create_backup()
def on_progress(progress: BackupProgress) -> None:
connection.send_message(websocket_api.event_message(msg["id"], progress))
backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress)
connection.send_result(msg["id"], backup) connection.send_result(msg["id"], backup)
@ -132,6 +127,7 @@ async def handle_backup_start(
) -> None: ) -> None:
"""Backup start notification.""" """Backup start notification."""
manager = hass.data[DATA_MANAGER] manager = hass.data[DATA_MANAGER]
manager.backing_up = True
LOGGER.debug("Backup start notification") LOGGER.debug("Backup start notification")
try: try:
@ -153,6 +149,7 @@ async def handle_backup_end(
) -> None: ) -> None:
"""Backup end notification.""" """Backup end notification."""
manager = hass.data[DATA_MANAGER] manager = hass.data[DATA_MANAGER]
manager.backing_up = False
LOGGER.debug("Backup end notification") LOGGER.debug("Backup end notification")
try: try:

View file

@ -7,6 +7,6 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiostreammagic"], "loggers": ["aiostreammagic"],
"requirements": ["aiostreammagic==2.8.5"], "requirements": ["aiostreammagic==2.8.4"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
} }

View file

@ -51,13 +51,8 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
CambridgeAudioSelectEntityDescription( CambridgeAudioSelectEntityDescription(
key="display_brightness", key="display_brightness",
translation_key="display_brightness", translation_key="display_brightness",
options=[ options=[x.value for x in DisplayBrightness],
DisplayBrightness.BRIGHT.value,
DisplayBrightness.DIM.value,
DisplayBrightness.OFF.value,
],
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
value_fn=lambda client: client.display.brightness, value_fn=lambda client: client.display.brightness,
set_value_fn=lambda client, value: client.set_display_brightness( set_value_fn=lambda client, value: client.set_display_brightness(
DisplayBrightness(value) DisplayBrightness(value)

View file

@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
import asyncio import asyncio
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from functools import cache, partial, wraps from functools import cache, partial
import logging import logging
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any, Protocol
@ -205,49 +205,6 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
) )
type WsCommandWithCamera = Callable[
[websocket_api.ActiveConnection, dict[str, Any], Camera],
Awaitable[None],
]
def require_webrtc_support(
error_code: str,
) -> Callable[[WsCommandWithCamera], websocket_api.AsyncWebSocketCommandHandler]:
"""Validate that the camera supports WebRTC."""
def decorate(
func: WsCommandWithCamera,
) -> websocket_api.AsyncWebSocketCommandHandler:
"""Decorate func."""
@wraps(func)
async def validate(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Validate that the camera supports WebRTC."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
error_code,
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await func(connection, msg, camera)
return validate
return decorate
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "camera/webrtc/offer", vol.Required("type"): "camera/webrtc/offer",
@ -256,9 +213,8 @@ def require_webrtc_support(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_offer_failed")
async def ws_webrtc_offer( async def ws_webrtc_offer(
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None: ) -> None:
"""Handle the signal path for a WebRTC stream. """Handle the signal path for a WebRTC stream.
@ -270,7 +226,20 @@ async def ws_webrtc_offer(
Async friendly. Async friendly.
""" """
entity_id = msg["entity_id"]
offer = msg["offer"] offer = msg["offer"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_offer_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
session_id = ulid() session_id = ulid()
connection.subscriptions[msg["id"]] = partial( connection.subscriptions[msg["id"]] = partial(
camera.close_webrtc_session, session_id camera.close_webrtc_session, session_id
@ -309,11 +278,23 @@ async def ws_webrtc_offer(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_get_client_config_failed")
async def ws_get_client_config( async def ws_get_client_config(
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None: ) -> None:
"""Handle get WebRTC client config websocket command.""" """Handle get WebRTC client config websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_get_client_config_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
config = camera.async_get_webrtc_client_configuration().to_frontend_dict() config = camera.async_get_webrtc_client_configuration().to_frontend_dict()
connection.send_result( connection.send_result(
msg["id"], msg["id"],
@ -330,11 +311,23 @@ async def ws_get_client_config(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_candidate_failed")
async def ws_candidate( async def ws_candidate(
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None: ) -> None:
"""Handle WebRTC candidate websocket command.""" """Handle WebRTC candidate websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_candidate_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await camera.async_on_webrtc_candidate( await camera.async_on_webrtc_candidate(
msg["session_id"], RTCIceCandidate(msg["candidate"]) msg["session_id"], RTCIceCandidate(msg["candidate"])
) )

View file

@ -440,16 +440,16 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "cloud/update_prefs", vol.Required("type"): "cloud/update_prefs",
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool,
vol.Optional(PREF_ENABLE_GOOGLE): bool, vol.Optional(PREF_ENABLE_GOOGLE): bool,
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
vol.Coerce(tuple), validate_language_voice vol.Coerce(tuple), validate_language_voice
), ),
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool,
} }
) )
@websocket_api.async_response @websocket_api.async_response

View file

@ -163,21 +163,21 @@ class CloudPreferences:
async def async_update( async def async_update(
self, self,
*, *,
alexa_enabled: bool | UndefinedType = UNDEFINED,
alexa_report_state: bool | UndefinedType = UNDEFINED,
alexa_settings_version: int | UndefinedType = UNDEFINED,
cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED,
cloud_user: str | UndefinedType = UNDEFINED,
cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED,
google_connected: bool | UndefinedType = UNDEFINED,
google_enabled: bool | UndefinedType = UNDEFINED, google_enabled: bool | UndefinedType = UNDEFINED,
google_report_state: bool | UndefinedType = UNDEFINED, alexa_enabled: bool | UndefinedType = UNDEFINED,
google_secure_devices_pin: str | None | UndefinedType = UNDEFINED,
google_settings_version: int | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
remote_domain: str | None | UndefinedType = UNDEFINED,
remote_enabled: bool | UndefinedType = UNDEFINED, remote_enabled: bool | UndefinedType = UNDEFINED,
google_secure_devices_pin: str | None | UndefinedType = UNDEFINED,
cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED,
cloud_user: str | UndefinedType = UNDEFINED,
alexa_report_state: bool | UndefinedType = UNDEFINED,
google_report_state: bool | UndefinedType = UNDEFINED,
tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED,
remote_domain: str | None | UndefinedType = UNDEFINED,
alexa_settings_version: int | UndefinedType = UNDEFINED,
google_settings_version: int | UndefinedType = UNDEFINED,
google_connected: bool | UndefinedType = UNDEFINED,
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED,
) -> None: ) -> None:
"""Update user preferences.""" """Update user preferences."""
prefs = {**self._prefs} prefs = {**self._prefs}
@ -186,21 +186,21 @@ class CloudPreferences:
{ {
key: value key: value
for key, value in ( for key, value in (
(PREF_ALEXA_REPORT_STATE, alexa_report_state),
(PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version),
(PREF_CLOUD_USER, cloud_user),
(PREF_CLOUDHOOKS, cloudhooks),
(PREF_ENABLE_ALEXA, alexa_enabled),
(PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled),
(PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_GOOGLE, google_enabled),
(PREF_ENABLE_ALEXA, alexa_enabled),
(PREF_ENABLE_REMOTE, remote_enabled), (PREF_ENABLE_REMOTE, remote_enabled),
(PREF_GOOGLE_CONNECTED, google_connected),
(PREF_GOOGLE_REPORT_STATE, google_report_state),
(PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
(PREF_CLOUDHOOKS, cloudhooks),
(PREF_CLOUD_USER, cloud_user),
(PREF_ALEXA_REPORT_STATE, alexa_report_state),
(PREF_GOOGLE_REPORT_STATE, google_report_state),
(PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version),
(PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
(PREF_REMOTE_DOMAIN, remote_domain),
(PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_TTS_DEFAULT_VOICE, tts_default_voice),
(PREF_REMOTE_DOMAIN, remote_domain),
(PREF_GOOGLE_CONNECTED, google_connected),
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
(PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled),
) )
if value is not UNDEFINED if value is not UNDEFINED
} }
@ -242,7 +242,6 @@ class CloudPreferences:
PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_CLOUDHOOKS: self.cloudhooks, PREF_CLOUDHOOKS: self.cloudhooks,
PREF_ENABLE_ALEXA: self.alexa_enabled, PREF_ENABLE_ALEXA: self.alexa_enabled,
PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
PREF_ENABLE_GOOGLE: self.google_enabled, PREF_ENABLE_GOOGLE: self.google_enabled,
PREF_ENABLE_REMOTE: self.remote_enabled, PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose,
@ -250,6 +249,7 @@ class CloudPreferences:
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
} }
@property @property

View file

@ -168,7 +168,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
) )
return self.async_create_entry( return self.async_create_entry(
title=get_extra_name(data) or "Electricity Maps", title=get_extra_name(data) or "CO2 Signal",
data=data, data=data,
) )

View file

@ -16,11 +16,11 @@ from hassil.expression import Expression, ListReference, Sequence
from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList
from hassil.recognize import ( from hassil.recognize import (
MISSING_ENTITY, MISSING_ENTITY,
MatchEntity,
RecognizeResult, RecognizeResult,
UnmatchedTextEntity,
recognize_all, recognize_all,
recognize_best,
) )
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from hassil.util import merge_dict from hassil.util import merge_dict
from home_assistant_intents import ErrorKey, get_intents, get_languages from home_assistant_intents import ErrorKey, get_intents, get_languages
import yaml import yaml
@ -294,7 +294,7 @@ class DefaultAgent(ConversationEntity):
self.hass, language, DOMAIN, [DOMAIN] self.hass, language, DOMAIN, [DOMAIN]
) )
response_text = translations.get( response_text = translations.get(
f"component.{DOMAIN}.conversation.agent.done", "Done" f"component.{DOMAIN}.agent.done", "Done"
) )
response.async_set_speech(response_text) response.async_set_speech(response_text)
@ -499,7 +499,6 @@ class DefaultAgent(ConversationEntity):
maybe_result: RecognizeResult | None = None maybe_result: RecognizeResult | None = None
best_num_matched_entities = 0 best_num_matched_entities = 0
best_num_unmatched_entities = 0 best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all( for result in recognize_all(
user_input.text, user_input.text,
lang_intents.intents, lang_intents.intents,
@ -518,14 +517,10 @@ class DefaultAgent(ConversationEntity):
num_matched_entities += 1 num_matched_entities += 1
num_unmatched_entities = 0 num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list: for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity): if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY: if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1 num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else: else:
num_unmatched_entities += 1 num_unmatched_entities += 1
@ -537,24 +532,15 @@ class DefaultAgent(ConversationEntity):
(num_matched_entities == best_num_matched_entities) (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities) and (num_unmatched_entities < best_num_unmatched_entities)
) )
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or ( or (
# More literal text matched # More literal text matched
(num_matched_entities == best_num_matched_entities) (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities) and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (result.text_chunks_matched > maybe_result.text_chunks_matched) and (result.text_chunks_matched > maybe_result.text_chunks_matched)
) )
or ( or (
# Prefer match failures with entities # Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched) (result.text_chunks_matched == maybe_result.text_chunks_matched)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and ( and (
("name" in result.entities) ("name" in result.entities)
or ("name" in result.unmatched_entities) or ("name" in result.unmatched_entities)
@ -564,7 +550,6 @@ class DefaultAgent(ConversationEntity):
maybe_result = result maybe_result = result
best_num_matched_entities = num_matched_entities best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
return maybe_result return maybe_result
@ -577,16 +562,77 @@ class DefaultAgent(ConversationEntity):
language: str, language: str,
) -> RecognizeResult | None: ) -> RecognizeResult | None:
"""Search intents for a strict match to user input.""" """Search intents for a strict match to user input."""
return recognize_best( custom_found = False
name_found = False
best_results: list[RecognizeResult] = []
best_name_quality: int | None = None
best_text_chunks_matched: int | None = None
for result in recognize_all(
user_input.text, user_input.text,
lang_intents.intents, lang_intents.intents,
slot_lists=slot_lists, slot_lists=slot_lists,
intent_context=intent_context, intent_context=intent_context,
language=language, language=language,
best_metadata_key=METADATA_CUSTOM_SENTENCE, ):
best_slot_name="name", # Prioritize user intents
is_custom = (
result.intent_metadata is not None
and result.intent_metadata.get(METADATA_CUSTOM_SENTENCE)
) )
if custom_found and not is_custom:
continue
if not custom_found and is_custom:
custom_found = True
# Clear builtin results
name_found = False
best_results = []
best_name_quality = None
best_text_chunks_matched = None
# Prioritize results with a "name" slot
name = result.entities.get("name")
is_name = name and not name.is_wildcard
if name_found and not is_name:
continue
if not name_found and is_name:
name_found = True
# Clear non-name results
best_results = []
best_text_chunks_matched = None
if is_name:
# Prioritize results with a better "name" slot
name_quality = len(cast(MatchEntity, name).value.split())
if (best_name_quality is None) or (name_quality > best_name_quality):
best_name_quality = name_quality
# Clear worse name results
best_results = []
best_text_chunks_matched = None
elif name_quality < best_name_quality:
continue
# Prioritize results with more literal text
# This causes wildcards to match last.
if (best_text_chunks_matched is None) or (
result.text_chunks_matched > best_text_chunks_matched
):
best_results = [result]
best_text_chunks_matched = result.text_chunks_matched
elif result.text_chunks_matched == best_text_chunks_matched:
# Accumulate results with the same number of literal text matched.
# We will resolve the ambiguity below.
best_results.append(result)
if best_results:
# Successful strict match
return best_results[0]
return None
async def _build_speech( async def _build_speech(
self, self,
language: str, language: str,

View file

@ -6,8 +6,12 @@ from collections.abc import Iterable
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
from hassil.recognize import MISSING_ENTITY, RecognizeResult from hassil.recognize import (
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity MISSING_ENTITY,
RecognizeResult,
UnmatchedRangeEntity,
UnmatchedTextEntity,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.components import http, websocket_api from homeassistant.components import http, websocket_api

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==2.0.1", "home-assistant-intents==2024.11.13"] "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"]
} }

View file

@ -4,8 +4,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from hassil.recognize import RecognizeResult from hassil.recognize import PUNCTUATION, RecognizeResult
from hassil.util import PUNCTUATION_ALL
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
@ -21,7 +20,7 @@ from .const import DATA_DEFAULT_ENTITY, DOMAIN
def has_no_punctuation(value: list[str]) -> list[str]: def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation.""" """Validate result does not contain punctuation."""
for sentence in value: for sentence in value:
if PUNCTUATION_ALL.search(sentence): if PUNCTUATION.search(sentence):
raise vol.Invalid("sentence should not contain punctuation") raise vol.Invalid("sentence should not contain punctuation")
return value return value

View file

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/doods", "documentation": "https://www.home-assistant.io/integrations/doods",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pydoods"], "loggers": ["pydoods"],
"requirements": ["pydoods==1.0.2", "Pillow==11.0.0"] "requirements": ["pydoods==1.0.2", "Pillow==10.4.0"]
} }

View file

@ -6,14 +6,9 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from homeassistant.components.number import ( from homeassistant.components.number import NumberEntity, NumberEntityDescription
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -59,26 +54,17 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the ecobee thermostat number entity.""" """Set up the ecobee thermostat number entity."""
data: EcobeeData = hass.data[DOMAIN] data: EcobeeData = hass.data[DOMAIN]
_LOGGER.debug("Adding min time ventilators numbers (if present)")
assert data is not None async_add_entities(
(
entities: list[NumberEntity] = [
EcobeeVentilatorMinTime(data, index, numbers) EcobeeVentilatorMinTime(data, index, numbers)
for index, thermostat in enumerate(data.ecobee.thermostats) for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["ventilatorType"] != "none" if thermostat["settings"]["ventilatorType"] != "none"
for numbers in VENTILATOR_NUMBERS for numbers in VENTILATOR_NUMBERS
] ),
True,
_LOGGER.debug("Adding compressor min temp number (if present)")
entities.extend(
(
EcobeeCompressorMinTemp(data, index)
for index, thermostat in enumerate(data.ecobee.thermostats)
if thermostat["settings"]["hasHeatPump"]
) )
)
async_add_entities(entities, True)
class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity):
@ -119,53 +105,3 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity):
"""Set new ventilator Min On Time value.""" """Set new ventilator Min On Time value."""
self.entity_description.set_fn(self.data, self.thermostat_index, int(value)) self.entity_description.set_fn(self.data, self.thermostat_index, int(value))
self.update_without_throttle = True self.update_without_throttle = True
class EcobeeCompressorMinTemp(EcobeeBaseEntity, NumberEntity):
"""Minimum outdoor temperature at which the compressor will operate.
This applies more to air source heat pumps than geothermal. This serves as a safety
feature (compressors have a minimum operating temperature) as well as
providing the ability to choose fuel in a dual-fuel system (i.e. choose between
electrical heat pump and fossil auxiliary heat depending on Time of Use, Solar,
etc.).
Note that python-ecobee-api refers to this as Aux Cutover Threshold, but Ecobee
uses Compressor Protection Min Temp.
"""
_attr_device_class = NumberDeviceClass.TEMPERATURE
_attr_has_entity_name = True
_attr_icon = "mdi:thermometer-off"
_attr_mode = NumberMode.BOX
_attr_native_min_value = -25
_attr_native_max_value = 66
_attr_native_step = 5
_attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
_attr_translation_key = "compressor_protection_min_temp"
def __init__(
self,
data: EcobeeData,
thermostat_index: int,
) -> None:
"""Initialize ecobee compressor min temperature."""
super().__init__(data, thermostat_index)
self._attr_unique_id = f"{self.base_unique_id}_compressor_protection_min_temp"
self.update_without_throttle = False
async def async_update(self) -> None:
"""Get the latest state from the thermostat."""
if self.update_without_throttle:
await self.data.update(no_throttle=True)
self.update_without_throttle = False
else:
await self.data.update()
self._attr_native_value = (
(self.thermostat["settings"]["compressorProtectionMinTemp"]) / 10
)
def set_native_value(self, value: float) -> None:
"""Set new compressor minimum temperature."""
self.data.ecobee.set_aux_cutover_threshold(self.thermostat_index, value)
self.update_without_throttle = True

View file

@ -33,18 +33,15 @@
}, },
"number": { "number": {
"ventilator_min_type_home": { "ventilator_min_type_home": {
"name": "Ventilator minimum time home" "name": "Ventilator min time home"
}, },
"ventilator_min_type_away": { "ventilator_min_type_away": {
"name": "Ventilator minimum time away" "name": "Ventilator min time away"
},
"compressor_protection_min_temp": {
"name": "Compressor minimum temperature"
} }
}, },
"switch": { "switch": {
"aux_heat_only": { "aux_heat_only": {
"name": "Auxiliary heat only" "name": "Aux heat only"
} }
} }
}, },

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==8.4.1"] "requirements": ["py-sucks==0.9.10", "deebot-client==8.4.0"]
} }

View file

@ -15,23 +15,17 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
from .models import Eq3Config, Eq3ConfigEntryData from .models import Eq3Config, Eq3ConfigEntryData
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE, Platform.CLIMATE,
Platform.NUMBER,
Platform.SWITCH,
] ]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type Eq3ConfigEntry = ConfigEntry[Eq3ConfigEntryData] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
"""Handle config entry setup.""" """Handle config entry setup."""
mac_address: str | None = entry.unique_id mac_address: str | None = entry.unique_id
@ -59,11 +53,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
ble_device=device, ble_device=device,
) )
entry.runtime_data = Eq3ConfigEntryData( eq3_config_entry = Eq3ConfigEntryData(eq3_config=eq3_config, thermostat=thermostat)
eq3_config=eq3_config, thermostat=thermostat hass.data.setdefault(DOMAIN, {})[entry.entry_id] = eq3_config_entry
)
entry.async_on_unload(entry.add_update_listener(update_listener)) entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_create_background_task( entry.async_create_background_task(
hass, _async_run_thermostat(hass, entry), entry.entry_id hass, _async_run_thermostat(hass, entry), entry.entry_id
) )
@ -71,27 +66,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle config entry unload.""" """Handle config entry unload."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.thermostat.async_disconnect() eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN].pop(entry.entry_id)
await eq3_config_entry.thermostat.async_disconnect()
return unload_ok return unload_ok
async def update_listener(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle config entry update.""" """Handle config entry update."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
async def _async_run_thermostat(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None: async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Run the thermostat.""" """Run the thermostat."""
thermostat = entry.runtime_data.thermostat eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id]
mac_address = entry.runtime_data.eq3_config.mac_address thermostat = eq3_config_entry.thermostat
scan_interval = entry.runtime_data.eq3_config.scan_interval mac_address = eq3_config_entry.eq3_config.mac_address
scan_interval = eq3_config_entry.eq3_config.scan_interval
await _async_reconnect_thermostat(hass, entry) await _async_reconnect_thermostat(hass, entry)
@ -120,14 +117,13 @@ async def _async_run_thermostat(hass: HomeAssistant, entry: Eq3ConfigEntry) -> N
await asyncio.sleep(scan_interval) await asyncio.sleep(scan_interval)
async def _async_reconnect_thermostat( async def _async_reconnect_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None:
hass: HomeAssistant, entry: Eq3ConfigEntry
) -> None:
"""Reconnect the thermostat.""" """Reconnect the thermostat."""
thermostat = entry.runtime_data.thermostat eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id]
mac_address = entry.runtime_data.eq3_config.mac_address thermostat = eq3_config_entry.thermostat
scan_interval = entry.runtime_data.eq3_config.scan_interval mac_address = eq3_config_entry.eq3_config.mac_address
scan_interval = eq3_config_entry.eq3_config.scan_interval
while True: while True:
try: try:

View file

@ -1,86 +0,0 @@
"""Platform for eq3 binary sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart.models import Status
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_BATTERY, ENTITY_KEY_DST, ENTITY_KEY_WINDOW
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3BinarySensorEntityDescription(BinarySensorEntityDescription):
"""Entity description for eq3 binary sensors."""
value_func: Callable[[Status], bool]
BINARY_SENSOR_ENTITY_DESCRIPTIONS = [
Eq3BinarySensorEntityDescription(
value_func=lambda status: status.is_low_battery,
key=ENTITY_KEY_BATTERY,
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
),
Eq3BinarySensorEntityDescription(
value_func=lambda status: status.is_window_open,
key=ENTITY_KEY_WINDOW,
device_class=BinarySensorDeviceClass.WINDOW,
),
Eq3BinarySensorEntityDescription(
value_func=lambda status: status.is_dst,
key=ENTITY_KEY_DST,
translation_key=ENTITY_KEY_DST,
entity_category=EntityCategory.DIAGNOSTIC,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3BinarySensorEntity(entry, entity_description)
for entity_description in BINARY_SENSOR_ENTITY_DESCRIPTIONS
)
class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity):
"""Base class for eQ-3 binary sensor entities."""
entity_description: Eq3BinarySensorEntityDescription
def __init__(
self,
entry: Eq3ConfigEntry,
entity_description: Eq3BinarySensorEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
@property
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status)

View file

@ -3,6 +3,7 @@
import logging import logging
from typing import Any from typing import Any
from eq3btsmart import Thermostat
from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode
from eq3btsmart.exceptions import Eq3Exception from eq3btsmart.exceptions import Eq3Exception
@ -14,35 +15,45 @@ from homeassistant.components.climate import (
HVACAction, HVACAction,
HVACMode, HVACMode,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from . import Eq3ConfigEntry
from .const import ( from .const import (
DEVICE_MODEL,
DOMAIN,
EQ_TO_HA_HVAC, EQ_TO_HA_HVAC,
HA_TO_EQ_HVAC, HA_TO_EQ_HVAC,
MANUFACTURER,
SIGNAL_THERMOSTAT_CONNECTED,
SIGNAL_THERMOSTAT_DISCONNECTED,
CurrentTemperatureSelector, CurrentTemperatureSelector,
Preset, Preset,
TargetTemperatureSelector, TargetTemperatureSelector,
) )
from .entity import Eq3Entity from .entity import Eq3Entity
from .models import Eq3Config, Eq3ConfigEntryData
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: Eq3ConfigEntry, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Handle config entry setup.""" """Handle config entry setup."""
eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities( async_add_entities(
[Eq3Climate(entry)], [Eq3Climate(eq3_config_entry.eq3_config, eq3_config_entry.thermostat)],
) )
@ -69,6 +80,53 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
_attr_preset_mode: str | None = None _attr_preset_mode: str | None = None
_target_temperature: float | None = None _target_temperature: float | None = None
def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None:
"""Initialize the climate entity."""
super().__init__(eq3_config, thermostat)
self._attr_unique_id = dr.format_mac(eq3_config.mac_address)
self._attr_device_info = DeviceInfo(
name=slugify(self._eq3_config.mac_address),
manufacturer=MANUFACTURER,
model=DEVICE_MODEL,
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self._thermostat.register_update_callback(self._async_on_updated)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}",
self._async_on_disconnected,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}",
self._async_on_connected,
)
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
self._thermostat.unregister_update_callback(self._async_on_updated)
@callback
def _async_on_disconnected(self) -> None:
self._attr_available = False
self.async_write_ha_state()
@callback
def _async_on_connected(self) -> None:
self._attr_available = True
self.async_write_ha_state()
@callback @callback
def _async_on_updated(self) -> None: def _async_on_updated(self) -> None:
"""Handle updated data from the thermostat.""" """Handle updated data from the thermostat."""
@ -79,15 +137,12 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
if self._thermostat.device_data is not None: if self._thermostat.device_data is not None:
self._async_on_device_updated() self._async_on_device_updated()
super()._async_on_updated() self.async_write_ha_state()
@callback @callback
def _async_on_status_updated(self) -> None: def _async_on_status_updated(self) -> None:
"""Handle updated status from the thermostat.""" """Handle updated status from the thermostat."""
if self._thermostat.status is None:
return
self._target_temperature = self._thermostat.status.target_temperature.value self._target_temperature = self._thermostat.status.target_temperature.value
self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode] self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode]
self._attr_current_temperature = self._get_current_temperature() self._attr_current_temperature = self._get_current_temperature()
@ -99,16 +154,13 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
def _async_on_device_updated(self) -> None: def _async_on_device_updated(self) -> None:
"""Handle updated device data from the thermostat.""" """Handle updated device data from the thermostat."""
if self._thermostat.device_data is None:
return
device_registry = dr.async_get(self.hass) device_registry = dr.async_get(self.hass)
if device := device_registry.async_get_device( if device := device_registry.async_get_device(
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
): ):
device_registry.async_update_device( device_registry.async_update_device(
device.id, device.id,
sw_version=str(self._thermostat.device_data.firmware_version), sw_version=self._thermostat.device_data.firmware_version,
serial_number=self._thermostat.device_data.device_serial.value, serial_number=self._thermostat.device_data.device_serial.value,
) )
@ -213,7 +265,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity):
self.async_write_ha_state() self.async_write_ha_state()
try: try:
await self._thermostat.async_set_temperature(temperature) await self._thermostat.async_set_temperature(self._target_temperature)
except Eq3Exception: except Eq3Exception:
_LOGGER.error( _LOGGER.error(
"[%s] Failed setting temperature", self._eq3_config.mac_address "[%s] Failed setting temperature", self._eq3_config.mac_address

View file

@ -18,20 +18,9 @@ DOMAIN = "eq3btsmart"
MANUFACTURER = "eQ-3 AG" MANUFACTURER = "eQ-3 AG"
DEVICE_MODEL = "CC-RT-BLE-EQ" DEVICE_MODEL = "CC-RT-BLE-EQ"
ENTITY_KEY_DST = "dst"
ENTITY_KEY_BATTERY = "battery"
ENTITY_KEY_WINDOW = "window"
ENTITY_KEY_LOCK = "lock"
ENTITY_KEY_BOOST = "boost"
ENTITY_KEY_AWAY = "away"
ENTITY_KEY_COMFORT = "comfort"
ENTITY_KEY_ECO = "eco"
ENTITY_KEY_OFFSET = "offset"
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE = "window_open_temperature"
ENTITY_KEY_WINDOW_OPEN_TIMEOUT = "window_open_timeout"
GET_DEVICE_TIMEOUT = 5 # seconds GET_DEVICE_TIMEOUT = 5 # seconds
EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = {
OperationMode.OFF: HVACMode.OFF, OperationMode.OFF: HVACMode.OFF,
OperationMode.ON: HVACMode.HEAT, OperationMode.ON: HVACMode.HEAT,
@ -82,5 +71,3 @@ DEFAULT_SCAN_INTERVAL = 10 # seconds
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected" SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected" SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"
EQ3BT_STEP = 0.5

View file

@ -1,22 +1,10 @@
"""Base class for all eQ-3 entities.""" """Base class for all eQ-3 entities."""
from homeassistant.core import callback from eq3btsmart.thermostat import Thermostat
from homeassistant.helpers.device_registry import (
CONNECTION_BLUETOOTH,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
from . import Eq3ConfigEntry from homeassistant.helpers.entity import Entity
from .const import (
DEVICE_MODEL, from .models import Eq3Config
MANUFACTURER,
SIGNAL_THERMOSTAT_CONNECTED,
SIGNAL_THERMOSTAT_DISCONNECTED,
)
class Eq3Entity(Entity): class Eq3Entity(Entity):
@ -24,70 +12,8 @@ class Eq3Entity(Entity):
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__( def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None:
self,
entry: Eq3ConfigEntry,
unique_id_key: str | None = None,
) -> None:
"""Initialize the eq3 entity.""" """Initialize the eq3 entity."""
self._eq3_config = entry.runtime_data.eq3_config self._eq3_config = eq3_config
self._thermostat = entry.runtime_data.thermostat self._thermostat = thermostat
self._attr_device_info = DeviceInfo(
name=slugify(self._eq3_config.mac_address),
manufacturer=MANUFACTURER,
model=DEVICE_MODEL,
connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)},
)
suffix = f"_{unique_id_key}" if unique_id_key else ""
self._attr_unique_id = f"{format_mac(self._eq3_config.mac_address)}{suffix}"
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self._thermostat.register_update_callback(self._async_on_updated)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}",
self._async_on_disconnected,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}",
self._async_on_connected,
)
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
self._thermostat.unregister_update_callback(self._async_on_updated)
def _async_on_updated(self) -> None:
"""Handle updated data from the thermostat."""
self.async_write_ha_state()
@callback
def _async_on_disconnected(self) -> None:
"""Handle disconnection from the thermostat."""
self._attr_available = False
self.async_write_ha_state()
@callback
def _async_on_connected(self) -> None:
"""Handle connection to the thermostat."""
self._attr_available = True
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Whether the entity is available."""
return self._thermostat.status is not None and self._attr_available

View file

@ -1,49 +0,0 @@
{
"entity": {
"binary_sensor": {
"dst": {
"default": "mdi:sun-clock",
"state": {
"off": "mdi:sun-clock-outline"
}
}
},
"number": {
"comfort": {
"default": "mdi:sun-thermometer"
},
"eco": {
"default": "mdi:snowflake-thermometer"
},
"offset": {
"default": "mdi:thermometer-plus"
},
"window_open_temperature": {
"default": "mdi:window-open-variant"
},
"window_open_timeout": {
"default": "mdi:timer-refresh"
}
},
"switch": {
"away": {
"default": "mdi:home-account",
"state": {
"on": "mdi:home-export"
}
},
"lock": {
"default": "mdi:lock",
"state": {
"off": "mdi:lock-off"
}
},
"boost": {
"default": "mdi:fire",
"state": {
"off": "mdi:fire-off"
}
}
}
}
}

View file

@ -23,5 +23,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eq3btsmart"], "loggers": ["eq3btsmart"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"] "requirements": ["eq3btsmart==1.2.0", "bleak-esphome==1.1.0"]
} }

View file

@ -2,6 +2,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP
from eq3btsmart.thermostat import Thermostat from eq3btsmart.thermostat import Thermostat
from .const import ( from .const import (
@ -22,6 +23,8 @@ class Eq3Config:
target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR
external_temp_sensor: str = "" external_temp_sensor: str = ""
scan_interval: int = DEFAULT_SCAN_INTERVAL scan_interval: int = DEFAULT_SCAN_INTERVAL
default_away_hours: float = DEFAULT_AWAY_HOURS
default_away_temperature: float = DEFAULT_AWAY_TEMP
@dataclass(slots=True) @dataclass(slots=True)

View file

@ -1,158 +0,0 @@
"""Platform for eq3 number entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart import Thermostat
from eq3btsmart.const import (
EQ3BT_MAX_OFFSET,
EQ3BT_MAX_TEMP,
EQ3BT_MIN_OFFSET,
EQ3BT_MIN_TEMP,
)
from eq3btsmart.models import Presets
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import (
ENTITY_KEY_COMFORT,
ENTITY_KEY_ECO,
ENTITY_KEY_OFFSET,
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
EQ3BT_STEP,
)
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3NumberEntityDescription(NumberEntityDescription):
"""Entity description for eq3 number entities."""
value_func: Callable[[Presets], float]
value_set_func: Callable[
[Thermostat],
Callable[[float], Awaitable[None]],
]
mode: NumberMode = NumberMode.BOX
entity_category: EntityCategory | None = EntityCategory.CONFIG
NUMBER_ENTITY_DESCRIPTIONS = [
Eq3NumberEntityDescription(
key=ENTITY_KEY_COMFORT,
value_func=lambda presets: presets.comfort_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
translation_key=ENTITY_KEY_COMFORT,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_ECO,
value_func=lambda presets: presets.eco_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
translation_key=ENTITY_KEY_ECO,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
value_func=lambda presets: presets.window_open_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_OFFSET,
value_func=lambda presets: presets.offset_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
translation_key=ENTITY_KEY_OFFSET,
native_min_value=EQ3BT_MIN_OFFSET,
native_max_value=EQ3BT_MAX_OFFSET,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60,
translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
native_min_value=0,
native_max_value=60,
native_step=5,
native_unit_of_measurement=UnitOfTime.MINUTES,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3NumberEntity(entry, entity_description)
for entity_description in NUMBER_ENTITY_DESCRIPTIONS
)
class Eq3NumberEntity(Eq3Entity, NumberEntity):
"""Base class for all eq3 number entities."""
entity_description: Eq3NumberEntityDescription
def __init__(
self, entry: Eq3ConfigEntry, entity_description: Eq3NumberEntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
@property
def native_value(self) -> float:
"""Return the state of the entity."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
assert self._thermostat.status.presets is not None
return self.entity_description.value_func(self._thermostat.status.presets)
async def async_set_native_value(self, value: float) -> None:
"""Set the state of the entity."""
await self.entity_description.value_set_func(self._thermostat)(value)
@property
def available(self) -> bool:
"""Return whether the entity is available."""
return (
self._thermostat.status is not None
and self._thermostat.status.presets is not None
and self._attr_available
)

View file

@ -18,40 +18,5 @@
"error": { "error": {
"invalid_mac_address": "Invalid MAC address" "invalid_mac_address": "Invalid MAC address"
} }
},
"entity": {
"binary_sensor": {
"dst": {
"name": "Daylight saving time"
}
},
"number": {
"comfort": {
"name": "Comfort temperature"
},
"eco": {
"name": "Eco temperature"
},
"offset": {
"name": "Offset temperature"
},
"window_open_temperature": {
"name": "Window open temperature"
},
"window_open_timeout": {
"name": "Window open timeout"
}
},
"switch": {
"lock": {
"name": "Lock"
},
"boost": {
"name": "Boost"
},
"away": {
"name": "Away"
}
}
} }
} }

View file

@ -1,94 +0,0 @@
"""Platform for eq3 switch entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from eq3btsmart import Thermostat
from eq3btsmart.models import Status
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3SwitchEntityDescription(SwitchEntityDescription):
"""Entity description for eq3 switch entities."""
toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]]
value_func: Callable[[Status], bool]
SWITCH_ENTITY_DESCRIPTIONS = [
Eq3SwitchEntityDescription(
key=ENTITY_KEY_LOCK,
translation_key=ENTITY_KEY_LOCK,
toggle_func=lambda thermostat: thermostat.async_set_locked,
value_func=lambda status: status.is_locked,
),
Eq3SwitchEntityDescription(
key=ENTITY_KEY_BOOST,
translation_key=ENTITY_KEY_BOOST,
toggle_func=lambda thermostat: thermostat.async_set_boost,
value_func=lambda status: status.is_boost,
),
Eq3SwitchEntityDescription(
key=ENTITY_KEY_AWAY,
translation_key=ENTITY_KEY_AWAY,
toggle_func=lambda thermostat: thermostat.async_set_away,
value_func=lambda status: status.is_away,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3SwitchEntity(entry, entity_description)
for entity_description in SWITCH_ENTITY_DESCRIPTIONS
)
class Eq3SwitchEntity(Eq3Entity, SwitchEntity):
"""Base class for eq3 switch entities."""
entity_description: Eq3SwitchEntityDescription
def __init__(
self,
entry: Eq3ConfigEntry,
entity_description: Eq3SwitchEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
await self.entity_description.toggle_func(self._thermostat)(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
await self.entity_description.toggle_func(self._thermostat)(False)
@property
def is_on(self) -> bool:
"""Return the state of the switch."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status)

View file

@ -7,9 +7,12 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN from .const import DOMAIN
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]

View file

@ -18,7 +18,7 @@
}, },
"data_description": { "data_description": {
"file_path": "The local file path to retrieve the sensor value from", "file_path": "The local file path to retrieve the sensor value from",
"value_template": "A template to render the sensors value based on the file content", "value_template": "A template to render the the sensors value based on the file content",
"unit_of_measurement": "Unit of measurement for the sensor" "unit_of_measurement": "Unit of measurement for the sensor"
} }
}, },

View file

@ -57,8 +57,6 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
_host: str
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
@ -69,6 +67,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize FRITZ!Box Tools flow.""" """Initialize FRITZ!Box Tools flow."""
self._host: str | None = None
self._name: str = "" self._name: str = ""
self._password: str = "" self._password: str = ""
self._use_tls: bool = False self._use_tls: bool = False
@ -113,6 +112,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_check_configured_entry(self) -> ConfigEntry | None: async def async_check_configured_entry(self) -> ConfigEntry | None:
"""Check if entry is configured.""" """Check if entry is configured."""
assert self._host
current_host = await self.hass.async_add_executor_job( current_host = await self.hass.async_add_executor_job(
socket.gethostbyname, self._host socket.gethostbyname, self._host
) )
@ -154,17 +154,15 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow initialized by discovery.""" """Handle a flow initialized by discovery."""
ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "") ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "")
host = ssdp_location.hostname self._host = ssdp_location.hostname
if not host or ipaddress.ip_address(host).is_link_local:
return self.async_abort(reason="ignore_ip6_link_local")
self._host = host
self._name = ( self._name = (
discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
) )
uuid: str | None if not self._host or ipaddress.ip_address(self._host).is_link_local:
return self.async_abort(reason="ignore_ip6_link_local")
if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN):
if uuid.startswith("uuid:"): if uuid.startswith("uuid:"):
uuid = uuid[5:] uuid = uuid[5:]

View file

@ -43,11 +43,10 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
_name: str
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize flow.""" """Initialize flow."""
self._host: str | None = None self._host: str | None = None
self._name: str | None = None
self._password: str | None = None self._password: str | None = None
self._username: str | None = None self._username: str | None = None
@ -159,6 +158,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
result = await self.async_try_connect() result = await self.async_try_connect()
if result == RESULT_SUCCESS: if result == RESULT_SUCCESS:
assert self._name is not None
return self._get_entry(self._name) return self._get_entry(self._name)
if result != RESULT_INVALID_AUTH: if result != RESULT_INVALID_AUTH:
return self.async_abort(reason=result) return self.async_abort(reason=result)

View file

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic", "documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["av==13.1.0", "Pillow==11.0.0"] "requirements": ["av==13.1.0", "Pillow==10.4.0"]
} }

View file

@ -3,7 +3,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Add generic thermostat", "title": "Add generic thermostat helper",
"description": "Create a climate entity that controls the temperature via a switch and sensor.", "description": "Create a climate entity that controls the temperature via a switch and sensor.",
"data": { "data": {
"ac_mode": "Cooling mode", "ac_mode": "Cooling mode",
@ -17,8 +17,8 @@
"data_description": { "data_description": {
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.", "ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
"heater": "Switch entity used to cool or heat depending on A/C mode.", "heater": "Switch entity used to cool or heat depending on A/C mode.",
"target_sensor": "Temperature sensor that reflects the current temperature.", "target_sensor": "Temperature sensor that reflect the current temperature.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.", "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on. This option will be ignored if the keep alive option is set.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.", "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5." "hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5."
} }

View file

@ -9,6 +9,7 @@ import aiohttp
from geniushubclient import GeniusHub from geniushubclient import GeniusHub
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
@ -20,12 +21,20 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
Platform, Platform,
) )
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import (
DOMAIN as HOMEASSISTANT_DOMAIN,
HomeAssistant,
ServiceCall,
callback,
)
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.service import verify_domain_control
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN from .const import DOMAIN
@ -36,6 +45,27 @@ SCAN_INTERVAL = timedelta(seconds=60)
MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$" MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$"
CLOUD_API_SCHEMA = vol.Schema(
{
vol.Required(CONF_TOKEN): cv.string,
vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
}
)
LOCAL_API_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP),
}
)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Any(LOCAL_API_SCHEMA, CLOUD_API_SCHEMA)}, extra=vol.ALLOW_EXTRA
)
ATTR_ZONE_MODE = "mode" ATTR_ZONE_MODE = "mode"
ATTR_DURATION = "duration" ATTR_DURATION = "duration"
@ -70,6 +100,56 @@ PLATFORMS = [
] ]
async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None:
"""Import a config entry from configuration.yaml."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=base_config[DOMAIN],
)
if (
result["type"] is FlowResultType.CREATE_ENTRY
or result["reason"] == "already_configured"
):
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Genius Hub",
},
)
return
async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Genius Hub",
},
)
async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool:
"""Set up a Genius Hub system."""
if DOMAIN in base_config:
hass.async_create_task(_async_import(hass, base_config))
return True
type GeniusHubConfigEntry = ConfigEntry[GeniusBroker] type GeniusHubConfigEntry = ConfigEntry[GeniusBroker]

View file

@ -13,6 +13,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
@ -122,3 +123,14 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA
) )
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import the yaml config."""
if CONF_HOST in import_data:
result = await self.async_step_local_api(import_data)
else:
result = await self.async_step_cloud_api(import_data)
if result["type"] is FlowResultType.FORM:
assert result["errors"]
return self.async_abort(reason=result["errors"]["base"])
return result

View file

@ -1,10 +1,12 @@
"""The go2rtc component.""" """The go2rtc component."""
from __future__ import annotations
from dataclasses import dataclass
import logging import logging
import shutil import shutil
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Go2RtcRestClient from go2rtc_client import Go2RtcRestClient
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.ws import ( from go2rtc_client.ws import (
@ -33,11 +35,7 @@ from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import ( from homeassistant.helpers import config_validation as cv, discovery_flow
config_validation as cv,
discovery_flow,
issue_registry as ir,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
@ -47,8 +45,8 @@ from .const import (
CONF_DEBUG_UI, CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE, DEBUG_UI_URL_MESSAGE,
DOMAIN, DOMAIN,
HA_MANAGED_RTSP_PORT,
HA_MANAGED_URL, HA_MANAGED_URL,
RECOMMENDED_VERSION,
) )
from .server import Server from .server import Server
@ -96,13 +94,22 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN) _DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN)
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
@dataclass(frozen=True)
class Go2RtcData:
"""Data for go2rtc."""
url: str
managed: bool
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up WebRTC.""" """Set up WebRTC."""
url: str | None = None url: str | None = None
managed = False
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config: if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
await _remove_go2rtc_entries(hass) await _remove_go2rtc_entries(hass)
return True return True
@ -137,8 +144,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
url = HA_MANAGED_URL url = HA_MANAGED_URL
managed = True
hass.data[_DATA_GO2RTC] = url hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed)
discovery_flow.async_create_flow( discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
) )
@ -153,42 +161,32 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up go2rtc from a config entry.""" """Set up go2rtc from a config entry."""
url = hass.data[_DATA_GO2RTC] data = hass.data[_DATA_GO2RTC]
# Validate the server URL # Validate the server URL
try: try:
client = Go2RtcRestClient(async_get_clientsession(hass), url) client = Go2RtcRestClient(async_get_clientsession(hass), data.url)
version = await client.validate_server_version() await client.validate_server_version()
if version < AwesomeVersion(RECOMMENDED_VERSION):
ir.async_create_issue(
hass,
DOMAIN,
"recommended_version",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="recommended_version",
translation_placeholders={
"recommended_version": RECOMMENDED_VERSION,
"current_version": str(version),
},
)
except Go2RtcClientError as err: except Go2RtcClientError as err:
if isinstance(err.__cause__, _RETRYABLE_ERRORS): if isinstance(err.__cause__, _RETRYABLE_ERRORS):
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Could not connect to go2rtc instance on {url}" f"Could not connect to go2rtc instance on {data.url}"
) from err ) from err
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) _LOGGER.warning(
"Could not connect to go2rtc instance on %s (%s)", data.url, err
)
return False return False
except Go2RtcVersionError as err: except Go2RtcVersionError as err:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"The go2rtc server version is not supported, {err}" f"The go2rtc server version is not supported, {err}"
) from err ) from err
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) _LOGGER.warning(
"Could not connect to go2rtc instance on %s (%s)", data.url, err
)
return False return False
provider = WebRTCProvider(hass, url) provider = WebRTCProvider(hass, data)
async_register_webrtc_provider(hass, provider) async_register_webrtc_provider(hass, provider)
return True return True
@ -206,12 +204,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
class WebRTCProvider(CameraWebRTCProvider): class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider.""" """WebRTC provider."""
def __init__(self, hass: HomeAssistant, url: str) -> None: def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None:
"""Initialize the WebRTC provider.""" """Initialize the WebRTC provider."""
self._hass = hass self._hass = hass
self._url = url self._data = data
self._session = async_get_clientsession(hass) self._session = async_get_clientsession(hass)
self._rest_client = Go2RtcRestClient(self._session, url) self._rest_client = Go2RtcRestClient(self._session, data.url)
self._sessions: dict[str, Go2RtcWsClient] = {} self._sessions: dict[str, Go2RtcWsClient] = {}
@property @property
@ -233,7 +231,7 @@ class WebRTCProvider(CameraWebRTCProvider):
) -> None: ) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback.""" """Handle the WebRTC offer and return the answer via the provided callback."""
self._sessions[session_id] = ws_client = Go2RtcWsClient( self._sessions[session_id] = ws_client = Go2RtcWsClient(
self._session, self._url, source=camera.entity_id self._session, self._data.url, source=camera.entity_id
) )
if not (stream_source := await camera.stream_source()): if not (stream_source := await camera.stream_source()):
@ -244,18 +242,34 @@ class WebRTCProvider(CameraWebRTCProvider):
streams = await self._rest_client.streams.list() streams = await self._rest_client.streams.list()
if (stream := streams.get(camera.entity_id)) is None or not any( if self._data.managed:
stream_source == producer.url for producer in stream.producers # HA manages the go2rtc instance
stream_original_name = f"{camera.entity_id}_original"
stream_redirect_sources = [
f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}",
f"ffmpeg:{stream_original_name}#audio=opus",
]
if (
(stream_org := streams.get(stream_original_name)) is None
or not any(
stream_source == producer.url for producer in stream_org.producers
)
or (stream_redirect := streams.get(camera.entity_id)) is None
or stream_redirect_sources != [p.url for p in stream_redirect.producers]
):
await self._rest_client.streams.add(stream_original_name, stream_source)
await self._rest_client.streams.add(
camera.entity_id, stream_redirect_sources
)
# go2rtc instance is managed outside HA
elif (stream_org := streams.get(camera.entity_id)) is None or not any(
stream_source == producer.url for producer in stream_org.producers
): ):
await self._rest_client.streams.add( await self._rest_client.streams.add(
camera.entity_id, camera.entity_id,
[ [stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"],
stream_source,
# We are setting any ffmpeg rtsp related logs to debug
# Connection problems to the camera will be logged by the first stream
# Therefore setting it to debug will not hide any important logs
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
],
) )
@callback @callback

View file

@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984 HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
RECOMMENDED_VERSION = "1.9.7" HA_MANAGED_RTSP_PORT = 18554

View file

@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/go2rtc", "documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system", "integration_type": "system",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["go2rtc-client==0.1.1"], "requirements": ["go2rtc-client==0.1.0"],
"single_config_entry": true "single_config_entry": true
} }

View file

@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL from .const import HA_MANAGED_API_PORT, HA_MANAGED_RTSP_PORT, HA_MANAGED_URL
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5 _TERMINATE_TIMEOUT = 5
@ -33,7 +33,7 @@ api:
listen: "{api_ip}:{api_port}" listen: "{api_ip}:{api_port}"
rtsp: rtsp:
listen: "127.0.0.1:18554" listen: "127.0.0.1:{rtsp_port}"
webrtc: webrtc:
listen: ":18555/tcp" listen: ":18555/tcp"
@ -68,7 +68,9 @@ def _create_temp_file(api_ip: str) -> str:
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write( file.write(
_GO2RTC_CONFIG_FORMAT.format( _GO2RTC_CONFIG_FORMAT.format(
api_ip=api_ip, api_port=HA_MANAGED_API_PORT api_ip=api_ip,
api_port=HA_MANAGED_API_PORT,
rtsp_port=HA_MANAGED_RTSP_PORT,
).encode() ).encode()
) )
return file.name return file.name

View file

@ -1,8 +0,0 @@
{
"issues": {
"recommended_version": {
"title": "Outdated go2rtc server detected",
"description": "We detected that you are using an outdated go2rtc server version. For the best experience, we recommend updating the go2rtc server to version `{recommended_version}`.\nCurrently you are using version `{current_version}`."
}
}
}

View file

@ -78,7 +78,6 @@ TYPE_AWNING = f"{PREFIX_TYPES}AWNING"
TYPE_BLINDS = f"{PREFIX_TYPES}BLINDS" TYPE_BLINDS = f"{PREFIX_TYPES}BLINDS"
TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA" TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA"
TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN" TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN"
TYPE_CARBON_MONOXIDE_DETECTOR = f"{PREFIX_TYPES}CARBON_MONOXIDE_DETECTOR"
TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER" TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER"
TYPE_DOOR = f"{PREFIX_TYPES}DOOR" TYPE_DOOR = f"{PREFIX_TYPES}DOOR"
TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL" TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL"
@ -94,7 +93,6 @@ TYPE_SCENE = f"{PREFIX_TYPES}SCENE"
TYPE_SENSOR = f"{PREFIX_TYPES}SENSOR" TYPE_SENSOR = f"{PREFIX_TYPES}SENSOR"
TYPE_SETTOP = f"{PREFIX_TYPES}SETTOP" TYPE_SETTOP = f"{PREFIX_TYPES}SETTOP"
TYPE_SHUTTER = f"{PREFIX_TYPES}SHUTTER" TYPE_SHUTTER = f"{PREFIX_TYPES}SHUTTER"
TYPE_SMOKE_DETECTOR = f"{PREFIX_TYPES}SMOKE_DETECTOR"
TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER" TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER"
TYPE_SWITCH = f"{PREFIX_TYPES}SWITCH" TYPE_SWITCH = f"{PREFIX_TYPES}SWITCH"
TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT"
@ -138,7 +136,6 @@ EVENT_SYNC_RECEIVED = "google_assistant_sync"
DOMAIN_TO_GOOGLE_TYPES = { DOMAIN_TO_GOOGLE_TYPES = {
alarm_control_panel.DOMAIN: TYPE_ALARM, alarm_control_panel.DOMAIN: TYPE_ALARM,
binary_sensor.DOMAIN: TYPE_SENSOR,
button.DOMAIN: TYPE_SCENE, button.DOMAIN: TYPE_SCENE,
camera.DOMAIN: TYPE_CAMERA, camera.DOMAIN: TYPE_CAMERA,
climate.DOMAIN: TYPE_THERMOSTAT, climate.DOMAIN: TYPE_THERMOSTAT,
@ -171,14 +168,6 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
binary_sensor.DOMAIN, binary_sensor.DOMAIN,
binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR,
): TYPE_GARAGE, ): TYPE_GARAGE,
(
binary_sensor.DOMAIN,
binary_sensor.BinarySensorDeviceClass.SMOKE,
): TYPE_SMOKE_DETECTOR,
(
binary_sensor.DOMAIN,
binary_sensor.BinarySensorDeviceClass.CO,
): TYPE_CARBON_MONOXIDE_DETECTOR,
(cover.DOMAIN, cover.CoverDeviceClass.AWNING): TYPE_AWNING, (cover.DOMAIN, cover.CoverDeviceClass.AWNING): TYPE_AWNING,
(cover.DOMAIN, cover.CoverDeviceClass.CURTAIN): TYPE_CURTAIN, (cover.DOMAIN, cover.CoverDeviceClass.CURTAIN): TYPE_CURTAIN,
(cover.DOMAIN, cover.CoverDeviceClass.DOOR): TYPE_DOOR, (cover.DOMAIN, cover.CoverDeviceClass.DOOR): TYPE_DOOR,

View file

@ -2706,21 +2706,6 @@ class SensorStateTrait(_Trait):
), ),
} }
binary_sensor_types = {
binary_sensor.BinarySensorDeviceClass.CO: (
"CarbonMonoxideLevel",
["carbon monoxide detected", "no carbon monoxide detected", "unknown"],
),
binary_sensor.BinarySensorDeviceClass.SMOKE: (
"SmokeLevel",
["smoke detected", "no smoke detected", "unknown"],
),
binary_sensor.BinarySensorDeviceClass.MOISTURE: (
"WaterLeak",
["leak", "no leak", "unknown"],
),
}
name = TRAIT_SENSOR_STATE name = TRAIT_SENSOR_STATE
commands: list[str] = [] commands: list[str] = []
@ -2743,37 +2728,24 @@ class SensorStateTrait(_Trait):
@classmethod @classmethod
def supported(cls, domain, features, device_class, _): def supported(cls, domain, features, device_class, _):
"""Test if state is supported.""" """Test if state is supported."""
return (domain == sensor.DOMAIN and device_class in cls.sensor_types) or ( return domain == sensor.DOMAIN and device_class in cls.sensor_types
domain == binary_sensor.DOMAIN and device_class in cls.binary_sensor_types
)
def sync_attributes(self) -> dict[str, Any]: def sync_attributes(self) -> dict[str, Any]:
"""Return attributes for a sync request.""" """Return attributes for a sync request."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
data = self.sensor_types.get(device_class)
def create_sensor_state( if device_class is None or data is None:
name: str,
raw_value_unit: str | None = None,
available_states: list[str] | None = None,
) -> dict[str, Any]:
sensor_state: dict[str, Any] = {
"name": name,
}
if raw_value_unit:
sensor_state["numericCapabilities"] = {"rawValueUnit": raw_value_unit}
if available_states:
sensor_state["descriptiveCapabilities"] = {
"availableStates": available_states
}
return {"sensorStatesSupported": [sensor_state]}
if self.state.domain == sensor.DOMAIN:
sensor_data = self.sensor_types.get(device_class)
if device_class is None or sensor_data is None:
return {} return {}
available_states: list[str] | None = None
sensor_state = {
"name": data[0],
"numericCapabilities": {"rawValueUnit": data[1]},
}
if device_class == sensor.SensorDeviceClass.AQI: if device_class == sensor.SensorDeviceClass.AQI:
available_states = [ sensor_state["descriptiveCapabilities"] = {
"availableStates": [
"healthy", "healthy",
"moderate", "moderate",
"unhealthy for sensitive groups", "unhealthy for sensitive groups",
@ -2781,53 +2753,30 @@ class SensorStateTrait(_Trait):
"very unhealthy", "very unhealthy",
"hazardous", "hazardous",
"unknown", "unknown",
] ],
return create_sensor_state(sensor_data[0], sensor_data[1], available_states) }
binary_sensor_data = self.binary_sensor_types.get(device_class)
if device_class is None or binary_sensor_data is None: return {"sensorStatesSupported": [sensor_state]}
return {}
return create_sensor_state(
binary_sensor_data[0], available_states=binary_sensor_data[1]
)
def query_attributes(self) -> dict[str, Any]: def query_attributes(self) -> dict[str, Any]:
"""Return the attributes of this trait for this entity.""" """Return the attributes of this trait for this entity."""
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
data = self.sensor_types.get(device_class)
def create_sensor_state( if device_class is None or data is None:
name: str, raw_value: float | None = None, current_state: str | None = None
) -> dict[str, Any]:
sensor_state: dict[str, Any] = {
"name": name,
"rawValue": raw_value,
}
if current_state:
sensor_state["currentSensorState"] = current_state
return {"currentSensorStateData": [sensor_state]}
if self.state.domain == sensor.DOMAIN:
sensor_data = self.sensor_types.get(device_class)
if device_class is None or sensor_data is None:
return {} return {}
try: try:
value = float(self.state.state) value = float(self.state.state)
except ValueError: except ValueError:
value = None value = None
if self.state.state == STATE_UNKNOWN: if self.state.state == STATE_UNKNOWN:
value = None value = None
current_state: str | None = None sensor_data = {"name": data[0], "rawValue": value}
if device_class == sensor.SensorDeviceClass.AQI:
current_state = self._air_quality_description_for_aqi(value)
return create_sensor_state(sensor_data[0], value, current_state)
binary_sensor_data = self.binary_sensor_types.get(device_class) if device_class == sensor.SensorDeviceClass.AQI:
if device_class is None or binary_sensor_data is None: sensor_data["currentSensorState"] = self._air_quality_description_for_aqi(
return {} value
value = {
STATE_ON: 0,
STATE_OFF: 1,
STATE_UNKNOWN: 2,
}[self.state.state]
return create_sensor_state(
binary_sensor_data[0], current_state=binary_sensor_data[1][value]
) )
return {"currentSensorStateData": [sensor_data]}

View file

@ -19,7 +19,6 @@ from homeassistant.core import (
) )
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.selector import ConfigEntrySelector from homeassistant.helpers.selector import ConfigEntrySelector
from .const import ( from .const import (
@ -97,19 +96,6 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
"""Set up services for Habitica integration.""" """Set up services for Habitica integration."""
async def handle_api_call(call: ServiceCall) -> None: async def handle_api_call(call: ServiceCall) -> None:
async_create_issue(
hass,
DOMAIN,
"deprecated_api_call",
breaks_in_ha_version="2025.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_api_call",
)
_LOGGER.warning(
"Deprecated action called: 'habitica.api_call' is deprecated and will be removed in Home Assistant version 2025.6.0"
)
name = call.data[ATTR_NAME] name = call.data[ATTR_NAME]
path = call.data[ATTR_PATH] path = call.data[ATTR_PATH]
entries = hass.config_entries.async_entries(DOMAIN) entries = hass.config_entries.async_entries(DOMAIN)

View file

@ -327,10 +327,6 @@
"deprecated_task_entity": { "deprecated_task_entity": {
"title": "The Habitica {task_name} sensor is deprecated", "title": "The Habitica {task_name} sensor is deprecated",
"description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`."
},
"deprecated_api_call": {
"title": "The Habitica action habitica.api_call is deprecated",
"description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities."
} }
}, },
"services": { "services": {

View file

@ -12,7 +12,6 @@ from homeassistant.components.binary_sensor import (
from homeassistant.components.script import scripts_with_entity from homeassistant.components.script import scripts_with_entity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import ( from homeassistant.helpers.issue_registry import (
IssueSeverity, IssueSeverity,
@ -193,32 +192,11 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass.""" """Call when entity is added to hass."""
await super().async_added_to_hass() await super().async_added_to_hass()
automations = automations_with_entity(self.hass, self.entity_id) entity_automations = automations_with_entity(self.hass, self.entity_id)
scripts = scripts_with_entity(self.hass, self.entity_id) entity_scripts = scripts_with_entity(self.hass, self.entity_id)
items = automations + scripts items = entity_automations + entity_scripts
if not items: if not items:
return return
entity_reg: er.EntityRegistry = er.async_get(self.hass)
entity_automations = [
automation_entity
for automation_id in automations
if (automation_entity := entity_reg.async_get(automation_id))
]
entity_scripts = [
script_entity
for script_id in scripts
if (script_entity := entity_reg.async_get(script_id))
]
items_list = [
f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
for item in entity_automations
] + [
f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
for item in entity_scripts
]
async_create_issue( async_create_issue(
self.hass, self.hass,
DOMAIN, DOMAIN,
@ -229,7 +207,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
translation_key="deprecated_binary_common_door_sensor", translation_key="deprecated_binary_common_door_sensor",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
"items": "\n".join(items_list), "items": "\n".join([f"- {item}" for item in items]),
}, },
) )

View file

@ -318,6 +318,7 @@ class OptionsFlowHandler(OptionsFlow, ABC):
self.start_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None
self.stop_task: asyncio.Task | None = None self.stop_task: asyncio.Task | None = None
self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None
self.config_entry = config_entry
self.original_addon_config: dict[str, Any] | None = None self.original_addon_config: dict[str, Any] | None = None
self.revert_reason: str | None = None self.revert_reason: str | None = None

View file

@ -18,8 +18,6 @@ from homeassistant.const import (
SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_HOME,
SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_NIGHT,
SERVICE_ALARM_DISARM, SERVICE_ALARM_DISARM,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
) )
from homeassistant.core import State, callback from homeassistant.core import State, callback
@ -154,12 +152,12 @@ class SecuritySystem(HomeAccessory):
@callback @callback
def async_update_state(self, new_state: State) -> None: def async_update_state(self, new_state: State) -> None:
"""Update security state after state changed.""" """Update security state after state changed."""
hass_state: str | AlarmControlPanelState = new_state.state hass_state = None
if hass_state in {"None", STATE_UNKNOWN, STATE_UNAVAILABLE}: if new_state and new_state.state == "None":
# Bail out early for no state, unknown or unavailable # Bail out early for no state
return return
if hass_state is not None: if new_state and new_state.state is not None:
hass_state = AlarmControlPanelState(hass_state) hass_state = AlarmControlPanelState(new_state.state)
if ( if (
hass_state hass_state
and (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None and (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None

View file

@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber):
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
async def async_set_native_value(self, value: float) -> None: def set_native_value(self, value: float) -> None:
"""Update the current value.""" """Update the current value."""
self._attr_native_value = value self._attr_native_value = value
self.entity_description.store_value_fn(self.coordinator, self._shade.id, value) self.entity_description.store_value_fn(self.coordinator, self._shade.id, value)

View file

@ -8,7 +8,6 @@ from aioautomower.exceptions import (
ApiException, ApiException,
AuthException, AuthException,
HusqvarnaWSServerHandshakeError, HusqvarnaWSServerHandshakeError,
TimeoutException,
) )
from aioautomower.model import MowerAttributes from aioautomower.model import MowerAttributes
from aioautomower.session import AutomowerSession from aioautomower.session import AutomowerSession
@ -23,7 +22,6 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MAX_WS_RECONNECT_TIME = 600 MAX_WS_RECONNECT_TIME = 600
SCAN_INTERVAL = timedelta(minutes=8) SCAN_INTERVAL = timedelta(minutes=8)
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]):
@ -42,8 +40,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )
self.api = api self.api = api
self.ws_connected: bool = False self.ws_connected: bool = False
self.reconnect_time = DEFAULT_RECONNECT_TIME
async def _async_update_data(self) -> dict[str, MowerAttributes]: async def _async_update_data(self) -> dict[str, MowerAttributes]:
"""Subscribe for websocket and poll data from the API.""" """Subscribe for websocket and poll data from the API."""
@ -68,28 +66,24 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
automower_client: AutomowerSession, automower_client: AutomowerSession,
reconnect_time: int = 2,
) -> None: ) -> None:
"""Listen with the client.""" """Listen with the client."""
try: try:
await automower_client.auth.websocket_connect() await automower_client.auth.websocket_connect()
# Reset reconnect time after successful connection reconnect_time = 2
self.reconnect_time = DEFAULT_RECONNECT_TIME
await automower_client.start_listening() await automower_client.start_listening()
except HusqvarnaWSServerHandshakeError as err: except HusqvarnaWSServerHandshakeError as err:
_LOGGER.debug( _LOGGER.debug(
"Failed to connect to websocket. Trying to reconnect: %s", "Failed to connect to websocket. Trying to reconnect: %s", err
err,
)
except TimeoutException as err:
_LOGGER.debug(
"Failed to listen to websocket. Trying to reconnect: %s",
err,
) )
if not hass.is_stopping: if not hass.is_stopping:
await asyncio.sleep(self.reconnect_time) await asyncio.sleep(reconnect_time)
self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME) reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME)
entry.async_create_background_task( await self.client_listen(
hass, hass=hass,
self.client_listen(hass, entry, automower_client), entry=entry,
"reconnect_task", automower_client=automower_client,
reconnect_time=reconnect_time,
) )

View file

@ -3,23 +3,30 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, PLATFORMS from .const import DOMAIN, PLATFORMS
if sys.version_info < (3, 13):
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Huum from a config entry.""" """Set up Huum from a config entry."""
if sys.version_info >= (3, 13):
raise HomeAssistantError(
"Huum is not supported on Python 3.13. Please use Python 3.12."
)
username = entry.data[CONF_USERNAME] username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD] password = entry.data[CONF_PASSWORD]

View file

@ -3,13 +3,9 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from typing import Any from typing import Any
from huum.const import SaunaStatus
from huum.exceptions import SafetyException
from huum.huum import Huum
from huum.schemas import HuumStatusResponse
from homeassistant.components.climate import ( from homeassistant.components.climate import (
ClimateEntity, ClimateEntity,
ClimateEntityFeature, ClimateEntityFeature,
@ -24,6 +20,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
if sys.version_info < (3, 13):
from huum.const import SaunaStatus
from huum.exceptions import SafetyException
from huum.huum import Huum
from huum.schemas import HuumStatusResponse
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View file

@ -3,10 +3,9 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from typing import Any from typing import Any
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@ -15,6 +14,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
if sys.version_info < (3, 13):
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema( STEP_USER_DATA_SCHEMA = vol.Schema(

View file

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/huum", "documentation": "https://www.home-assistant.io/integrations/huum",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["huum==0.7.12"] "requirements": ["huum==0.7.11;python_version<'3.13'"]
} }

View file

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload", "documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["Pillow==11.0.0"] "requirements": ["Pillow==10.4.0"]
} }

View file

@ -5,17 +5,25 @@ from __future__ import annotations
from functools import partial from functools import partial
from hdate import Location from hdate import Location
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ( from homeassistant.const import (
CONF_ELEVATION, CONF_ELEVATION,
CONF_LANGUAGE, CONF_LANGUAGE,
CONF_LATITUDE, CONF_LATITUDE,
CONF_LONGITUDE, CONF_LONGITUDE,
CONF_NAME,
CONF_TIME_ZONE, CONF_TIME_ZONE,
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from .binary_sensor import BINARY_SENSORS
from .const import ( from .const import (
CONF_CANDLE_LIGHT_MINUTES, CONF_CANDLE_LIGHT_MINUTES,
CONF_DIASPORA, CONF_DIASPORA,
@ -24,11 +32,93 @@ from .const import (
DEFAULT_DIASPORA, DEFAULT_DIASPORA,
DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_HAVDALAH_OFFSET_MINUTES,
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
DEFAULT_NAME,
DOMAIN,
) )
from .entity import JewishCalendarConfigEntry, JewishCalendarData from .entity import JewishCalendarConfigEntry, JewishCalendarData
from .sensor import INFO_SENSORS, TIME_SENSORS
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
cv.deprecated(DOMAIN),
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_DIASPORA, default=DEFAULT_DIASPORA): cv.boolean,
vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude,
vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude,
vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(
["hebrew", "english"]
),
vol.Optional(
CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT
): int,
# Default of 0 means use 8.5 degrees / 'three_stars' time.
vol.Optional(
CONF_HAVDALAH_OFFSET_MINUTES,
default=DEFAULT_HAVDALAH_OFFSET_MINUTES,
): int,
},
)
},
extra=vol.ALLOW_EXTRA,
)
def get_unique_prefix(
location: Location,
language: str,
candle_lighting_offset: int | None,
havdalah_offset: int | None,
) -> str:
"""Create a prefix for unique ids."""
# location.altitude was unset before 2024.6 when this method
# was used to create the unique id. As such it would always
# use the default altitude of 754.
config_properties = [
location.latitude,
location.longitude,
location.timezone,
754,
location.diaspora,
language,
candle_lighting_offset,
havdalah_offset,
]
prefix = "_".join(map(str, config_properties))
return f"{prefix}"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Jewish Calendar component."""
if DOMAIN not in config:
return True
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2024.12.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": DEFAULT_NAME,
},
)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, config_entry: JewishCalendarConfigEntry hass: HomeAssistant, config_entry: JewishCalendarConfigEntry
@ -63,6 +153,16 @@ async def async_setup_entry(
havdalah_offset, havdalah_offset,
) )
# Update unique ID to be unrelated to user defined options
old_prefix = get_unique_prefix(
location, language, candle_lighting_offset, havdalah_offset
)
ent_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
if not entries or any(entry.unique_id.startswith(old_prefix) for entry in entries):
async_update_unique_ids(ent_reg, config_entry.entry_id, old_prefix)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
async def update_listener( async def update_listener(
@ -80,3 +180,25 @@ async def async_unload_entry(
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
@callback
def async_update_unique_ids(
ent_reg: er.EntityRegistry, new_prefix: str, old_prefix: str
) -> None:
"""Update unique ID to be unrelated to user defined options.
Introduced with release 2024.6
"""
platform_descriptions = {
Platform.BINARY_SENSOR: BINARY_SENSORS,
Platform.SENSOR: (*INFO_SENSORS, *TIME_SENSORS),
}
for platform, descriptions in platform_descriptions.items():
for description in descriptions:
new_unique_id = f"{new_prefix}-{description.key}"
old_unique_id = f"{old_prefix}_{description.key}"
if entity_id := ent_reg.async_get_entity_id(
platform, DOMAIN, old_unique_id
):
ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)

View file

@ -101,10 +101,23 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
if user_input is not None: if user_input is not None:
_options = {}
if CONF_CANDLE_LIGHT_MINUTES in user_input:
_options[CONF_CANDLE_LIGHT_MINUTES] = user_input[
CONF_CANDLE_LIGHT_MINUTES
]
del user_input[CONF_CANDLE_LIGHT_MINUTES]
if CONF_HAVDALAH_OFFSET_MINUTES in user_input:
_options[CONF_HAVDALAH_OFFSET_MINUTES] = user_input[
CONF_HAVDALAH_OFFSET_MINUTES
]
del user_input[CONF_HAVDALAH_OFFSET_MINUTES]
if CONF_LOCATION in user_input: if CONF_LOCATION in user_input:
user_input[CONF_LATITUDE] = user_input[CONF_LOCATION][CONF_LATITUDE] user_input[CONF_LATITUDE] = user_input[CONF_LOCATION][CONF_LATITUDE]
user_input[CONF_LONGITUDE] = user_input[CONF_LOCATION][CONF_LONGITUDE] user_input[CONF_LONGITUDE] = user_input[CONF_LOCATION][CONF_LONGITUDE]
return self.async_create_entry(title=DEFAULT_NAME, data=user_input) return self.async_create_entry(
title=DEFAULT_NAME, data=user_input, options=_options
)
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
@ -113,6 +126,10 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
), ),
) )
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import a config entry from configuration.yaml."""
return await self.async_step_user(import_data)
async def async_step_reconfigure( async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View file

@ -17,7 +17,6 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
EntityCategory,
UnitOfElectricCurrent, UnitOfElectricCurrent,
UnitOfElectricPotential, UnitOfElectricPotential,
UnitOfEnergy, UnitOfEnergy,
@ -748,15 +747,6 @@ SENSOR_PROCESS_DATA = [
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
formatter="format_energy", formatter="format_energy",
), ),
PlenticoreSensorEntityDescription(
module_id="scb:event",
key="Event:ActiveErrorCnt",
name="Active Alarms",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
icon="mdi:alert",
formatter="format_round",
),
PlenticoreSensorEntityDescription( PlenticoreSensorEntityDescription(
module_id="_virt_", module_id="_virt_",
key="pv_P", key="pv_P",

View file

@ -8,7 +8,7 @@ import logging
import pypck import pypck
from pypck.connection import PchkConnectionManager from pypck.connection import PchkConnectionManager
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_DEVICE_ID, CONF_DEVICE_ID,
CONF_DOMAIN, CONF_DOMAIN,
@ -20,7 +20,7 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
@ -39,29 +39,40 @@ from .helpers import (
InputType, InputType,
async_update_config_entry, async_update_config_entry,
generate_unique_id, generate_unique_id,
import_lcn_config,
register_lcn_address_devices, register_lcn_address_devices,
register_lcn_host_device, register_lcn_host_device,
) )
from .services import register_services from .schemas import CONFIG_SCHEMA # noqa: F401
from .services import SERVICES
from .websocket import register_panel_and_ws_api from .websocket import register_panel_and_ws_api
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the LCN component.""" """Set up the LCN component."""
hass.data.setdefault(DOMAIN, {}) if DOMAIN not in config:
return True
await register_services(hass) # initialize a config_flow for all LCN configurations read from
await register_panel_and_ws_api(hass) # configuration.yaml
config_entries_data = import_lcn_config(config[DOMAIN])
for config_entry_data in config_entries_data:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config_entry_data,
)
)
return True return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up a connection to PCHK host from a config entry.""" """Set up a connection to PCHK host from a config entry."""
hass.data.setdefault(DOMAIN, {})
if config_entry.entry_id in hass.data[DOMAIN]: if config_entry.entry_id in hass.data[DOMAIN]:
return False return False
@ -121,6 +132,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
) )
lcn_connection.register_for_inputs(input_received) lcn_connection.register_for_inputs(input_received)
# register service calls
for service_name, service in SERVICES:
if not hass.services.has_service(DOMAIN, service_name):
hass.services.async_register(
DOMAIN, service_name, service(hass).async_call_service, service.schema
)
await register_panel_and_ws_api(hass)
return True return True
@ -171,6 +191,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
host = hass.data[DOMAIN].pop(config_entry.entry_id) host = hass.data[DOMAIN].pop(config_entry.entry_id)
await host[CONNECTION].async_close() await host[CONNECTION].async_close()
# unregister service calls
if unload_ok and not hass.data[DOMAIN]: # check if this is the last entry to unload
for service_name, _ in SERVICES:
hass.services.async_remove(DOMAIN, service_name)
return unload_ok return unload_ok

View file

@ -9,6 +9,7 @@ import pypck
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import ( from homeassistant.const import (
CONF_BASE, CONF_BASE,
CONF_DEVICES, CONF_DEVICES,
@ -19,12 +20,14 @@ from homeassistant.const import (
CONF_PORT, CONF_PORT,
CONF_USERNAME, CONF_USERNAME,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from . import PchkConnectionManager from . import PchkConnectionManager
from .const import CONF_ACKNOWLEDGE, CONF_DIM_MODE, CONF_SK_NUM_TRIES, DIM_MODES, DOMAIN from .const import CONF_ACKNOWLEDGE, CONF_DIM_MODE, CONF_SK_NUM_TRIES, DIM_MODES, DOMAIN
from .helpers import purge_device_registry, purge_entity_registry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -110,6 +113,55 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 2 VERSION = 2
MINOR_VERSION = 1 MINOR_VERSION = 1
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import existing configuration from LCN."""
# validate the imported connection parameters
if error := await validate_connection(import_data):
async_create_issue(
self.hass,
DOMAIN,
error,
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.ERROR,
translation_key=error,
translation_placeholders={
"url": "/config/integrations/dashboard/add?domain=lcn"
},
)
return self.async_abort(reason=error)
async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "LCN",
},
)
# check if we already have a host with the same address configured
if entry := get_config_entry(self.hass, import_data):
entry.source = config_entries.SOURCE_IMPORT
# Cleanup entity and device registry, if we imported from configuration.yaml to
# remove orphans when entities were removed from configuration
purge_entity_registry(self.hass, entry.entry_id, import_data)
purge_device_registry(self.hass, entry.entry_id, import_data)
self.hass.config_entries.async_update_entry(entry, data=import_data)
return self.async_abort(reason="existing_configuration_updated")
return self.async_create_entry(
title=f"{import_data[CONF_HOST]}", data=import_data
)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult: ) -> config_entries.ConfigFlowResult:

View file

@ -9,6 +9,7 @@ import re
from typing import cast from typing import cast
import pypck import pypck
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@ -18,12 +19,17 @@ from homeassistant.const import (
CONF_DEVICES, CONF_DEVICES,
CONF_DOMAIN, CONF_DOMAIN,
CONF_ENTITIES, CONF_ENTITIES,
CONF_HOST,
CONF_IP_ADDRESS,
CONF_LIGHTS, CONF_LIGHTS,
CONF_NAME, CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_RESOURCE, CONF_RESOURCE,
CONF_SENSORS, CONF_SENSORS,
CONF_SOURCE, CONF_SOURCE,
CONF_SWITCHES, CONF_SWITCHES,
CONF_USERNAME,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
@ -31,13 +37,19 @@ from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
BINSENSOR_PORTS, BINSENSOR_PORTS,
CONF_ACKNOWLEDGE,
CONF_CLIMATES, CONF_CLIMATES,
CONF_CONNECTIONS,
CONF_DIM_MODE,
CONF_DOMAIN_DATA,
CONF_HARDWARE_SERIAL, CONF_HARDWARE_SERIAL,
CONF_HARDWARE_TYPE, CONF_HARDWARE_TYPE,
CONF_OUTPUT, CONF_OUTPUT,
CONF_SCENES, CONF_SCENES,
CONF_SK_NUM_TRIES,
CONF_SOFTWARE_SERIAL, CONF_SOFTWARE_SERIAL,
CONNECTION, CONNECTION,
DEFAULT_NAME,
DOMAIN, DOMAIN,
LED_PORTS, LED_PORTS,
LOGICOP_PORTS, LOGICOP_PORTS,
@ -134,6 +146,110 @@ def generate_unique_id(
return unique_id return unique_id
def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]:
"""Convert lcn settings from configuration.yaml to config_entries data.
Create a list of config_entry data structures like:
"data": {
"host": "pchk",
"ip_address": "192.168.2.41",
"port": 4114,
"username": "lcn",
"password": "lcn,
"sk_num_tries: 0,
"dim_mode: "STEPS200",
"acknowledge": False,
"devices": [
{
"address": (0, 7, False)
"name": "",
"hardware_serial": -1,
"software_serial": -1,
"hardware_type": -1
}, ...
],
"entities": [
{
"address": (0, 7, False)
"name": "Light_Output1",
"resource": "output1",
"domain": "light",
"domain_data": {
"output": "OUTPUT1",
"dimmable": True,
"transition": 5000.0
}
}, ...
]
}
"""
data = {}
for connection in lcn_config[CONF_CONNECTIONS]:
host = {
CONF_HOST: connection[CONF_NAME],
CONF_IP_ADDRESS: connection[CONF_HOST],
CONF_PORT: connection[CONF_PORT],
CONF_USERNAME: connection[CONF_USERNAME],
CONF_PASSWORD: connection[CONF_PASSWORD],
CONF_SK_NUM_TRIES: connection[CONF_SK_NUM_TRIES],
CONF_DIM_MODE: connection[CONF_DIM_MODE],
CONF_ACKNOWLEDGE: False,
CONF_DEVICES: [],
CONF_ENTITIES: [],
}
data[connection[CONF_NAME]] = host
for confkey, domain_config in lcn_config.items():
if confkey == CONF_CONNECTIONS:
continue
domain = DOMAIN_LOOKUP[confkey]
# loop over entities in configuration.yaml
for domain_data in domain_config:
# remove name and address from domain_data
entity_name = domain_data.pop(CONF_NAME)
address, host_name = domain_data.pop(CONF_ADDRESS)
if host_name is None:
host_name = DEFAULT_NAME
# check if we have a new device config
for device_config in data[host_name][CONF_DEVICES]:
if address == device_config[CONF_ADDRESS]:
break
else: # create new device_config
device_config = {
CONF_ADDRESS: address,
CONF_NAME: "",
CONF_HARDWARE_SERIAL: -1,
CONF_SOFTWARE_SERIAL: -1,
CONF_HARDWARE_TYPE: -1,
}
data[host_name][CONF_DEVICES].append(device_config)
# insert entity config
resource = get_resource(domain, domain_data).lower()
for entity_config in data[host_name][CONF_ENTITIES]:
if (
address == entity_config[CONF_ADDRESS]
and resource == entity_config[CONF_RESOURCE]
and domain == entity_config[CONF_DOMAIN]
):
break
else: # create new entity_config
entity_config = {
CONF_ADDRESS: address,
CONF_NAME: entity_name,
CONF_RESOURCE: resource,
CONF_DOMAIN: domain,
CONF_DOMAIN_DATA: domain_data.copy(),
}
data[host_name][CONF_ENTITIES].append(entity_config)
return list(data.values())
def purge_entity_registry( def purge_entity_registry(
hass: HomeAssistant, entry_id: str, imported_entry_data: ConfigType hass: HomeAssistant, entry_id: str, imported_entry_data: ConfigType
) -> None: ) -> None:
@ -320,6 +436,26 @@ def get_device_config(
return None return None
def has_unique_host_names(hosts: list[ConfigType]) -> list[ConfigType]:
"""Validate that all connection names are unique.
Use 'pchk' as default connection_name (or add a numeric suffix if
pchk' is already in use.
"""
suffix = 0
for host in hosts:
if host.get(CONF_NAME) is None:
if suffix == 0:
host[CONF_NAME] = DEFAULT_NAME
else:
host[CONF_NAME] = f"{DEFAULT_NAME}{suffix:d}"
suffix += 1
schema = vol.Schema(vol.Unique())
schema([host.get(CONF_NAME) for host in hosts])
return hosts
def is_address(value: str) -> tuple[AddressType, str]: def is_address(value: str) -> tuple[AddressType, str]:
"""Validate the given address string. """Validate the given address string.

View file

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn", "documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pypck"], "loggers": ["pypck"],
"requirements": ["pypck==0.7.24", "lcn-frontend==0.2.2"] "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.1"]
} }

View file

@ -4,9 +4,20 @@ import voluptuous as vol
from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP
from homeassistant.const import ( from homeassistant.const import (
CONF_ADDRESS,
CONF_BINARY_SENSORS,
CONF_COVERS,
CONF_HOST,
CONF_LIGHTS,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_SCENE, CONF_SCENE,
CONF_SENSORS,
CONF_SOURCE, CONF_SOURCE,
CONF_SWITCHES,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
UnitOfTemperature, UnitOfTemperature,
) )
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -14,6 +25,9 @@ from homeassistant.helpers.typing import VolDictType
from .const import ( from .const import (
BINSENSOR_PORTS, BINSENSOR_PORTS,
CONF_CLIMATES,
CONF_CONNECTIONS,
CONF_DIM_MODE,
CONF_DIMMABLE, CONF_DIMMABLE,
CONF_LOCKABLE, CONF_LOCKABLE,
CONF_MAX_TEMP, CONF_MAX_TEMP,
@ -23,8 +37,12 @@ from .const import (
CONF_OUTPUTS, CONF_OUTPUTS,
CONF_REGISTER, CONF_REGISTER,
CONF_REVERSE_TIME, CONF_REVERSE_TIME,
CONF_SCENES,
CONF_SETPOINT, CONF_SETPOINT,
CONF_SK_NUM_TRIES,
CONF_TRANSITION, CONF_TRANSITION,
DIM_MODES,
DOMAIN,
KEYS, KEYS,
LED_PORTS, LED_PORTS,
LOGICOP_PORTS, LOGICOP_PORTS,
@ -38,6 +56,7 @@ from .const import (
VAR_UNITS, VAR_UNITS,
VARIABLES, VARIABLES,
) )
from .helpers import has_unique_host_names, is_address
ADDRESS_SCHEMA = vol.Coerce(tuple) ADDRESS_SCHEMA = vol.Coerce(tuple)
@ -111,3 +130,72 @@ DOMAIN_DATA_SWITCH: VolDictType = {
vol.In(OUTPUT_PORTS + RELAY_PORTS + SETPOINTS + KEYS), vol.In(OUTPUT_PORTS + RELAY_PORTS + SETPOINTS + KEYS),
), ),
} }
#
# Configuration
#
DOMAIN_DATA_BASE: VolDictType = {
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_ADDRESS): is_address,
}
BINARY_SENSORS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_BINARY_SENSOR})
CLIMATES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_CLIMATE})
COVERS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_COVER})
LIGHTS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_LIGHT})
SCENES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SCENE})
SENSORS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SENSOR})
SWITCHES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SWITCH})
CONNECTION_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_SK_NUM_TRIES, default=0): cv.positive_int,
vol.Optional(CONF_DIM_MODE, default="steps50"): vol.All(
vol.Upper, vol.In(DIM_MODES)
),
vol.Optional(CONF_NAME): cv.string,
}
)
CONFIG_SCHEMA = vol.Schema(
vol.All(
cv.deprecated(DOMAIN),
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_CONNECTIONS): vol.All(
cv.ensure_list, has_unique_host_names, [CONNECTION_SCHEMA]
),
vol.Optional(CONF_BINARY_SENSORS): vol.All(
cv.ensure_list, [BINARY_SENSORS_SCHEMA]
),
vol.Optional(CONF_CLIMATES): vol.All(
cv.ensure_list, [CLIMATES_SCHEMA]
),
vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]),
vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHTS_SCHEMA]),
vol.Optional(CONF_SCENES): vol.All(cv.ensure_list, [SCENES_SCHEMA]),
vol.Optional(CONF_SENSORS): vol.All(
cv.ensure_list, [SENSORS_SCHEMA]
),
vol.Optional(CONF_SWITCHES): vol.All(
cv.ensure_list, [SWITCHES_SCHEMA]
),
},
)
},
),
extra=vol.ALLOW_EXTRA,
)

View file

@ -429,11 +429,3 @@ SERVICES = (
(LcnService.DYN_TEXT, DynText), (LcnService.DYN_TEXT, DynText),
(LcnService.PCK, Pck), (LcnService.PCK, Pck),
) )
async def register_services(hass: HomeAssistant) -> None:
"""Register services for LCN."""
for service_name, service in SERVICES:
hass.services.async_register(
DOMAIN, service_name, service(hass).async_call_service, service.schema
)

View file

@ -63,6 +63,18 @@
} }
}, },
"issues": { "issues": {
"authentication_error": {
"title": "Authentication failed.",
"description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure username and password are correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"license_error": {
"title": "Maximum number of connections was reached.",
"description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure sufficient PCHK licenses are registered and restart Home Assistant.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"connection_refused": {
"title": "Unable to connect to PCHK.",
"description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure the connection (IP and port) to the LCN bus coupler is correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"deprecated_regulatorlock_sensor": { "deprecated_regulatorlock_sensor": {
"title": "Deprecated LCN regulator lock binary sensor", "title": "Deprecated LCN regulator lock binary sensor",
"description": "Your LCN regulator lock binary sensor entity `{entity}` is beeing used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." "description": "Your LCN regulator lock binary sensor entity `{entity}` is beeing used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue."

View file

@ -72,11 +72,8 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
super().__init__(coordinator, entity_description, property_id) super().__init__(coordinator, entity_description, property_id)
self._ordered_named_fan_speeds = [] self._ordered_named_fan_speeds = []
self._attr_supported_features = ( self._attr_supported_features |= FanEntityFeature.SET_SPEED
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
if (fan_modes := self.data.fan_modes) is not None: if (fan_modes := self.data.fan_modes) is not None:
self._attr_speed_count = len(fan_modes) self._attr_speed_count = len(fan_modes)
if self.speed_count == 4: if self.speed_count == 4:
@ -101,7 +98,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
self._attr_percentage = 0 self._attr_percentage = 0
_LOGGER.debug( _LOGGER.debug(
"[%s:%s] update status: %s -> %s (percentage=%s)", "[%s:%s] update status: %s -> %s (percntage=%s)",
self.coordinator.device_name, self.coordinator.device_name,
self.property_id, self.property_id,
self.data.is_on, self.data.is_on,
@ -123,7 +120,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
return return
_LOGGER.debug( _LOGGER.debug(
"[%s:%s] async_set_percentage. percentage=%s, value=%s", "[%s:%s] async_set_percentage. percntage=%s, value=%s",
self.coordinator.device_name, self.coordinator.device_name,
self.property_id, self.property_id,
percentage, percentage,

View file

@ -1,17 +0,0 @@
"""Diagnostics support for Linkplay."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
from . import LinkPlayConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: LinkPlayConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
data = entry.runtime_data
return {"device_info": data.bridge.to_dict()}

View file

@ -7,6 +7,6 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["linkplay"], "loggers": ["linkplay"],
"requirements": ["python-linkplay==0.0.20"], "requirements": ["python-linkplay==0.0.18"],
"zeroconf": ["_linkplay._tcp.local."] "zeroconf": ["_linkplay._tcp.local."]
} }

View file

@ -69,8 +69,6 @@ SOURCE_MAP: dict[PlayingMode, str] = {
PlayingMode.FM: "FM Radio", PlayingMode.FM: "FM Radio",
PlayingMode.RCA: "RCA", PlayingMode.RCA: "RCA",
PlayingMode.UDISK: "USB", PlayingMode.UDISK: "USB",
PlayingMode.SPOTIFY: "Spotify",
PlayingMode.TIDAL: "Tidal",
PlayingMode.FOLLOWER: "Follower", PlayingMode.FOLLOWER: "Follower",
} }
@ -298,11 +296,6 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
except ValueError as err: except ValueError as err:
raise HomeAssistantError(err) from err raise HomeAssistantError(err) from err
@exception_wrap
async def async_media_seek(self, position: float) -> None:
"""Seek to a position."""
await self._bridge.player.seek(round(position))
@exception_wrap @exception_wrap
async def async_join_players(self, group_members: list[str]) -> None: async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player.""" """Join `group_members` as a player group with the current player."""
@ -388,9 +381,9 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
) )
self._attr_source = SOURCE_MAP.get(self._bridge.player.play_mode, "other") self._attr_source = SOURCE_MAP.get(self._bridge.player.play_mode, "other")
self._attr_media_position = self._bridge.player.current_position_in_seconds self._attr_media_position = self._bridge.player.current_position / 1000
self._attr_media_position_updated_at = utcnow() self._attr_media_position_updated_at = utcnow()
self._attr_media_duration = self._bridge.player.total_length_in_seconds self._attr_media_duration = self._bridge.player.total_length / 1000
self._attr_media_artist = self._bridge.player.artist self._attr_media_artist = self._bridge.player.artist
self._attr_media_title = self._bridge.player.title self._attr_media_title = self._bridge.player.title
self._attr_media_album_name = self._bridge.player.album self._attr_media_album_name = self._bridge.player.album

View file

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/matrix", "documentation": "https://www.home-assistant.io/integrations/matrix",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["matrix_client"], "loggers": ["matrix_client"],
"requirements": ["matrix-nio==0.25.2", "Pillow==11.0.0"] "requirements": ["matrix-nio==0.25.2", "Pillow==10.4.0"]
} }

View file

@ -18,7 +18,7 @@ from homeassistant.components.media_player import (
from homeassistant.components.websocket_api import ActiveConnection from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.frame import report_usage from homeassistant.helpers.frame import report
from homeassistant.helpers.integration_platform import ( from homeassistant.helpers.integration_platform import (
async_process_integration_platforms, async_process_integration_platforms,
) )
@ -156,7 +156,7 @@ async def async_resolve_media(
raise Unresolvable("Media Source not loaded") raise Unresolvable("Media Source not loaded")
if target_media_player is UNDEFINED: if target_media_player is UNDEFINED:
report_usage( report(
"calls media_source.async_resolve_media without passing an entity_id", "calls media_source.async_resolve_media without passing an entity_id",
exclude_integrations={DOMAIN}, exclude_integrations={DOMAIN},
) )

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill", "documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["mill", "mill_local"], "loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.12.2", "mill-local==0.3.0"] "requirements": ["millheater==0.11.8", "mill-local==0.3.0"]
} }

View file

@ -9,13 +9,11 @@ import voluptuous as vol
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a ModernForms config flow.""" """Handle a ModernForms config flow."""
@ -57,21 +55,17 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None, prepare: bool = False self, user_input: dict[str, Any] | None = None, prepare: bool = False
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Config flow handler for ModernForms.""" """Config flow handler for ModernForms."""
source = self.context["source"]
# Request user input, unless we are preparing discovery flow # Request user input, unless we are preparing discovery flow
if user_input is None: if user_input is None:
user_input = {} user_input = {}
if not prepare: if not prepare:
if self.source == SOURCE_ZEROCONF: if source == SOURCE_ZEROCONF:
return self.async_show_form( return self._show_confirm_dialog()
step_id="zeroconf_confirm", return self._show_setup_form()
description_placeholders={"name": self.name},
)
return self.async_show_form(
step_id="user",
data_schema=USER_SCHEMA,
)
if self.source == SOURCE_ZEROCONF: if source == SOURCE_ZEROCONF:
user_input[CONF_HOST] = self.host user_input[CONF_HOST] = self.host
user_input[CONF_MAC] = self.mac user_input[CONF_MAC] = self.mac
@ -81,21 +75,18 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
try: try:
device = await device.update() device = await device.update()
except ModernFormsConnectionError: except ModernFormsConnectionError:
if self.source == SOURCE_ZEROCONF: if source == SOURCE_ZEROCONF:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
return self.async_show_form( return self._show_setup_form({"base": "cannot_connect"})
step_id="user",
data_schema=USER_SCHEMA,
errors={"base": "cannot_connect"},
)
user_input[CONF_MAC] = device.info.mac_address user_input[CONF_MAC] = device.info.mac_address
user_input[CONF_NAME] = device.info.device_name
# Check if already configured # Check if already configured
await self.async_set_unique_id(user_input[CONF_MAC]) await self.async_set_unique_id(user_input[CONF_MAC])
self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
title = device.info.device_name title = device.info.device_name
if self.source == SOURCE_ZEROCONF: if source == SOURCE_ZEROCONF:
title = self.name title = self.name
if prepare: if prepare:
@ -105,3 +96,19 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
title=title, title=title,
data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]}, data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]},
) )
def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult:
"""Show the setup form to the user."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors or {},
)
def _show_confirm_dialog(self, errors: dict | None = None) -> ConfigFlowResult:
"""Show the confirm dialog to the user."""
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={"name": self.name},
errors=errors or {},
)

View file

@ -4,8 +4,9 @@
"after_dependencies": ["media_source", "media_player"], "after_dependencies": ["media_source", "media_player"],
"codeowners": ["@music-assistant"], "codeowners": ["@music-assistant"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/music_assistant", "documentation": "https://music-assistant.io",
"iot_class": "local_push", "iot_class": "local_push",
"issue_tracker": "https://github.com/music-assistant/hass-music-assistant/issues",
"loggers": ["music_assistant"], "loggers": ["music_assistant"],
"requirements": ["music-assistant-client==1.0.5"], "requirements": ["music-assistant-client==1.0.5"],
"zeroconf": ["_mass._tcp.local."] "zeroconf": ["_mass._tcp.local."]

View file

@ -12,12 +12,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
from .const import F_SERIES
from .entity import MyUplinkEntity, MyUplinkSystemEntity from .entity import MyUplinkEntity, MyUplinkSystemEntity
from .helpers import find_matching_platform, transform_model_series from .helpers import find_matching_platform
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = { CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = {
F_SERIES: { "F730": {
"43161": BinarySensorEntityDescription( "43161": BinarySensorEntityDescription(
key="elect_add", key="elect_add",
translation_key="elect_add", translation_key="elect_add",
@ -51,7 +50,6 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription
2. Default to None 2. Default to None
""" """
prefix, _, _ = device_point.category.partition(" ") prefix, _, _ = device_point.category.partition(" ")
prefix = transform_model_series(prefix)
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id) return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)

View file

@ -6,5 +6,3 @@ API_ENDPOINT = "https://api.myuplink.com"
OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize" OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize"
OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token" OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token"
OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"] OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"]
F_SERIES = "f-series"

View file

@ -6,8 +6,6 @@ from homeassistant.components.number import NumberEntityDescription
from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.const import Platform from homeassistant.const import Platform
from .const import F_SERIES
def find_matching_platform( def find_matching_platform(
device_point: DevicePoint, device_point: DevicePoint,
@ -88,9 +86,8 @@ PARAMETER_ID_TO_EXCLUDE_F730 = (
"47941", "47941",
"47975", "47975",
"48009", "48009",
"48042",
"48072", "48072",
"48442",
"49909",
"50113", "50113",
) )
@ -113,7 +110,7 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool:
): ):
return False return False
return True return True
if model.lower().startswith("f"): if "F730" in model:
# Entity names containing weekdays are used for advanced scheduling in the # Entity names containing weekdays are used for advanced scheduling in the
# heat pump and should not be exposed in the integration # heat pump and should not be exposed in the integration
if any(d in device_point.parameter_name.lower() for d in WEEKDAYS): if any(d in device_point.parameter_name.lower() for d in WEEKDAYS):
@ -121,10 +118,3 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool:
if device_point.parameter_id in PARAMETER_ID_TO_EXCLUDE_F730: if device_point.parameter_id in PARAMETER_ID_TO_EXCLUDE_F730:
return True return True
return False return False
def transform_model_series(prefix: str) -> str:
"""Remap all F-series models."""
if prefix.lower().startswith("f"):
return F_SERIES
return prefix

View file

@ -10,9 +10,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
from .const import F_SERIES
from .entity import MyUplinkEntity from .entity import MyUplinkEntity
from .helpers import find_matching_platform, skip_entity, transform_model_series from .helpers import find_matching_platform, skip_entity
DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = { DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = {
"DM": NumberEntityDescription( "DM": NumberEntityDescription(
@ -23,7 +22,7 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = {
} }
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, NumberEntityDescription]] = { CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, NumberEntityDescription]] = {
F_SERIES: { "F730": {
"40940": NumberEntityDescription( "40940": NumberEntityDescription(
key="degree_minutes", key="degree_minutes",
translation_key="degree_minutes", translation_key="degree_minutes",
@ -49,7 +48,6 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None
3. Default to None 3. Default to None
""" """
prefix, _, _ = device_point.category.partition(" ") prefix, _, _ = device_point.category.partition(" ")
prefix = transform_model_series(prefix)
description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(
device_point.parameter_id device_point.parameter_id
) )

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