Compare commits

..

3 Commits

Author SHA1 Message Date
Marc Mueller
a2140bd033 Build netifaces 0.11.0 2025-10-19 14:24:12 +02:00
Marc Mueller
133054693e Build Python 3.14 wheels 2025-10-19 14:24:12 +02:00
Marc Mueller
f695fa182c Only build custom wheels 2025-10-19 14:23:54 +02:00
614 changed files with 4714 additions and 26905 deletions

View File

@@ -33,7 +33,7 @@
"GitHub.vscode-pull-request-github",
"GitHub.copilot"
],
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.jsonc
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json
"settings": {
"python.experiments.optOutFrom": ["pythonTestAdapter"],
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
@@ -41,7 +41,6 @@
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
"python.analysis.typeCheckingMode": "basic",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
@@ -63,9 +62,6 @@
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],

View File

@@ -74,7 +74,6 @@ rules:
- **Formatting**: Ruff
- **Linting**: PyLint and Ruff
- **Type Checking**: MyPy
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
- **Testing**: pytest with plain functions and fixtures
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)

View File

@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: translations
path: translations.tar.gz
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: translations
@@ -464,7 +464,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: translations

View File

@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 1
CACHE_VERSION: 9
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.11"
@@ -535,7 +535,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -867,7 +867,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: pytest_buckets
- &compile-english-translations

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
with:
category: "/language:python"

View File

@@ -80,6 +80,8 @@ jobs:
# Add additional pip wheel build constraints
echo "PIP_CONSTRAINT=build_constraints.txt"
echo 'CFLAGS="-Wno-error=int-conversion"'
) > .env_file
- name: Write pip wheel build constraints
@@ -92,7 +94,7 @@ jobs:
) > build_constraints.txt
- name: Upload env_file
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: env_file
path: ./.env_file
@@ -126,13 +128,13 @@ jobs:
core:
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
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix: &matrix-build
abi: ["cp313", "cp314"]
abi: ["cp314"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- os: ubuntu-latest
@@ -150,7 +152,7 @@ jobs:
- &download-env-file
name: Download env_file
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: env_file
@@ -219,9 +221,29 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
- name: Create requirements file for custom build
run: |
touch requirements_custom.txt
echo "netifaces==0.11.0" >> requirements_custom.txt
- name: Build wheels (custom)
uses: cdce8p/wheels@master
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-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements: "requirements_custom.txt"
verbose: true
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
uses: *home-assistant-wheels
if: false
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2

2
.gitignore vendored
View File

@@ -111,7 +111,6 @@ virtualization/vagrant/config
!.vscode/cSpell.json
!.vscode/extensions.json
!.vscode/tasks.json
!.vscode/settings.default.jsonc
.env
# Windows Explorer
@@ -141,5 +140,4 @@ pytest_buckets.txt
# AI tooling
.claude/settings.local.json
.serena/

View File

@@ -33,8 +33,8 @@ repos:
rev: v1.37.1
hooks:
- id: yamllint
- repo: https://github.com/rbubley/mirrors-prettier
rev: v3.6.2
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.0.3
hooks:
- id: prettier
- repo: https://github.com/cdce8p/python-typing-update

View File

@@ -278,7 +278,6 @@ homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.inels.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
@@ -478,7 +477,6 @@ homeassistant.components.skybell.*
homeassistant.components.slack.*
homeassistant.components.sleep_as_android.*
homeassistant.components.sleepiq.*
homeassistant.components.sma.*
homeassistant.components.smhi.*
homeassistant.components.smlight.*
homeassistant.components.smtp.*

View File

@@ -7,19 +7,13 @@
"python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment",
// Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
},
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],
// This value differs between working with devcontainer and locally, therefore this value should NOT be in sync!
"url": "./script/json_schemas/manifest_schema.json",
},
],
{
"fileMatch": [
"homeassistant/components/*/manifest.json"
],
// This value differs between working with devcontainer and locally, therefor this value should NOT be in sync!
"url": "./script/json_schemas/manifest_schema.json"
}
]
}

6
CODEOWNERS generated
View File

@@ -494,8 +494,6 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes
/homeassistant/components/fing/ @Lorenzo-Gasparini
/tests/components/fing/ @Lorenzo-Gasparini
/homeassistant/components/firefly_iii/ @erwindouna
/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky
@@ -743,8 +741,6 @@ build.json @home-assistant/supervisor
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh
/homeassistant/components/inels/ @epdevlab
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
/homeassistant/components/inkbird/ @bdraco
@@ -1543,8 +1539,6 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @home-assistant/core
/tests/components/sun/ @home-assistant/core
/homeassistant/components/sunricher_dali_center/ @niracler
/tests/components/sunricher_dali_center/ @niracler
/homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen

4
Dockerfile generated
View File

@@ -25,13 +25,13 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
# Install uv
RUN pip3 install uv==0.9.5
RUN pip3 install uv==0.8.9
WORKDIR /usr/src

View File

@@ -5,6 +5,9 @@ build_from:
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
cosign:
base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.*

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["airos==0.6.0"]
"requirements": ["airos==0.5.6"]
}

View File

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

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==6.4.6"]
"requirements": ["aioamazondevices==6.4.4"]
}

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.15"]
"requirements": ["py-aosmith==1.0.14"]
}

View File

@@ -109,12 +109,12 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=config
)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates=config
)
self._abort_if_unique_id_configured()
@@ -248,7 +248,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info[CONF_MAC])
self._abort_if_unique_id_configured(
updates={CONF_HOST: discovery_info[CONF_HOST]}, reload_on_update=False
updates={CONF_HOST: discovery_info[CONF_HOST]}
)
self.context.update(

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
"quality_scale": "platinum",
"requirements": ["bring-api==1.1.1"]
"requirements": ["bring-api==1.1.0"]
}

View File

@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.15.0"]
"requirements": ["bthome-ble==3.14.2"]
}

View File

@@ -816,20 +816,13 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
return MediaPlayerState.PAUSED
if media_status.player_is_idle:
return MediaPlayerState.IDLE
if self._chromecast is not None and self._chromecast.is_idle:
# If library consider us idle, that is our off state
# it takes HDMI status into account for cast devices.
return MediaPlayerState.OFF
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
# Some apps don't report media status, show the player as playing
return MediaPlayerState.PLAYING
if self.app_id is not None:
# We have an active app
if self.app_id is not None and self.app_id != pychromecast.IDLE_APP_ID:
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
# Some apps don't report media status, show the player as playing
return MediaPlayerState.PLAYING
return MediaPlayerState.IDLE
if self._chromecast is not None and self._chromecast.is_idle:
return MediaPlayerState.OFF
return None
@property

View File

@@ -14,7 +14,6 @@ from homeassistant.const import CONF_HOST, CONF_PORT
from .const import DEFAULT_PORT, DOMAIN
from .errors import (
ConnectionRefused,
ConnectionReset,
ConnectionTimeout,
ResolveFailed,
ValidationFailure,
@@ -50,8 +49,6 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
self._errors[CONF_HOST] = "connection_timeout"
except ConnectionRefused:
self._errors[CONF_HOST] = "connection_refused"
except ConnectionReset:
self._errors[CONF_HOST] = "connection_reset"
except ValidationFailure:
return True
else:

View File

@@ -25,7 +25,3 @@ class ConnectionTimeout(TemporaryFailure):
class ConnectionRefused(TemporaryFailure):
"""Network connection refused."""
class ConnectionReset(TemporaryFailure):
"""Network connection reset."""

View File

@@ -13,7 +13,6 @@ from homeassistant.util.ssl import get_default_context
from .const import TIMEOUT
from .errors import (
ConnectionRefused,
ConnectionReset,
ConnectionTimeout,
ResolveFailed,
ValidationFailure,
@@ -59,8 +58,6 @@ async def get_cert_expiry_timestamp(
raise ConnectionRefused(
f"Connection refused by server: {hostname}:{port}"
) from err
except ConnectionResetError as err:
raise ConnectionReset(f"Connection reset by server: {hostname}:{port}") from err
except ssl.CertificateError as err:
raise ValidationFailure(err.verify_message) from err
except ssl.SSLError as err:

View File

@@ -14,8 +14,7 @@
"error": {
"resolve_failed": "This host cannot be resolved",
"connection_timeout": "Timeout when connecting to this host",
"connection_refused": "Connection refused when connecting to host",
"connection_reset": "Connection reset when connecting to host"
"connection_refused": "Connection refused when connecting to host"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",

View File

@@ -34,7 +34,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER]
@dataclass
@@ -61,22 +61,13 @@ async def call_c4_api_retry(func, *func_args):
try:
return await func(*func_args)
except client_exceptions.ClientError as exception:
_LOGGER.debug(
"Attempt %d/%d failed connecting to Control4 account API: %s",
_LOGGER.error(
"Try: %d, Error connecting to Control4 account API: %s",
i + 1,
API_RETRY_TIMES,
exception,
)
exc = exception
_LOGGER.error(
"Failed to connect to Control4 account API after %d attempts: %s",
API_RETRY_TIMES,
exc,
)
raise ConfigEntryNotReady(
f"Failed to connect to Control4 account API after {API_RETRY_TIMES} attempts"
) from exc
raise ConfigEntryNotReady(exc) from exc
async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool:
@@ -89,9 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
await account.getAccountBearerToken()
except client_exceptions.ClientError as exception:
_LOGGER.error("Error connecting to Control4 account API: %s", exception)
raise ConfigEntryNotReady(
"Error connecting to Control4 account API to get bearer token"
) from exception
raise ConfigEntryNotReady from exception
except BadCredentials as exception:
_LOGGER.error(
(
@@ -133,34 +122,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) ->
)
# Store all items found on controller for platforms to use
try:
all_items_raw = await director.getAllItemInfo()
except (TimeoutError, client_exceptions.ClientError) as err:
_LOGGER.error(
"Timeout connecting to Control4 controller at %s",
config[CONF_HOST],
)
raise ConfigEntryNotReady(
f"Timeout connecting to Control4 controller at {config[CONF_HOST]}"
) from err
director_all_items: list[dict[str, Any]] = json.loads(all_items_raw)
director_all_items: list[dict[str, Any]] = json.loads(
await director.getAllItemInfo()
)
# Check if OS version is 3 or higher to get UI configuration
ui_configuration: dict[str, Any] | None = None
if int(director_sw_version.split(".")[0]) >= 3:
try:
ui_config_raw = await director.getUiConfiguration()
except (TimeoutError, client_exceptions.ClientError) as err:
_LOGGER.error(
"Timeout getting UI configuration from Control4 controller at %s",
config[CONF_HOST],
)
raise ConfigEntryNotReady(
f"Timeout getting UI configuration from Control4 controller at {config[CONF_HOST]}"
) from err
ui_configuration = json.loads(ui_config_raw)
ui_configuration = json.loads(await director.getUiConfiguration())
# Load options from config entry
scan_interval: int = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)

View File

@@ -1,301 +0,0 @@
"""Platform for Control4 Climate/Thermostat."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pyControl4.climate import C4Climate
from pyControl4.error_handling import C4Exception
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category
from .const import CONTROL4_ENTITY_TYPE
from .director_utils import update_variables_for_config_entry
from .entity import Control4Entity
_LOGGER = logging.getLogger(__name__)
CONTROL4_CATEGORY = "comfort"
# Control4 variable names
CONTROL4_HVAC_STATE = "HVAC_STATE"
CONTROL4_HVAC_MODE = "HVAC_MODE"
CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F"
CONTROL4_HUMIDITY = "HUMIDITY"
CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F"
CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F"
VARIABLES_OF_INTEREST = {
CONTROL4_HVAC_STATE,
CONTROL4_HVAC_MODE,
CONTROL4_CURRENT_TEMPERATURE,
CONTROL4_HUMIDITY,
CONTROL4_COOL_SETPOINT,
CONTROL4_HEAT_SETPOINT,
}
# Map Control4 HVAC modes to Home Assistant
C4_TO_HA_HVAC_MODE = {
"Off": HVACMode.OFF,
"Cool": HVACMode.COOL,
"Heat": HVACMode.HEAT,
"Auto": HVACMode.HEAT_COOL,
}
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
# Map Control4 HVAC state to Home Assistant HVAC action
C4_TO_HA_HVAC_ACTION = {
"heating": HVACAction.HEATING,
"cooling": HVACAction.COOLING,
"idle": HVACAction.IDLE,
"off": HVACAction.OFF,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: Control4ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Control4 thermostats from a config entry."""
runtime_data = entry.runtime_data
async def async_update_data() -> dict[int, dict[str, Any]]:
"""Fetch data from Control4 director for thermostats."""
try:
return await update_variables_for_config_entry(
hass, entry, VARIABLES_OF_INTEREST
)
except C4Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
hass,
_LOGGER,
name="climate",
update_method=async_update_data,
update_interval=timedelta(seconds=runtime_data.scan_interval),
config_entry=entry,
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
entity_list = []
for item in items_of_category:
try:
if item["type"] == CONTROL4_ENTITY_TYPE:
item_name = item["name"]
item_id = item["id"]
item_parent_id = item["parentId"]
item_manufacturer = None
item_device_name = None
item_model = None
for parent_item in items_of_category:
if parent_item["id"] == item_parent_id:
item_manufacturer = parent_item.get("manufacturer")
item_device_name = parent_item.get("roomName")
item_model = parent_item.get("model")
else:
continue
except KeyError:
_LOGGER.exception(
"Unknown device properties received from Control4: %s",
item,
)
continue
# Skip if we don't have data for this thermostat
if item_id not in coordinator.data:
_LOGGER.warning(
"Couldn't get climate state data for %s (ID: %s), skipping setup",
item_name,
item_id,
)
continue
entity_list.append(
Control4Climate(
runtime_data,
coordinator,
item_name,
item_id,
item_device_name,
item_manufacturer,
item_model,
item_parent_id,
)
)
async_add_entities(entity_list)
class Control4Climate(Control4Entity, ClimateEntity):
"""Control4 climate entity."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL]
def __init__(
self,
runtime_data: Control4RuntimeData,
coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]],
name: str,
idx: int,
device_name: str | None,
device_manufacturer: str | None,
device_model: str | None,
device_id: int,
) -> None:
"""Initialize Control4 climate entity."""
super().__init__(
runtime_data,
coordinator,
name,
idx,
device_name,
device_manufacturer,
device_model,
device_id,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._thermostat_data is not None
def _create_api_object(self) -> C4Climate:
"""Create a pyControl4 device object.
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
"""
return C4Climate(self.runtime_data.director, self._idx)
@property
def _thermostat_data(self) -> dict[str, Any] | None:
"""Return the thermostat data from the coordinator."""
return self.coordinator.data.get(self._idx)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
data = self._thermostat_data
if data is None:
return None
return data.get(CONTROL4_CURRENT_TEMPERATURE)
@property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
data = self._thermostat_data
if data is None:
return None
humidity = data.get(CONTROL4_HUMIDITY)
return int(humidity) if humidity is not None else None
@property
def hvac_mode(self) -> HVACMode:
"""Return current HVAC mode."""
data = self._thermostat_data
if data is None:
return HVACMode.OFF
c4_mode = data.get(CONTROL4_HVAC_MODE) or ""
return C4_TO_HA_HVAC_MODE.get(c4_mode, HVACMode.OFF)
@property
def hvac_action(self) -> HVACAction | None:
"""Return current HVAC action."""
data = self._thermostat_data
if data is None:
return None
c4_state = data.get(CONTROL4_HVAC_STATE)
if c4_state is None:
return None
# Convert state to lowercase for mapping
return C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
data = self._thermostat_data
if data is None:
return None
hvac_mode = self.hvac_mode
if hvac_mode == HVACMode.COOL:
return data.get(CONTROL4_COOL_SETPOINT)
if hvac_mode == HVACMode.HEAT:
return data.get(CONTROL4_HEAT_SETPOINT)
return None
@property
def target_temperature_high(self) -> float | None:
"""Return the high target temperature for auto mode."""
data = self._thermostat_data
if data is None:
return None
if self.hvac_mode == HVACMode.HEAT_COOL:
return data.get(CONTROL4_COOL_SETPOINT)
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the low target temperature for auto mode."""
data = self._thermostat_data
if data is None:
return None
if self.hvac_mode == HVACMode.HEAT_COOL:
return data.get(CONTROL4_HEAT_SETPOINT)
return None
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target HVAC mode."""
c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode]
c4_climate = self._create_api_object()
await c4_climate.setHvacMode(c4_hvac_mode)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
c4_climate = self._create_api_object()
low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
temp = kwargs.get(ATTR_TEMPERATURE)
# Handle temperature range for auto mode
if self.hvac_mode == HVACMode.HEAT_COOL:
if low_temp is not None:
await c4_climate.setHeatSetpointF(low_temp)
if high_temp is not None:
await c4_climate.setCoolSetpointF(high_temp)
# Handle single temperature setpoint
elif temp is not None:
if self.hvac_mode == HVACMode.COOL:
await c4_climate.setCoolSetpointF(temp)
elif self.hvac_mode == HVACMode.HEAT:
await c4_climate.setHeatSetpointF(temp)
await self.coordinator.async_request_refresh()

View File

@@ -7,7 +7,6 @@ from collections.abc import AsyncGenerator, AsyncIterable, Callable, Generator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime
import logging
from pathlib import Path
from typing import Any, Literal, TypedDict, cast
@@ -17,18 +16,14 @@ import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import chat_session, frame, intent, llm, template
from homeassistant.util.dt import utcnow
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JsonObjectType
from . import trace
from .const import ChatLogEventType
from .models import ConversationInput, ConversationResult
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
DATA_SUBSCRIPTIONS: HassKey[
list[Callable[[str, ChatLogEventType, dict[str, Any]], None]]
] = HassKey("conversation_chat_log_subscriptions")
LOGGER = logging.getLogger(__name__)
current_chat_log: ContextVar[ChatLog | None] = ContextVar(
@@ -36,40 +31,6 @@ current_chat_log: ContextVar[ChatLog | None] = ContextVar(
)
@callback
def async_subscribe_chat_logs(
hass: HomeAssistant,
callback_func: Callable[[str, ChatLogEventType, dict[str, Any]], None],
) -> Callable[[], None]:
"""Subscribe to all chat logs."""
subscriptions = hass.data.get(DATA_SUBSCRIPTIONS)
if subscriptions is None:
subscriptions = []
hass.data[DATA_SUBSCRIPTIONS] = subscriptions
subscriptions.append(callback_func)
@callback
def unsubscribe() -> None:
"""Unsubscribe from chat logs."""
subscriptions.remove(callback_func)
return unsubscribe
@callback
def _async_notify_subscribers(
hass: HomeAssistant,
conversation_id: str,
event_type: ChatLogEventType,
data: dict[str, Any],
) -> None:
"""Notify subscribers of a chat log event."""
if subscriptions := hass.data.get(DATA_SUBSCRIPTIONS):
for callback_func in subscriptions:
callback_func(conversation_id, event_type, data)
@contextmanager
def async_get_chat_log(
hass: HomeAssistant,
@@ -102,8 +63,6 @@ def async_get_chat_log(
all_chat_logs = {}
hass.data[DATA_CHAT_LOGS] = all_chat_logs
is_new_log = session.conversation_id not in all_chat_logs
if chat_log := all_chat_logs.get(session.conversation_id):
chat_log = replace(chat_log, content=chat_log.content.copy())
else:
@@ -112,15 +71,6 @@ def async_get_chat_log(
if chat_log_delta_listener:
chat_log.delta_listener = chat_log_delta_listener
# Fire CREATED event for new chat logs before any content is added
if is_new_log:
_async_notify_subscribers(
hass,
session.conversation_id,
ChatLogEventType.CREATED,
{"chat_log": chat_log.as_dict()},
)
if user_input is not None:
chat_log.async_add_user_content(UserContent(content=user_input.text))
@@ -134,28 +84,14 @@ def async_get_chat_log(
LOGGER.debug(
"Chat Log opened but no assistant message was added, ignoring update"
)
# If this was a new log but nothing was added, fire DELETED to clean up
if is_new_log:
_async_notify_subscribers(
hass,
session.conversation_id,
ChatLogEventType.DELETED,
{},
)
return
if is_new_log:
if session.conversation_id not in all_chat_logs:
@callback
def do_cleanup() -> None:
"""Handle cleanup."""
all_chat_logs.pop(session.conversation_id)
_async_notify_subscribers(
hass,
session.conversation_id,
ChatLogEventType.DELETED,
{},
)
session.async_on_cleanup(do_cleanup)
@@ -164,16 +100,6 @@ def async_get_chat_log(
all_chat_logs[session.conversation_id] = chat_log
# For new logs, CREATED was already fired before content was added
# For existing logs, fire UPDATED
if not is_new_log:
_async_notify_subscribers(
hass,
session.conversation_id,
ChatLogEventType.UPDATED,
{"chat_log": chat_log.as_dict()},
)
class ConverseError(HomeAssistantError):
"""Error during initialization of conversation.
@@ -203,11 +129,6 @@ class SystemContent:
role: Literal["system"] = field(init=False, default="system")
content: str
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the content."""
return {"role": self.role, "content": self.content}
@dataclass(frozen=True)
@@ -217,20 +138,6 @@ class UserContent:
role: Literal["user"] = field(init=False, default="user")
content: str
attachments: list[Attachment] | None = field(default=None)
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the content."""
result: dict[str, Any] = {
"role": self.role,
"content": self.content,
"created": self.created,
}
if self.attachments:
result["attachments"] = [
attachment.as_dict() for attachment in self.attachments
]
return result
@dataclass(frozen=True)
@@ -246,14 +153,6 @@ class Attachment:
path: Path
"""Path to the attachment on disk."""
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the attachment."""
return {
"media_content_id": self.media_content_id,
"mime_type": self.mime_type,
"path": str(self.path),
}
@dataclass(frozen=True)
class AssistantContent:
@@ -265,22 +164,6 @@ class AssistantContent:
thinking_content: str | None = None
tool_calls: list[llm.ToolInput] | None = None
native: Any = None
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the content."""
result: dict[str, Any] = {
"role": self.role,
"agent_id": self.agent_id,
"created": self.created,
}
if self.content:
result["content"] = self.content
if self.thinking_content:
result["thinking_content"] = self.thinking_content
if self.tool_calls:
result["tool_calls"] = self.tool_calls
return result
@dataclass(frozen=True)
@@ -292,18 +175,6 @@ class ToolResultContent:
tool_call_id: str
tool_name: str
tool_result: JsonObjectType
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the content."""
return {
"role": self.role,
"agent_id": self.agent_id,
"tool_call_id": self.tool_call_id,
"tool_name": self.tool_name,
"tool_result": self.tool_result,
"created": self.created,
}
type Content = SystemContent | UserContent | AssistantContent | ToolResultContent
@@ -339,16 +210,6 @@ class ChatLog:
llm_api: llm.APIInstance | None = None
delta_listener: Callable[[ChatLog, dict], None] | None = None
llm_input_provided_index = 0
created: datetime = field(init=False, default_factory=utcnow)
def as_dict(self) -> dict[str, Any]:
"""Return a dictionary representation of the chat log."""
return {
"conversation_id": self.conversation_id,
"continue_conversation": self.continue_conversation,
"content": [c.as_dict() for c in self.content],
"created": self.created,
}
@property
def continue_conversation(self) -> bool:
@@ -380,12 +241,6 @@ class ChatLog:
"""Add user content to the log."""
LOGGER.debug("Adding user content: %s", content)
self.content.append(content)
_async_notify_subscribers(
self.hass,
self.conversation_id,
ChatLogEventType.CONTENT_ADDED,
{"content": content.as_dict()},
)
@callback
def async_add_assistant_content_without_tools(
@@ -404,12 +259,6 @@ class ChatLog:
):
raise ValueError("Non-external tool calls not allowed")
self.content.append(content)
_async_notify_subscribers(
self.hass,
self.conversation_id,
ChatLogEventType.CONTENT_ADDED,
{"content": content.as_dict()},
)
async def async_add_assistant_content(
self,
@@ -468,14 +317,6 @@ class ChatLog:
tool_result=tool_result,
)
self.content.append(response_content)
_async_notify_subscribers(
self.hass,
self.conversation_id,
ChatLogEventType.CONTENT_ADDED,
{
"content": response_content.as_dict(),
},
)
yield response_content
async def async_add_delta_content_stream(
@@ -752,12 +593,6 @@ class ChatLog:
self.llm_api = llm_api
self.extra_system_prompt = extra_system_prompt
self.content[0] = SystemContent(content=prompt)
_async_notify_subscribers(
self.hass,
self.conversation_id,
ChatLogEventType.UPDATED,
{"chat_log": self.as_dict()},
)
LOGGER.debug("Prompt: %s", self.content)
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from enum import IntFlag, StrEnum
from enum import IntFlag
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
@@ -30,13 +30,3 @@ class ConversationEntityFeature(IntFlag):
"""Supported features of the conversation entity."""
CONTROL = 1
class ChatLogEventType(StrEnum):
"""Chat log event type."""
INITIAL_STATE = "initial_state"
CREATED = "created"
UPDATED = "updated"
DELETED = "deleted"
CONTENT_ADDED = "content_added"

View File

@@ -12,7 +12,6 @@ from homeassistant.components import http, websocket_api
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import MATCH_ALL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.chat_session import async_get_chat_session
from homeassistant.util import language as language_util
from .agent_manager import (
@@ -21,8 +20,7 @@ from .agent_manager import (
async_get_agent,
get_agent_manager,
)
from .chat_log import DATA_CHAT_LOGS, async_get_chat_log, async_subscribe_chat_logs
from .const import DATA_COMPONENT, ChatLogEventType
from .const import DATA_COMPONENT
from .entity import ConversationEntity
from .models import ConversationInput
@@ -37,8 +35,6 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_list_sentences)
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
websocket_api.async_register_command(hass, websocket_subscribe_chat_log)
websocket_api.async_register_command(hass, websocket_subscribe_chat_log_index)
@websocket_api.websocket_command(
@@ -269,114 +265,3 @@ class ConversationProcessView(http.HomeAssistantView):
)
return self.json(result.as_dict())
@websocket_api.websocket_command(
{
vol.Required("type"): "conversation/chat_log/subscribe",
vol.Required("conversation_id"): str,
}
)
@websocket_api.require_admin
def websocket_subscribe_chat_log(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to a chat log."""
msg_id = msg["id"]
subscribed_conversation = msg["conversation_id"]
chat_logs = hass.data.get(DATA_CHAT_LOGS)
if not chat_logs or subscribed_conversation not in chat_logs:
connection.send_error(
msg_id,
websocket_api.ERR_NOT_FOUND,
"Conversation chat log not found",
)
return
@callback
def forward_events(conversation_id: str, event_type: str, data: dict) -> None:
"""Forward chat log events to websocket connection."""
if conversation_id != subscribed_conversation:
return
connection.send_event(
msg_id,
{
"conversation_id": conversation_id,
"event_type": event_type,
"data": data,
},
)
if event_type == ChatLogEventType.DELETED:
unsubscribe()
del connection.subscriptions[msg["id"]]
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
connection.subscriptions[msg["id"]] = unsubscribe
connection.send_result(msg["id"])
with (
async_get_chat_session(hass, subscribed_conversation) as session,
async_get_chat_log(hass, session) as chat_log,
):
connection.send_event(
msg_id,
{
"event_type": ChatLogEventType.INITIAL_STATE,
"data": chat_log.as_dict(),
},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "conversation/chat_log/subscribe_index",
}
)
@websocket_api.require_admin
def websocket_subscribe_chat_log_index(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to a chat log."""
msg_id = msg["id"]
@callback
def forward_events(
conversation_id: str, event_type: ChatLogEventType, data: dict
) -> None:
"""Forward chat log events to websocket connection."""
if event_type not in (ChatLogEventType.CREATED, ChatLogEventType.DELETED):
return
connection.send_event(
msg_id,
{
"conversation_id": conversation_id,
"event_type": event_type,
"data": data,
},
)
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
connection.subscriptions[msg["id"]] = unsubscribe
connection.send_result(msg["id"])
chat_logs = hass.data.get(DATA_CHAT_LOGS)
if not chat_logs:
return
connection.send_event(
msg_id,
{
"event_type": ChatLogEventType.INITIAL_STATE,
"data": [c.as_dict() for c in chat_logs.values()],
},
)

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["pycync==0.4.2"]
"requirements": ["pycync==0.4.1"]
}

View File

@@ -184,8 +184,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_API_KEY: self.api_key,
},
reload_on_update=False,
}
)
except TimeoutError:
@@ -232,8 +231,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
},
reload_on_update=False,
}
)
self.context.update(
@@ -267,8 +265,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_API_KEY: self.api_key,
},
reload_on_update=False,
}
)
self.context["configuration_url"] = HASSIO_CONFIGURATION_URL

View File

@@ -80,7 +80,8 @@ async def async_setup_entry(
)
class DevoloScannerEntity(
# The pylint disable is needed because of https://github.com/pylint-dev/pylint/issues/9138
class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
CoordinatorEntity[DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]]],
ScannerEntity,
):

View File

@@ -122,12 +122,10 @@ class WanIpSensor(SensorEntity):
try:
async with asyncio.timeout(10):
response = await self.resolver.query(self.hostname, self.querytype)
except TimeoutError as err:
_LOGGER.debug("Timeout while resolving host: %s", err)
except TimeoutError:
await self.resolver.close()
except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err)
await self.resolver.close()
if response:
sorted_ips = sort_ips(

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/droplet",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["pydroplet==2.3.4"],
"requirements": ["pydroplet==2.3.3"],
"zeroconf": ["_droplet._tcp.local."]
}

View File

@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
from .const import SUPPORTED_LIFESPANS
from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
@@ -62,7 +62,7 @@ STATION_ENTITY_DESCRIPTIONS = tuple(
key=f"station_action_{action.name.lower()}",
translation_key=f"station_action_{action.name.lower()}",
)
for action in SUPPORTED_STATION_ACTIONS
for action in StationAction
)

View File

@@ -23,11 +23,7 @@ SUPPORTED_LIFESPANS = (
LifeSpan.STATION_FILTER,
)
SUPPORTED_STATION_ACTIONS = (
StationAction.CLEAN_BASE,
StationAction.DRY_MOP,
StationAction.EMPTY_DUSTBIN,
)
SUPPORTED_STATION_ACTIONS = (StationAction.EMPTY_DUSTBIN,)
LEGACY_SUPPORTED_LIFESPANS = (
"main_brush",

View File

@@ -36,12 +36,6 @@
"reset_lifespan_round_mop": {
"default": "mdi:broom"
},
"station_action_clean_base": {
"default": "mdi:home"
},
"station_action_dry_mop": {
"default": "mdi:broom"
},
"station_action_empty_dustbin": {
"default": "mdi:delete-restore"
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==16.1.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"]
}

View File

@@ -70,12 +70,6 @@
"reset_lifespan_side_brush": {
"name": "Reset side brush lifespan"
},
"station_action_clean_base": {
"name": "Clean base"
},
"station_action_dry_mop": {
"name": "Dry mop"
},
"station_action_empty_dustbin": {
"name": "Empty dustbin"
}

View File

@@ -17,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.helpers.typing import ConfigType
from . import assist_satellite, dashboard, ffmpeg_proxy
from . import dashboard, ffmpeg_proxy
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
from .domain_data import DomainData
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
@@ -31,7 +31,6 @@ CLIENT_INFO = f"Home Assistant {ha_version}"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the esphome component."""
ffmpeg_proxy.async_setup(hass)
await assist_satellite.async_setup(hass)
await dashboard.async_setup(hass)
return True

View File

@@ -5,12 +5,9 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterable
from functools import partial
import hashlib
import io
from itertools import chain
import json
import logging
from pathlib import Path
import socket
from typing import Any, cast
import wave
@@ -22,12 +19,9 @@ from aioesphomeapi import (
VoiceAssistantAudioSettings,
VoiceAssistantCommandFlag,
VoiceAssistantEventType,
VoiceAssistantExternalWakeWord,
VoiceAssistantFeature,
VoiceAssistantTimerEventType,
)
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.components import assist_satellite, tts
from homeassistant.components.assist_pipeline import (
@@ -35,7 +29,6 @@ from homeassistant.components.assist_pipeline import (
PipelineEventType,
PipelineStage,
)
from homeassistant.components.http import StaticPathConfig
from homeassistant.components.intent import (
TimerEventType,
TimerInfo,
@@ -46,11 +39,8 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.network import get_url
from homeassistant.helpers.singleton import singleton
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN, WAKE_WORDS_API_PATH, WAKE_WORDS_DIR_NAME
from .const import DOMAIN
from .entity import EsphomeAssistEntity, convert_api_error_ha_error
from .entry_data import ESPHomeConfigEntry
from .enum_mapper import EsphomeEnumMapper
@@ -94,16 +84,6 @@ _TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventTy
_ANNOUNCEMENT_TIMEOUT_SEC = 5 * 60 # 5 minutes
_CONFIG_TIMEOUT_SEC = 5
_WAKE_WORD_CONFIG_SCHEMA = vol.Schema(
{
vol.Required("type"): str,
vol.Required("wake_word"): str,
},
extra=vol.ALLOW_EXTRA,
)
_DATA_WAKE_WORDS: HassKey[dict[str, VoiceAssistantExternalWakeWord]] = HassKey(
"wake_word_cache"
)
async def async_setup_entry(
@@ -202,14 +182,9 @@ class EsphomeAssistSatellite(
async def _update_satellite_config(self) -> None:
"""Get the latest satellite configuration from the device."""
wake_words = await async_get_custom_wake_words(self.hass)
if wake_words:
_LOGGER.debug("Found custom wake words: %s", sorted(wake_words.keys()))
try:
config = await self.cli.get_voice_assistant_configuration(
_CONFIG_TIMEOUT_SEC,
external_wake_words=list(wake_words.values()),
_CONFIG_TIMEOUT_SEC
)
except TimeoutError:
# Placeholder config will be used
@@ -809,78 +784,3 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
return
self.transport.sendto(data, self.remote_addr)
async def async_get_custom_wake_words(
hass: HomeAssistant,
) -> dict[str, VoiceAssistantExternalWakeWord]:
"""Get available custom wake words."""
return await hass.async_add_executor_job(_get_custom_wake_words, hass)
@singleton(_DATA_WAKE_WORDS)
def _get_custom_wake_words(
hass: HomeAssistant,
) -> dict[str, VoiceAssistantExternalWakeWord]:
"""Get available custom wake words (singleton)."""
wake_words_dir = Path(hass.config.path(WAKE_WORDS_DIR_NAME))
wake_words: dict[str, VoiceAssistantExternalWakeWord] = {}
# Look for config/model files
for config_path in wake_words_dir.glob("*.json"):
wake_word_id = config_path.stem
model_path = config_path.with_suffix(".tflite")
if not model_path.exists():
# Missing model file
continue
with open(config_path, encoding="utf-8") as config_file:
config_dict = json.load(config_file)
try:
config = _WAKE_WORD_CONFIG_SCHEMA(config_dict)
except vol.Invalid as err:
# Invalid config
_LOGGER.debug(
"Invalid wake word config: path=%s, error=%s",
config_path,
humanize_error(config_dict, err),
)
continue
with open(model_path, "rb") as model_file:
model_hash = hashlib.sha256(model_file.read()).hexdigest()
model_size = model_path.stat().st_size
config_rel_path = config_path.relative_to(wake_words_dir)
# Only intended for the internal network
base_url = get_url(hass, prefer_external=False, allow_cloud=False)
wake_words[wake_word_id] = VoiceAssistantExternalWakeWord.from_dict(
{
"id": wake_word_id,
"wake_word": config["wake_word"],
"trained_languages": config_dict.get("trained_languages", []),
"model_type": config["type"],
"model_size": model_size,
"model_hash": model_hash,
"url": f"{base_url}{WAKE_WORDS_API_PATH}/{config_rel_path}",
}
)
return wake_words
async def async_setup(hass: HomeAssistant) -> None:
"""Set up the satellite."""
wake_words_dir = Path(hass.config.path(WAKE_WORDS_DIR_NAME))
# Satellites will pull model files over HTTP
await hass.http.async_register_static_paths(
[
StaticPathConfig(
url_path=WAKE_WORDS_API_PATH,
path=str(wake_words_dir),
)
]
)

View File

@@ -27,6 +27,3 @@ STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html"
NO_WAKE_WORD: Final[str] = "no_wake_word"
WAKE_WORDS_DIR_NAME = "custom_wake_words"
WAKE_WORDS_API_PATH = "/api/esphome/wake_words"

View File

@@ -1,42 +0,0 @@
"""The Fing integration."""
from __future__ import annotations
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from .coordinator import FingConfigEntry, FingDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.DEVICE_TRACKER]
async def async_setup_entry(hass: HomeAssistant, config_entry: FingConfigEntry) -> bool:
"""Set up the Fing component."""
coordinator = FingDataUpdateCoordinator(hass, config_entry)
await coordinator.async_config_entry_first_refresh()
if coordinator.data.network_id is None:
_LOGGER.warning(
"Skip setting up Fing integration; Received an empty NetworkId from the request - Check if the API version is the latest"
)
raise ConfigEntryError(
"The Agent's API version is outdated. Please update the agent to the latest version."
)
config_entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: FingConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)

View File

@@ -1,114 +0,0 @@
"""Config flow file."""
from contextlib import suppress
import logging
from typing import Any
from fing_agent_api import FingAgent
import httpx
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from .const import DOMAIN, UPNP_AVAILABLE
_LOGGER = logging.getLogger(__name__)
class FingConfigFlow(ConfigFlow, domain=DOMAIN):
"""Fing config flow."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set up user step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
devices_response = None
agent_info_response = None
self._async_abort_entries_match(
{CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS]}
)
fing_api = FingAgent(
ip=user_input[CONF_IP_ADDRESS],
port=int(user_input[CONF_PORT]),
key=user_input[CONF_API_KEY],
)
try:
devices_response = await fing_api.get_devices()
with suppress(httpx.ConnectError):
# The suppression is needed because the get_agent_info method isn't available for desktop agents
agent_info_response = await fing_api.get_agent_info()
except httpx.NetworkError as _:
errors["base"] = "cannot_connect"
except httpx.TimeoutException as _:
errors["base"] = "timeout_connect"
except httpx.HTTPStatusError as exception:
description_placeholders["message"] = (
f"{exception.response.status_code} - {exception.response.reason_phrase}"
)
if exception.response.status_code == 401:
errors["base"] = "invalid_api_key"
else:
errors["base"] = "http_status_error"
except httpx.InvalidURL as _:
errors["base"] = "url_error"
except (
httpx.HTTPError,
httpx.CookieConflict,
httpx.StreamError,
) as ex:
_LOGGER.error("Unexpected exception: %s", ex)
errors["base"] = "unknown"
else:
if (
devices_response.network_id is not None
and len(devices_response.network_id) > 0
):
agent_name = user_input.get(CONF_IP_ADDRESS)
upnp_available = False
if agent_info_response is not None:
upnp_available = True
agent_name = agent_info_response.agent_id
await self.async_set_unique_id(agent_info_response.agent_id)
self._abort_if_unique_id_configured()
data = {
CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS],
CONF_PORT: user_input[CONF_PORT],
CONF_API_KEY: user_input[CONF_API_KEY],
UPNP_AVAILABLE: upnp_available,
}
return self.async_create_entry(
title=f"Fing Agent {agent_name}",
data=data,
)
return self.async_abort(reason="api_version_error")
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_IP_ADDRESS): str,
vol.Required(CONF_PORT, default="49090"): str,
vol.Required(CONF_API_KEY): str,
}
),
user_input,
),
errors=errors,
description_placeholders=description_placeholders,
)

View File

@@ -1,4 +0,0 @@
"""Const for the Fing integration."""
DOMAIN = "fing"
UPNP_AVAILABLE = "upnp_available"

View File

@@ -1,85 +0,0 @@
"""DataUpdateCoordinator for Fing integration."""
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from fing_agent_api import FingAgent
from fing_agent_api.models import AgentInfoResponse, Device
import httpx
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPNP_AVAILABLE
_LOGGER = logging.getLogger(__name__)
type FingConfigEntry = ConfigEntry[FingDataUpdateCoordinator]
@dataclass
class FingDataObject:
"""Fing Data Object."""
network_id: str | None = None
agent_info: AgentInfoResponse | None = None
devices: dict[str, Device] = field(default_factory=dict)
class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
"""Class to manage fetching data from Fing Agent."""
def __init__(self, hass: HomeAssistant, config_entry: FingConfigEntry) -> None:
"""Initialize global Fing updater."""
self._fing = FingAgent(
ip=config_entry.data[CONF_IP_ADDRESS],
port=int(config_entry.data[CONF_PORT]),
key=config_entry.data[CONF_API_KEY],
)
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
update_interval = timedelta(seconds=30)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=update_interval,
config_entry=config_entry,
)
async def _async_update_data(self) -> FingDataObject:
"""Fetch data from Fing Agent."""
device_response = None
agent_info_response = None
try:
device_response = await self._fing.get_devices()
if self._upnp_available:
agent_info_response = await self._fing.get_agent_info()
except httpx.NetworkError as err:
raise UpdateFailed("Failed to connect") from err
except httpx.TimeoutException as err:
raise UpdateFailed("Timeout establishing connection") from err
except httpx.HTTPStatusError as err:
if err.response.status_code == 401:
raise UpdateFailed("Invalid API key") from err
raise UpdateFailed(
f"Http request failed -> {err.response.status_code} - {err.response.reason_phrase}"
) from err
except httpx.InvalidURL as err:
raise UpdateFailed("Invalid hostname or IP address") from err
except (
httpx.HTTPError,
httpx.CookieConflict,
httpx.StreamError,
) as err:
raise UpdateFailed("Unexpected error from HTTP request") from err
else:
return FingDataObject(
device_response.network_id,
agent_info_response,
{device.mac: device for device in device_response.devices},
)

View File

@@ -1,127 +0,0 @@
"""Platform for Device tracker integration."""
from fing_agent_api.models import Device
from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FingConfigEntry
from .coordinator import FingDataUpdateCoordinator
from .utils import get_icon_from_type
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FingConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
coordinator = config_entry.runtime_data
entity_registry = er.async_get(hass)
tracked_devices: set[str] = set()
@callback
def add_entities() -> None:
latest_devices = set(coordinator.data.devices.keys())
devices_to_remove = tracked_devices - set(latest_devices)
devices_to_add = set(latest_devices) - tracked_devices
entities_to_remove = []
for entity_entry in entity_registry.entities.values():
if entity_entry.config_entry_id != config_entry.entry_id:
continue
try:
_, mac = entity_entry.unique_id.rsplit("-", 1)
if mac in devices_to_remove:
entities_to_remove.append(entity_entry.entity_id)
except ValueError:
continue
for entity_id in entities_to_remove:
entity_registry.async_remove(entity_id)
entities_to_add = []
for mac_addr in devices_to_add:
device = coordinator.data.devices[mac_addr]
entities_to_add.append(FingTrackedDevice(coordinator, device))
tracked_devices.clear()
tracked_devices.update(latest_devices)
async_add_entities(entities_to_add)
add_entities()
config_entry.async_on_unload(coordinator.async_add_listener(add_entities))
class FingTrackedDevice(CoordinatorEntity[FingDataUpdateCoordinator], ScannerEntity):
"""Represent a tracked device."""
_attr_has_entity_name = True
def __init__(self, coordinator: FingDataUpdateCoordinator, device: Device) -> None:
"""Set up FingDevice entity."""
super().__init__(coordinator)
self._device = device
agent_id = coordinator.data.network_id
if coordinator.data.agent_info is not None:
agent_id = coordinator.data.agent_info.agent_id
self._attr_mac_address = self._device.mac
self._attr_unique_id = f"{agent_id}-{self._attr_mac_address}"
self._attr_name = self._device.name
self._attr_icon = get_icon_from_type(self._device.type)
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
return self._device.active
@property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return self._device.ip[0] if self._device.ip else None
@property
def entity_registry_enabled_default(self) -> bool:
"""Enable entity by default."""
return True
@property
def unique_id(self) -> str | None:
"""Return the unique ID of the entity."""
return self._attr_unique_id
def check_for_updates(self, new_device: Device) -> bool:
"""Return true if the device has updates."""
new_device_ip = new_device.ip[0] if new_device.ip else None
current_device_ip = self._device.ip[0] if self._device.ip else None
return (
current_device_ip != new_device_ip
or self._device.active != new_device.active
or self._device.type != new_device.type
or self._attr_name != new_device.name
or self._attr_icon != get_icon_from_type(new_device.type)
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
updated_device_data = self.coordinator.data.devices.get(self._device.mac)
if updated_device_data is not None and self.check_for_updates(
updated_device_data
):
self._device = updated_device_data
self._attr_name = updated_device_data.name
self._attr_icon = get_icon_from_type(updated_device_data.type)
er.async_get(self.hass).async_update_entity(
entity_id=self.entity_id,
original_name=self._attr_name,
original_icon=self._attr_icon,
)
self.async_write_ha_state()

View File

@@ -1,10 +0,0 @@
{
"domain": "fing",
"name": "Fing",
"codeowners": ["@Lorenzo-Gasparini"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fing",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["fing_agent_api==1.0.3"]
}

View File

@@ -1,72 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: The integration has no actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: There are no actions in Fing integration.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Fing integration entities do not use events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: The integration has no actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: The integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: done
entity-category: todo
entity-device-class:
status: exempt
comment: The integration creates only device tracker entities
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: done
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -1,31 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up Fing agent",
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",
"port": "[%key:common::config_flow::data::port%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"ip_address": "IP address of the Fing agent.",
"port": "Port number of the Fing API.",
"api_key": "API key used to authenticate with the Fing API."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"url_error": "[%key:common::config_flow::error::invalid_host%]",
"http_status_error": "HTTP request failed: {message}"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"api_version_error": "Your agent is using an outdated API version. The required 'network_id' parameter is missing. Please update to the latest API version."
}
}
}

View File

@@ -1,85 +0,0 @@
"""Utils functions."""
from enum import Enum
class DeviceType(Enum):
"""Device types enum."""
GENERIC = "mdi:lan-connect"
MOBILE = PHONE = "mdi:cellphone"
TABLET = IPOD = EREADER = "mdi:tablet"
WATCH = WEARABLE = "mdi:watch"
CAR = AUTOMOTIVE = "mdi:car-back"
MEDIA_PLAYER = "mdi:volume-high"
TELEVISION = "mdi:television"
GAME_CONSOLE = "mdi:nintendo-game-boy"
STREAMING_DONGLE = "mdi:cast"
LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker"
DISC_PLAYER = "mdi:disk-player"
REMOTE_CONTROL = "mdi:remote-tv"
RADIO = "mdi:radio"
PHOTO_CAMERA = PHOTOS = "mdi:camera"
MICROPHONE = VOICE_CONTROL = "mdi:microphone"
PROJECTOR = "mdi:projector"
COMPUTER = DESKTOP = "mdi:desktop-tower"
LAPTOP = "mdi:laptop"
PRINTER = "mdi:printer"
SCANNER = "mdi:scanner"
POS = "mdi:printer-pos"
CLOCK = "mdi:clock"
BARCODE = "mdi:barcode"
SURVEILLANCE_CAMERA = BABY_MONITOR = PET_MONITOR = "mdi:cctv"
POE_PLUG = HEALTH_MONITOR = SMART_HOME = SMART_METER = APPLIANCE = SLEEP = (
"mdi:home-automation"
)
SMART_PLUG = "mdi:power-plug"
LIGHT = "mdi:lightbulb"
THERMOSTAT = HEATING = "mdi:home-thermometer"
POWER_SYSTEM = ENERGY = "mdi:lightning-bolt"
SOLAR_PANEL = "mdi:solar-power"
WASHER = "mdi:washing-machine"
FRIDGE = "mdi:fridge"
CLEANER = "mdi:vacuum"
GARAGE = "mdi:garage"
SPRINKLER = "mdi:sprinkler"
BELL = "mdi:doorbell"
KEY_LOCK = "mdi:lock-smart"
CONTROL_PANEL = SMART_CONTROLLER = "mdi:alarm-panel"
SCALE = "mdi:scale-bathroom"
TOY = "mdi:teddy-bear"
ROBOT = "mdi:robot"
WEATHER = "mdi:weather-cloudy"
ALARM = "mdi:alarm-light"
MOTION_DETECTOR = "mdi:motion-sensor"
SMOKE = HUMIDITY = SENSOR = DOMOTZ_BOX = FINGBOX = "mdi:smoke-detector"
ROUTER = MODEM = GATEWAY = FIREWALL = VPN = SMALL_CELL = "mdi:router-network"
WIFI = WIFI_EXTENDER = "mdi:wifi"
NAS_STORAGE = "mdi:nas"
SWITCH = "mdi:switch"
USB = "mdi:usb"
CLOUD = "mdi:cloud"
BATTERY = "mdi:battery"
NETWORK_APPLIANCE = "mdi:network"
VIRTUAL_MACHINE = MAIL_SERVER = FILE_SERVER = PROXY_SERVER = WEB_SERVER = (
DOMAIN_SERVER
) = COMMUNICATION = "mdi:monitor"
SERVER = "mdi:server"
TERMINAL = "mdi:console"
DATABASE = "mdi:database"
RASPBERRY = ARDUINO = "mdi:raspberry-pi"
PROCESSOR = CIRCUIT_CARD = RFID = "mdi:chip"
INDUSTRIAL = "mdi:factory"
MEDICAL = "mdi:medical-bag"
VOIP = CONFERENCING = "mdi:phone-voip"
FITNESS = "mdi:dumbbell"
POOL = "mdi:pool"
SECURITY_SYSTEM = "mdi:security"
def get_icon_from_type(type: str) -> str:
"""Return the right icon based on the type."""
try:
return DeviceType[type].value
except (ValueError, KeyError):
return "mdi:lan-connect"

View File

@@ -106,7 +106,7 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
for category in categories
]
primary_currency = await self.firefly.get_currency_primary()
budgets = await self.firefly.get_budgets(start=start_date, end=end_date)
budgets = await self.firefly.get_budgets()
bills = await self.firefly.get_bills()
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from pyfirefly.models import Account, Budget, Category
from pyfirefly.models import Account, Category
from yarl import URL
from homeassistant.const import CONF_URL
@@ -83,29 +83,3 @@ class FireflyCategoryBaseEntity(FireflyBaseEntity):
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_category_{category.id}_{key}"
)
class FireflyBudgetBaseEntity(FireflyBaseEntity):
"""Base class for Firefly III budget entity."""
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
budget: Budget,
key: str,
) -> None:
"""Initialize a Firefly budget entity."""
super().__init__(coordinator)
self._budget = budget
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
name=budget.attributes.name,
configuration_url=f"{URL(coordinator.config_entry.data[CONF_URL])}/budgets/show/{budget.id}",
identifiers={
(DOMAIN, f"{coordinator.config_entry.entry_id}_budget_{budget.id}")
},
)
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_budget_{budget.id}_{key}"
)

View File

@@ -12,9 +12,6 @@
},
"category": {
"default": "mdi:label"
},
"budget": {
"default": "mdi:chart-pie"
}
}
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.8"]
"requirements": ["pyfirefly==0.1.6"]
}

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from pyfirefly.models import Account, Budget, Category
from pyfirefly.models import Account, Category
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -15,11 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
from .entity import (
FireflyAccountBaseEntity,
FireflyBudgetBaseEntity,
FireflyCategoryBaseEntity,
)
from .entity import FireflyAccountBaseEntity, FireflyCategoryBaseEntity
ACCOUNT_ROLE_MAPPING = {
"defaultAsset": "default_asset",
@@ -39,7 +35,6 @@ ACCOUNT_BALANCE = "account_balance"
ACCOUNT_ROLE = "account_role"
ACCOUNT_TYPE = "account_type"
CATEGORY = "category"
BUDGET = "budget"
async def async_setup_entry(
@@ -65,13 +60,6 @@ async def async_setup_entry(
]
)
entities.extend(
[
FireflyBudgetSensor(coordinator, budget, BUDGET)
for budget in coordinator.data.budgets
]
)
async_add_entities(entities)
@@ -188,30 +176,3 @@ class FireflyCategorySensor(FireflyCategoryBaseEntity, SensorEntity):
if spent == 0 and earned == 0:
return None
return spent + earned
class FireflyBudgetSensor(FireflyBudgetBaseEntity, SensorEntity):
"""Budget sensor."""
_attr_translation_key = "budget"
_attr_device_class = SensorDeviceClass.MONETARY
_attr_state_class = SensorStateClass.TOTAL
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
budget: Budget,
key: str,
) -> None:
"""Initialize the budget sensor."""
super().__init__(coordinator, budget, key)
self._budget = budget
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
@property
def native_value(self) -> StateType:
"""Return spent value for this budget in the period."""
spent_items = self._budget.attributes.spent or []
return sum(float(item.sum) for item in spent_items if item.sum is not None)

View File

@@ -72,9 +72,6 @@
},
"category": {
"name": "Earned/Spent"
},
"budget": {
"name": "Budget"
}
}
}

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."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
RECOMMENDED_VERSION = "1.9.11"
RECOMMENDED_VERSION = "1.9.9"

View File

@@ -186,7 +186,6 @@ async def async_setup_entry(
class InverterSensor(CoordinatorEntity[GoodweUpdateCoordinator], SensorEntity):
"""Entity representing individual inverter sensor."""
_attr_has_entity_name = True
entity_description: GoodweSensorEntityDescription
def __init__(

View File

@@ -15,13 +15,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .const import (
CONF_CHAT_MODEL,
CONF_RECOMMENDED,
LOGGER,
RECOMMENDED_AI_TASK_MAX_TOKENS,
RECOMMENDED_IMAGE_MODEL,
)
from .const import CONF_CHAT_MODEL, CONF_RECOMMENDED, LOGGER, RECOMMENDED_IMAGE_MODEL
from .entity import (
ERROR_GETTING_RESPONSE,
GoogleGenerativeAILLMBaseEntity,
@@ -79,9 +73,7 @@ class GoogleGenerativeAITaskEntity(
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(
chat_log, task.structure, default_max_tokens=RECOMMENDED_AI_TASK_MAX_TOKENS
)
await self._async_handle_chat_log(chat_log, task.structure)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
LOGGER.error(

View File

@@ -32,8 +32,6 @@ CONF_TOP_K = "top_k"
RECOMMENDED_TOP_K = 64
CONF_MAX_TOKENS = "max_tokens"
RECOMMENDED_MAX_TOKENS = 3000
# Input 5000, output 19400 = 0.05 USD
RECOMMENDED_AI_TASK_MAX_TOKENS = 19400
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"

View File

@@ -472,7 +472,6 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
self,
chat_log: conversation.ChatLog,
structure: vol.Schema | None = None,
default_max_tokens: int | None = None,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
@@ -619,9 +618,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
if not chat_log.unresponded_tool_results:
break
def create_generate_content_config(
self, default_max_tokens: int | None = None
) -> GenerateContentConfig:
def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
model = options.get(CONF_CHAT_MODEL, self.default_model)
@@ -635,12 +632,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=options.get(
CONF_MAX_TOKENS,
default_max_tokens
if default_max_tokens is not None
else RECOMMENDED_MAX_TOKENS,
),
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,

View File

@@ -59,7 +59,6 @@ class GoogleGenerativeAITextToSpeechEntity(
"en-US",
"es-US",
"fr-FR",
"he-IL",
"hi-IN",
"id-ID",
"it-IT",

View File

@@ -72,7 +72,6 @@ PLATFORMS = [
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
Platform.VALVE,
]
_LOGGER = logging.getLogger(__name__)

View File

@@ -35,7 +35,6 @@ from .media_player import MediaPlayerGroup, async_create_preview_media_player
from .notify import async_create_preview_notify
from .sensor import async_create_preview_sensor
from .switch import async_create_preview_switch
from .valve import async_create_preview_valve
_STATISTIC_MEASURES = [
"last",
@@ -173,7 +172,6 @@ GROUP_TYPES = [
"notify",
"sensor",
"switch",
"valve",
]
@@ -255,11 +253,6 @@ CONFIG_FLOW = {
preview="group",
validate_user_input=set_group_type("switch"),
),
"valve": SchemaFlowFormStep(
basic_group_config_schema("valve"),
preview="group",
validate_user_input=set_group_type("valve"),
),
}
@@ -309,10 +302,6 @@ OPTIONS_FLOW = {
partial(light_switch_options_schema, "switch"),
preview="group",
),
"valve": SchemaFlowFormStep(
partial(basic_group_options_schema, "valve"),
preview="group",
),
}
PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {}
@@ -332,7 +321,6 @@ CREATE_PREVIEW_ENTITY: dict[
"notify": async_create_preview_notify,
"sensor": async_create_preview_sensor,
"switch": async_create_preview_switch,
"valve": async_create_preview_valve,
}

View File

@@ -16,8 +16,7 @@
"media_player": "Media player group",
"notify": "Notify group",
"sensor": "Sensor group",
"switch": "Switch group",
"valve": "Valve group"
"switch": "Switch group"
}
},
"binary_sensor": {
@@ -128,18 +127,6 @@
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
},
"valve": {
"title": "[%key:component::group::config::step::user::title%]",
"data": {
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
}
}
},
@@ -225,16 +212,6 @@
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
},
"valve": {
"data": {
"all": "[%key:component::group::config::step::binary_sensor::data::all%]",
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]"
},
"data_description": {
"all": "[%key:component::group::config::step::binary_sensor::data_description::all%]"
}
}
}
},

View File

@@ -1,262 +0,0 @@
"""Platform allowing several valves to be grouped into one valve."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.valve import (
ATTR_CURRENT_POSITION,
ATTR_POSITION,
DOMAIN as VALVE_DOMAIN,
PLATFORM_SCHEMA as VALVE_PLATFORM_SCHEMA,
ValveEntity,
ValveEntityFeature,
ValveState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
CONF_ENTITIES,
CONF_NAME,
CONF_UNIQUE_ID,
SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION,
SERVICE_STOP_VALVE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
from .util import reduce_attribute
KEY_OPEN_CLOSE = "open_close"
KEY_STOP = "stop"
KEY_SET_POSITION = "set_position"
DEFAULT_NAME = "Valve Group"
# No limit on parallel updates to enable a group calling another group
PARALLEL_UPDATES = 0
PLATFORM_SCHEMA = VALVE_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ENTITIES): cv.entities_domain(VALVE_DOMAIN),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Valve Group platform."""
async_add_entities(
[
ValveGroup(
config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES]
)
]
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Valve Group config entry."""
registry = er.async_get(hass)
entities = er.async_validate_entity_ids(
registry, config_entry.options[CONF_ENTITIES]
)
async_add_entities(
[ValveGroup(config_entry.entry_id, config_entry.title, entities)]
)
@callback
def async_create_preview_valve(
hass: HomeAssistant, name: str, validated_config: dict[str, Any]
) -> ValveGroup:
"""Create a preview valve."""
return ValveGroup(
None,
name,
validated_config[CONF_ENTITIES],
)
class ValveGroup(GroupEntity, ValveEntity):
"""Representation of a ValveGroup."""
_attr_available: bool = False
_attr_current_valve_position: int | None = None
_attr_is_closed: bool | None = None
_attr_is_closing: bool | None = False
_attr_is_opening: bool | None = False
_attr_reports_position: bool = False
def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None:
"""Initialize a ValveGroup entity."""
self._entity_ids = entities
self._valves: dict[str, set[str]] = {
KEY_OPEN_CLOSE: set(),
KEY_STOP: set(),
KEY_SET_POSITION: set(),
}
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities}
self._attr_unique_id = unique_id
@callback
def async_update_supported_features(
self,
entity_id: str,
new_state: State | None,
) -> None:
"""Update dictionaries with supported features."""
if not new_state:
for values in self._valves.values():
values.discard(entity_id)
return
features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if features & (ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE):
self._valves[KEY_OPEN_CLOSE].add(entity_id)
else:
self._valves[KEY_OPEN_CLOSE].discard(entity_id)
if features & (ValveEntityFeature.STOP):
self._valves[KEY_STOP].add(entity_id)
else:
self._valves[KEY_STOP].discard(entity_id)
if features & (ValveEntityFeature.SET_POSITION):
self._valves[KEY_SET_POSITION].add(entity_id)
else:
self._valves[KEY_SET_POSITION].discard(entity_id)
async def async_open_valve(self) -> None:
"""Open the valves."""
data = {ATTR_ENTITY_ID: self._valves[KEY_OPEN_CLOSE]}
await self.hass.services.async_call(
VALVE_DOMAIN, SERVICE_OPEN_VALVE, data, blocking=True, context=self._context
)
async def async_handle_open_valve(self) -> None: # type: ignore[misc]
"""Open the valves.
Override the base class to avoid calling the set position service
for all valves. Transfer the service call to the base class and let
it decide if the valve uses set position or open service.
"""
await self.async_open_valve()
async def async_close_valve(self) -> None:
"""Close valves."""
data = {ATTR_ENTITY_ID: self._valves[KEY_OPEN_CLOSE]}
await self.hass.services.async_call(
VALVE_DOMAIN,
SERVICE_CLOSE_VALVE,
data,
blocking=True,
context=self._context,
)
async def async_handle_close_valve(self) -> None: # type: ignore[misc]
"""Close the valves.
Override the base class to avoid calling the set position service
for all valves. Transfer the service call to the base class and let
it decide if the valve uses set position or close service.
"""
await self.async_close_valve()
async def async_set_valve_position(self, position: int) -> None:
"""Move the valves to a specific position."""
data = {
ATTR_ENTITY_ID: self._valves[KEY_SET_POSITION],
ATTR_POSITION: position,
}
await self.hass.services.async_call(
VALVE_DOMAIN,
SERVICE_SET_VALVE_POSITION,
data,
blocking=True,
context=self._context,
)
async def async_stop_valve(self) -> None:
"""Stop the valves."""
data = {ATTR_ENTITY_ID: self._valves[KEY_STOP]}
await self.hass.services.async_call(
VALVE_DOMAIN, SERVICE_STOP_VALVE, data, blocking=True, context=self._context
)
@callback
def async_update_group_state(self) -> None:
"""Update state and attributes."""
states = [
state
for entity_id in self._entity_ids
if (state := self.hass.states.get(entity_id)) is not None
]
# Set group as unavailable if all members are unavailable or missing
self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states)
self._attr_is_closed = True
self._attr_is_closing = False
self._attr_is_opening = False
self._attr_reports_position = False
self._update_assumed_state_from_members()
for state in states:
if state.attributes.get(ATTR_CURRENT_POSITION) is not None:
self._attr_reports_position = True
if state.state == ValveState.OPEN:
self._attr_is_closed = False
continue
if state.state == ValveState.CLOSED:
continue
if state.state == ValveState.CLOSING:
self._attr_is_closing = True
continue
if state.state == ValveState.OPENING:
self._attr_is_opening = True
continue
valid_state = any(
state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states
)
if not valid_state:
# Set as unknown if all members are unknown or unavailable
self._attr_is_closed = None
self._attr_current_valve_position = reduce_attribute(
states, ATTR_CURRENT_POSITION
)
supported_features = ValveEntityFeature(0)
if self._valves[KEY_OPEN_CLOSE]:
supported_features |= ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
if self._valves[KEY_STOP]:
supported_features |= ValveEntityFeature.STOP
if self._valves[KEY_SET_POSITION]:
supported_features |= ValveEntityFeature.SET_POSITION
self._attr_supported_features = supported_features

View File

@@ -36,7 +36,7 @@ DEFAULT_URL = SERVER_URLS[0]
DOMAIN = "growatt_server"
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
LOGIN_INVALID_AUTH_CODE = "502"

View File

@@ -1,162 +0,0 @@
"""Number platform for Growatt."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from growattServer import GrowattV1ApiError
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = (
1 # Serialize updates as inverter does not handle concurrent requests
)
@dataclass(frozen=True, kw_only=True)
class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin):
"""Describes Growatt number entity."""
write_key: str | None = None # Parameter ID for writing (if different from api_key)
# Note that the Growatt V1 API uses different keys for reading and writing parameters.
# Reading values returns camelCase keys, while writing requires snake_case keys.
MIN_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = (
GrowattNumberEntityDescription(
key="battery_charge_power_limit",
translation_key="battery_charge_power_limit",
api_key="chargePowerCommand", # Key returned by V1 API
write_key="charge_power", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
GrowattNumberEntityDescription(
key="battery_charge_soc_limit",
translation_key="battery_charge_soc_limit",
api_key="wchargeSOCLowLimit", # Key returned by V1 API
write_key="charge_stop_soc", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
GrowattNumberEntityDescription(
key="battery_discharge_power_limit",
translation_key="battery_discharge_power_limit",
api_key="disChargePowerCommand", # Key returned by V1 API
write_key="discharge_power", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
GrowattNumberEntityDescription(
key="battery_discharge_soc_limit",
translation_key="battery_discharge_soc_limit",
api_key="wdisChargeSOCLowLimit", # Key returned by V1 API
write_key="discharge_stop_soc", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GrowattConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Growatt number entities."""
runtime_data = entry.runtime_data
# Add number entities for each MIN device (only supported with V1 API)
async_add_entities(
GrowattNumber(device_coordinator, description)
for device_coordinator in runtime_data.devices.values()
if (
device_coordinator.device_type == "min"
and device_coordinator.api_version == "v1"
)
for description in MIN_NUMBER_TYPES
)
class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
"""Representation of a Growatt number."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
entity_description: GrowattNumberEntityDescription
def __init__(
self,
coordinator: GrowattCoordinator,
description: GrowattNumberEntityDescription,
) -> None:
"""Initialize the number."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device_id)},
manufacturer="Growatt",
name=coordinator.device_id,
)
@property
def native_value(self) -> int | None:
"""Return the current value of the number."""
value = self.coordinator.data.get(self.entity_description.api_key)
if value is None:
return None
return int(value)
async def async_set_native_value(self, value: float) -> None:
"""Set the value of the number."""
# Use write_key if specified, otherwise fall back to api_key
parameter_id = (
self.entity_description.write_key or self.entity_description.api_key
)
int_value = int(value)
try:
# Use V1 API to write parameter
await self.hass.async_add_executor_job(
self.coordinator.api.min_write_parameter,
self.coordinator.device_id,
parameter_id,
int_value,
)
except GrowattV1ApiError as e:
raise HomeAssistantError(f"Error while setting parameter: {e}") from e
# If no exception was raised, the write was successful
_LOGGER.debug(
"Set parameter %s to %s",
parameter_id,
value,
)
# Update the value in coordinator data to avoid triggering an immediate
# refresh that would hit the API rate limit (5-minute polling interval)
self.coordinator.data[self.entity_description.api_key] = int_value
self.async_write_ha_state()

View File

@@ -504,20 +504,6 @@
"name": "Maximum power"
}
},
"number": {
"battery_charge_power_limit": {
"name": "Battery charge power limit"
},
"battery_charge_soc_limit": {
"name": "Battery charge SOC limit"
},
"battery_discharge_power_limit": {
"name": "Battery discharge power limit"
},
"battery_discharge_soc_limit": {
"name": "Battery discharge SOC limit"
}
},
"switch": {
"ac_charge": {
"name": "Charge from grid"

View File

@@ -3,15 +3,18 @@
from __future__ import annotations
import logging
import sys
from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS
from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
from .data import HarmonyConfigEntry, HarmonyData
if sys.version_info < (3, 14):
from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS
from .data import HarmonyConfigEntry, HarmonyData
_LOGGER = logging.getLogger(__name__)
@@ -22,6 +25,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HarmonyConfigEntry) -> b
# when setting up a config entry, we fallback to adding
# the options to the config entry and pull them out here if
# they are missing from the options
if sys.version_info >= (3, 14):
raise HomeAssistantError(
"Logitech Harmony Hub is not supported on Python 3.14. Please use Python 3.13."
)
_async_import_options_from_data_if_missing(hass, entry)
address = entry.data[CONF_HOST]

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/harmony",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
"requirements": ["aioharmony==0.5.3"],
"requirements": ["aioharmony==0.5.3;python_version<'3.14'"],
"ssdp": [
{
"manufacturer": "Logitech",

View File

@@ -109,8 +109,6 @@ DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
PLACEHOLDER_KEY_ADDON_DOCUMENTATION = "addon_documentation"
PLACEHOLDER_KEY_ADDON_URL = "addon_url"
PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components"
@@ -122,7 +120,6 @@ ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"
@@ -159,7 +156,6 @@ EXTRA_PLACEHOLDERS = {
ISSUE_KEY_ADDON_PWNED: {
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
},
ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS,
}

View File

@@ -43,7 +43,6 @@ from .const import (
EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
@@ -85,7 +84,6 @@ ISSUE_KEYS_FOR_REPAIRS = {
"issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED,
}
_LOGGER = logging.getLogger(__name__)

View File

@@ -18,13 +18,10 @@ from . import get_addons_info, get_issues_info
from .const import (
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_ADDON_DOCUMENTATION,
PLACEHOLDER_KEY_ADDON_INFO,
PLACEHOLDER_KEY_COMPONENTS,
PLACEHOLDER_KEY_REFERENCE,
)
@@ -198,23 +195,6 @@ class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
return placeholders or None
class DeprecatedAddonIssueRepairFlow(AddonIssueRepairFlow):
"""Handler for deprecated addon issue fixing flows."""
@property
def description_placeholders(self) -> dict[str, str] | None:
"""Get description placeholders for steps."""
placeholders: dict[str, str] = super().description_placeholders or {}
if self.issue and self.issue.reference:
placeholders[PLACEHOLDER_KEY_ADDON_INFO] = (
f"homeassistant://hassio/addon/{self.issue.reference}/info"
)
placeholders[PLACEHOLDER_KEY_ADDON_DOCUMENTATION] = (
f"homeassistant://hassio/addon/{self.issue.reference}/documentation"
)
return placeholders or None
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@@ -225,8 +205,6 @@ async def async_create_fix_flow(
issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
return DockerConfigIssueRepairFlow(hass, issue_id)
if issue and issue.key == ISSUE_KEY_ADDON_DEPRECATED:
return DeprecatedAddonIssueRepairFlow(hass, issue_id)
if issue and issue.key in {
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL,

View File

@@ -56,19 +56,6 @@
"title": "Insecure secrets detected in add-on configuration",
"description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue."
},
"issue_addon_deprecated_addon": {
"title": "Installed add-on is deprecated",
"fix_flow": {
"step": {
"addon_execute_remove": {
"description": "Add-on {addon} is marked deprecated by the developer. This means it is no longer being maintained and so may break or become a security issue over time.\n\nReview the [readme]({addon_info}) and [documentation]({addon_documentation}) of the add-on to see if the developer provided instructions.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
}
},
"abort": {
"apply_suggestion_fail": "Could not uninstall the add-on. Check the Supervisor logs for more details."
}
}
},
"issue_mount_mount_failed": {
"title": "Network storage device failed",
"fix_flow": {

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.83", "babel==2.15.0"]
"requirements": ["holidays==0.82", "babel==2.15.0"]
}

View File

@@ -22,6 +22,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.23.0"],
"requirements": ["aiohomeconnect==0.22.0"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -12,7 +12,7 @@ if TYPE_CHECKING:
DOMAIN = ha.DOMAIN
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entities")
DATA_EXPOSED_ENTITIES: HassKey[ExposedEntities] = HassKey(f"{DOMAIN}.exposed_entites")
DATA_STOP_HANDLER = f"{DOMAIN}.stop_handler"
SERVICE_HOMEASSISTANT_STOP: Final = "stop"

View File

@@ -94,14 +94,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
for device in devices:
# Check if the device is still present in homee
device_identifiers = {identifier[1] for identifier in device.identifiers}
# homee itself uses just the uid, nodes use {uid}-{nodeid}
if homee.settings.uid in device_identifiers:
continue # Hub itself is never removed.
# homee itself uses just the uid, nodes use uid-nodeid
is_homee_hub = homee.settings.uid in device_identifiers
is_node_present = any(
f"{homee.settings.uid}-{node.id}" in device_identifiers
for node in homee.nodes
)
if not is_node_present:
if not is_node_present and not is_homee_hub:
_LOGGER.info("Removing device %s", device.name)
device_registry.async_update_device(
device_id=device.id,
@@ -111,17 +110,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
# Remove device at runtime when node is removed in homee
async def _remove_node_callback(node: HomeeNode, add: bool) -> None:
"""Call when a node is removed."""
if add:
return
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{entry.runtime_data.settings.uid}-{node.id}")}
)
if device:
_LOGGER.info("Removing device %s", device.name)
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=entry.entry_id,
if not add:
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{entry.runtime_data.settings.uid}-{node.id}")}
)
if device:
_LOGGER.info("Removing device %s", device.name)
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=entry.entry_id,
)
homee.add_nodes_listener(_remove_node_callback)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.3.1"]
"requirements": ["homematicip==2.3.0"]
}

View File

@@ -11,7 +11,6 @@ from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@@ -72,25 +71,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def get_main_device(
hass: HomeAssistant, entry: HomeWizardConfigEntry
) -> dr.DeviceEntry | None:
"""Helper function to get the main device for the config entry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=entry.entry_id
)
if not device_entries:
return None
# Get first device that is not a sub-device, as this is the main device in HomeWizard
# This is relevant for the P1 Meter which may create sub-devices for external utility meters
return next(
(device for device in device_entries if device.via_device_id is None), None
)
async def async_check_v2_support_and_create_issue(
hass: HomeAssistant, entry: HomeWizardConfigEntry
) -> None:
@@ -99,16 +79,6 @@ async def async_check_v2_support_and_create_issue(
if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)):
return
title = entry.title
# Try to get the name from the device registry
# This is to make it clearer which device needs reconfiguration, as the config entry title is kept default most of the time
if main_device := get_main_device(hass, entry):
device_name = main_device.name_by_user or main_device.name
if device_name and entry.title != device_name:
title = f"{entry.title} ({device_name})"
async_create_issue(
hass,
DOMAIN,
@@ -118,7 +88,7 @@ async def async_check_v2_support_and_create_issue(
learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device",
translation_key="migrate_to_v2_api",
translation_placeholders={
"title": title,
"title": entry.title,
},
severity=IssueSeverity.WARNING,
data={"entry_id": entry.entry_id},

View File

@@ -177,7 +177,7 @@
},
"issues": {
"migrate_to_v2_api": {
"title": "Update the authentication method for {title}",
"title": "Update authentication method",
"fix_flow": {
"step": {
"confirm": {

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/honeywell",
"iot_class": "cloud_polling",
"loggers": ["somecomfort"],
"requirements": ["AIOSomecomfort==0.0.35"]
"requirements": ["AIOSomecomfort==0.0.33"]
}

View File

@@ -43,22 +43,18 @@ def async_setup_forwarded(
some proxies, for example, Kubernetes NGINX ingress, only retain one element
in the X-Forwarded-Proto header. In that case, we'll just use what we have.
`X-Forwarded-Host: <host1>, <host2>, <host3>`
e.g., `X-Forwarded-Host: example.com, proxy.example.com, backend.example.com`
OR `X-Forwarded-Host: example.com` (one entry, even with multiple proxies)
`X-Forwarded-Host: <host>`
e.g., `X-Forwarded-Host: example.com`
If the previous headers are processed successfully, and the X-Forwarded-Host is
present, the last one in the list will be used (set by the proxy nearest to the backend).
Multiple headers are valid as stated in https://www.rfc-editor.org/rfc/rfc7239#section-7.1
If multiple headers are present, they are handled according to
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For#parsing
> "split each X-Forwarded-For header by comma into lists and then join the lists."
present, it will be used.
Additionally:
- If no X-Forwarded-For header is found, the processing of all headers is skipped.
- Throw HTTP 400 status when untrusted connected peer provides
X-Forwarded-For headers.
- If multiple instances of X-Forwarded-For, X-Forwarded-Proto or
X-Forwarded-Host are found, an HTTP 400 status code is thrown.
- If malformed or invalid (IP) data in X-Forwarded-For header is found,
an HTTP 400 status code is thrown.
- The connected client peer on the socket of the incoming connection,
@@ -115,12 +111,15 @@ def async_setup_forwarded(
)
raise HTTPBadRequest
# Process multiple X-Forwarded-For from the right side (by reversing the list)
forwarded_for_split = list(
reversed(
[addr for header in forwarded_for_headers for addr in header.split(",")]
# Multiple X-Forwarded-For headers
if len(forwarded_for_headers) > 1:
_LOGGER.error(
"Too many headers for X-Forwarded-For: %s", forwarded_for_headers
)
)
raise HTTPBadRequest
# Process X-Forwarded-For from the right side (by reversing the list)
forwarded_for_split = list(reversed(forwarded_for_headers[0].split(",")))
try:
forwarded_for = [ip_address(addr.strip()) for addr in forwarded_for_split]
except ValueError as err:
@@ -149,15 +148,14 @@ def async_setup_forwarded(
X_FORWARDED_PROTO, []
)
if forwarded_proto_headers:
# Process multiple X-Forwarded-Proto from the right side (by reversing the list)
forwarded_proto_split = list(
reversed(
[
addr
for header in forwarded_proto_headers
for addr in header.split(",")
]
if len(forwarded_proto_headers) > 1:
_LOGGER.error(
"Too many headers for X-Forward-Proto: %s", forwarded_proto_headers
)
raise HTTPBadRequest
forwarded_proto_split = list(
reversed(forwarded_proto_headers[0].split(","))
)
forwarded_proto = [proto.strip() for proto in forwarded_proto_split]
@@ -193,16 +191,14 @@ def async_setup_forwarded(
# Handle X-Forwarded-Host
forwarded_host_headers: list[str] = request.headers.getall(X_FORWARDED_HOST, [])
if forwarded_host_headers:
# Process multiple X-Forwarded-Host from the right side (by reversing the list)
forwarded_host = list(
reversed(
[
addr.strip()
for header in forwarded_host_headers
for addr in header.split(",")
]
# Multiple X-Forwarded-Host headers
if len(forwarded_host_headers) > 1:
_LOGGER.error(
"Too many headers for X-Forwarded-Host: %s", forwarded_host_headers
)
)[0]
raise HTTPBadRequest
forwarded_host = forwarded_host_headers[0].strip()
if not forwarded_host:
_LOGGER.error("Empty value received in X-Forward-Host header")
raise HTTPBadRequest

View File

@@ -14,7 +14,7 @@ rules:
comment: See if we can catch more specific exceptions in get_device_info.
dependency-transparency:
status: todo
comment: stringcase is not built and published to PyPI from a public CI pipeline. huawei-lte-api is from https://gitlab.salamek.cz/Mirrors/huawei-lte-api, see https://github.com/Salamek/huawei-lte-api/issues/253
comment: huawei-lte-api and stringcase are not built and published to PyPI from a public CI pipeline.
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
@@ -41,7 +41,7 @@ rules:
reauthentication-flow: done
test-coverage:
status: todo
comment: Get percentage up there, add missing actual action press invocations in button tests' suspended state tests.
comment: Get percentage up there, add missing actual action press invocations in button tests' suspended state tests, rename test_switch.py to test_switch.py + make its functions receive hass as first parameter where applicable.
# Gold
devices: done

View File

@@ -112,7 +112,7 @@ class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity):
self.mower_attributes
)
@handle_sending_exception
@handle_sending_exception()
async def async_press(self) -> None:
"""Send a command to the mower."""
await self.entity_description.press_fn(self.coordinator.api, self.mower_id)

View File

@@ -182,6 +182,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
"Failed to listen to websocket. Trying to reconnect: %s",
err,
)
if not hass.is_stopping:
await asyncio.sleep(self.reconnect_time)
self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME)
entry.async_create_background_task(
hass,
self.client_listen(hass, entry, automower_client),
"reconnect_task",
)
def _should_poll(self) -> bool:
"""Return True if at least one mower is connected and at least one is not OFF."""

View File

@@ -6,7 +6,7 @@ import asyncio
from collections.abc import Callable, Coroutine
import functools
import logging
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload
from typing import TYPE_CHECKING, Any, Concatenate
from aioautomower.exceptions import ApiError
from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea
@@ -37,42 +37,23 @@ ERROR_STATES = [
]
_Entity = TypeVar("_Entity", bound="AutomowerBaseEntity")
_P = ParamSpec("_P")
@callback
def _work_area_translation_key(work_area_id: int, key: str) -> str:
"""Return the translation key."""
if work_area_id == 0:
return f"my_lawn_{key}"
return f"work_area_{key}"
@overload
def handle_sending_exception(
_func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]: ...
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]
@overload
def handle_sending_exception(
*,
def handle_sending_exception[_Entity: AutomowerBaseEntity, **_P](
poll_after_sending: bool = False,
) -> Callable[
[Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]]],
Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]],
]: ...
def handle_sending_exception(
_func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]] | None = None,
*,
poll_after_sending: bool = False,
) -> (
Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]
| Callable[
[Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]]],
Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]],
]
):
) -> Callable[[_FuncType[_Entity, _P, Any]], _FuncType[_Entity, _P, None]]:
"""Handle exceptions while sending a command and optionally refresh coordinator."""
def decorator(
func: Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_Entity, _P], Coroutine[Any, Any, None]]:
def decorator(func: _FuncType[_Entity, _P, Any]) -> _FuncType[_Entity, _P, None]:
@functools.wraps(func)
async def wrapper(self: _Entity, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
@@ -92,20 +73,7 @@ def handle_sending_exception(
return wrapper
if _func is None:
# call with brackets: @handle_sending_exception(...)
return decorator
# call without brackets: @handle_sending_exception
return decorator(_func)
@callback
def _work_area_translation_key(work_area_id: int, key: str) -> str:
"""Return the translation key."""
if work_area_id == 0:
return f"my_lawn_{key}"
return f"work_area_{key}"
return decorator
class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):

View File

@@ -6,11 +6,11 @@
}
},
"button": {
"reset_cutting_blade_usage_time": {
"default": "mdi:saw-blade"
},
"sync_clock": {
"default": "mdi:clock-check-outline"
},
"reset_cutting_blade_usage_time": {
"default": "mdi:saw-blade"
}
},
"event": {
@@ -64,17 +64,17 @@
"on": "mdi:square"
}
},
"stay_out_zones": {
"default": "mdi:rhombus-outline",
"state": {
"on": "mdi:rhombus"
}
},
"work_area_work_area": {
"default": "mdi:square-outline",
"state": {
"on": "mdi:square"
}
},
"stay_out_zones": {
"default": "mdi:rhombus-outline",
"state": {
"on": "mdi:rhombus"
}
}
}
},

View File

@@ -135,22 +135,22 @@ class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity):
"""Return the work areas of the mower."""
return self.mower_attributes.work_areas
@handle_sending_exception
@handle_sending_exception()
async def async_start_mowing(self) -> None:
"""Resume schedule."""
await self.coordinator.api.commands.resume_schedule(self.mower_id)
@handle_sending_exception
@handle_sending_exception()
async def async_pause(self) -> None:
"""Pauses the mower."""
await self.coordinator.api.commands.pause_mowing(self.mower_id)
@handle_sending_exception
@handle_sending_exception()
async def async_dock(self) -> None:
"""Parks the mower until next schedule."""
await self.coordinator.api.commands.park_until_next_schedule(self.mower_id)
@handle_sending_exception
@handle_sending_exception()
async def async_override_schedule(
self, override_mode: str, duration: timedelta
) -> None:
@@ -160,7 +160,7 @@ class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity):
if override_mode == PARK:
await self.coordinator.api.commands.park_for(self.mower_id, duration)
@handle_sending_exception
@handle_sending_exception()
async def async_override_schedule_work_area(
self, work_area_id: int, duration: timedelta
) -> None:

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.6.0"]
"requirements": ["aioautomower==2.2.1"]
}

View File

@@ -67,7 +67,7 @@ class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity):
"""Return the current option for the entity."""
return cast(HeadlightModes, self.mower_attributes.settings.headlight.mode)
@handle_sending_exception
@handle_sending_exception()
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.coordinator.api.commands.set_headlight_mode(

View File

@@ -123,13 +123,6 @@ def _get_current_work_area_name(data: MowerAttributes) -> str:
return STATE_NO_WORK_AREA_ACTIVE
@callback
def _get_remaining_charging_time(data: MowerAttributes) -> int | None:
if data.battery.remaining_charging_time is not None:
return int(data.battery.remaining_charging_time.total_seconds())
return None
@callback
def _get_current_work_area_dict(data: MowerAttributes) -> Mapping[str, Any]:
"""Return the name of the current work area."""
@@ -326,15 +319,6 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
option_fn=_get_work_area_names,
value_fn=_get_current_work_area_name,
),
AutomowerSensorEntityDescription(
key="remaining_charging_time",
translation_key="remaining_charging_time",
device_class=SensorDeviceClass.DURATION,
value_fn=_get_remaining_charging_time,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
suggested_display_precision=0,
),
)

View File

@@ -1,44 +1,44 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal.",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"no_mower_connected": "No mowers connected to this account.",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"wrong_account": "You can only reauthenticate this entry with the same Husqvarna account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"step": {
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Husqvarna Automower integration needs to re-authenticate your account"
},
"missing_scope": {
"description": "For the best experience with this integration both the `Authentication API` and the `Automower Connect API` should be connected. Please make sure that both of them are connected to your account in the [Husqvarna Developer Portal]({application_url}).",
"title": "Your account is missing some API connections"
"title": "Your account is missing some API connections",
"description": "For the best experience with this integration both the `Authentication API` and the `Automower Connect API` should be connected. Please make sure that both of them are connected to your account in the [Husqvarna Developer Portal]({application_url})."
},
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
"data": {
"implementation": "[%key:common::config_flow::data::implementation%]"
},
"data_description": {
"implementation": "[%key:common::config_flow::description::implementation%]"
},
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"description": "The Husqvarna Automower integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You can only reauthenticate this entry with the same Husqvarna account.",
"no_mower_connected": "No mowers connected to this account.",
"missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"entity": {
@@ -51,11 +51,11 @@
"confirm_error": {
"name": "Confirm error"
},
"reset_cutting_blade_usage_time": {
"name": "Reset cutting blade usage time"
},
"sync_clock": {
"name": "Sync clock"
},
"reset_cutting_blade_usage_time": {
"name": "Reset cutting blade usage time"
}
},
"event": {
@@ -92,10 +92,10 @@
"cutting_drive_motor_2_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_2_defect%]",
"cutting_drive_motor_3_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_3_defect%]",
"cutting_height_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_blocked%]",
"cutting_height_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem%]",
"cutting_height_problem_curr": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_curr%]",
"cutting_height_problem_dir": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_dir%]",
"cutting_height_problem_drive": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_drive%]",
"cutting_height_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem%]",
"cutting_motor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_motor_problem%]",
"cutting_stopped_slope_too_steep": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_stopped_slope_too_steep%]",
"cutting_system_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_blocked%]",
@@ -106,8 +106,8 @@
"docking_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::docking_sensor_defect%]",
"electronic_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::electronic_problem%]",
"empty_battery": "[%key:component::husqvarna_automower::entity::sensor::error::state::empty_battery%]",
"error": "[%key:common::state::error%]",
"error_at_power_up": "[%key:component::husqvarna_automower::entity::sensor::error::state::error_at_power_up%]",
"error": "[%key:common::state::error%]",
"fatal_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::fatal_error%]",
"folding_cutting_deck_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_cutting_deck_sensor_defect%]",
"folding_sensor_activated": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_sensor_activated%]",
@@ -198,13 +198,13 @@
},
"severity": {
"state": {
"debug": "Debug",
"error": "[%key:common::state::error%]",
"fatal": "Fatal",
"error": "[%key:common::state::error%]",
"warning": "Warning",
"info": "Info",
"debug": "Debug",
"sw": "Software",
"unknown": "Unknown",
"warning": "Warning"
"unknown": "Unknown"
}
}
}
@@ -225,20 +225,14 @@
"headlight_mode": {
"name": "Headlight mode",
"state": {
"always_off": "Always off",
"always_on": "Always on",
"evening_and_night": "Evening and night",
"evening_only": "Evening only"
"always_off": "Always off",
"evening_only": "Evening only",
"evening_and_night": "Evening and night"
}
}
},
"sensor": {
"cutting_blade_usage_time": {
"name": "Cutting blade usage time"
},
"downtime": {
"name": "Downtime"
},
"error": {
"name": "Error",
"state": {
@@ -270,10 +264,10 @@
"cutting_drive_motor_2_defect": "Cutting drive motor 2 defect",
"cutting_drive_motor_3_defect": "Cutting drive motor 3 defect",
"cutting_height_blocked": "Cutting height blocked",
"cutting_height_problem": "Cutting height problem",
"cutting_height_problem_curr": "Cutting height problem, curr",
"cutting_height_problem_dir": "Cutting height problem, dir",
"cutting_height_problem_drive": "Cutting height problem, drive",
"cutting_height_problem": "Cutting height problem",
"cutting_motor_problem": "Cutting motor problem",
"cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep",
"cutting_system_blocked": "Cutting system blocked",
@@ -284,8 +278,8 @@
"docking_sensor_defect": "Docking sensor defect",
"electronic_problem": "Electronic problem",
"empty_battery": "Empty battery",
"error": "[%key:common::state::error%]",
"error_at_power_up": "Error at power up",
"error": "[%key:common::state::error%]",
"fatal_error": "Fatal error",
"folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect",
"folding_sensor_activated": "Folding sensor activated",
@@ -382,33 +376,23 @@
"searching_for_satellites": "Searching for satellites"
}
},
"mode": {
"name": "Mode",
"state": {
"demo": "Demo",
"home": "Home",
"main_area": "Main area",
"poi": "Point of interest",
"secondary_area": "Secondary area"
}
},
"my_lawn_last_time_completed": {
"name": "My lawn last time completed"
},
"my_lawn_progress": {
"name": "My lawn progress"
},
"next_start_timestamp": {
"name": "Next start"
},
"number_of_charging_cycles": {
"name": "Number of charging cycles"
},
"number_of_collisions": {
"name": "Number of collisions"
},
"remaining_charging_time": {
"name": "Remaining charging time"
"cutting_blade_usage_time": {
"name": "Cutting blade usage time"
},
"downtime": {
"name": "Downtime"
},
"restricted_reason": {
"name": "Restricted reason",
@@ -423,17 +407,17 @@
"gardena_smart_system": "Gardena Smart System",
"google_assistant": "Google Assistant",
"home_assistant": "Home Assistant",
"ifttt": "IFTTT",
"ifttt_applets": "IFTTT applets",
"ifttt_calendar_connection": "IFTTT calendar connection",
"ifttt": "IFTTT",
"none": "No restrictions",
"not_applicable": "Not applicable",
"park_override": "Park override",
"sensor": "Weather timer",
"smart_routine": "Generic smart routine",
"smart_routine_frost_guard": "Frost guard",
"smart_routine_rain_guard": "Rain guard",
"smart_routine_wildlife_protection": "Wildlife protection",
"smart_routine": "Generic smart routine",
"week_schedule": "Week schedule"
}
},
@@ -443,15 +427,27 @@
"total_cutting_time": {
"name": "Total cutting time"
},
"total_drive_distance": {
"name": "Total drive distance"
},
"total_running_time": {
"name": "Total running time"
},
"total_searching_time": {
"name": "Total searching time"
},
"total_drive_distance": {
"name": "Total drive distance"
},
"next_start_timestamp": {
"name": "Next start"
},
"mode": {
"name": "Mode",
"state": {
"main_area": "Main area",
"secondary_area": "Secondary area",
"home": "Home",
"demo": "Demo"
}
},
"uptime": {
"name": "Uptime"
},
@@ -478,11 +474,11 @@
"enable_schedule": {
"name": "Enable schedule"
},
"my_lawn_work_area": {
"name": "My lawn"
},
"stay_out_zones": {
"name": "Avoid {stay_out_zone}"
},
"my_lawn_work_area": {
"name": "My lawn"
}
}
},
@@ -490,11 +486,11 @@
"command_send_failed": {
"message": "Failed to send command: {exception}"
},
"work_area_not_existing": {
"message": "The selected work area does not exist."
},
"work_areas_not_supported": {
"message": "This mower does not support work areas."
},
"work_area_not_existing": {
"message": "The selected work area does not exist."
}
},
"selector": {
@@ -507,32 +503,32 @@
},
"services": {
"override_schedule": {
"name": "Override schedule",
"description": "Lets the mower either mow or park for a given duration, overriding all schedules.",
"fields": {
"duration": {
"description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored.",
"name": "Duration"
"name": "Duration",
"description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored."
},
"override_mode": {
"description": "With which action the schedule should be overridden.",
"name": "Override mode"
"name": "Override mode",
"description": "With which action the schedule should be overridden."
}
},
"name": "Override schedule"
}
},
"override_schedule_work_area": {
"name": "Override schedule work area",
"description": "Lets the mower mow for a given duration in a specified work area, overriding all schedules.",
"fields": {
"duration": {
"description": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::description%]",
"name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]"
"name": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::name%]",
"description": "[%key:component::husqvarna_automower::services::override_schedule::fields::duration::description%]"
},
"work_area_id": {
"description": "In which work area the mower should mow.",
"name": "Work area ID"
"name": "Work area ID",
"description": "In which work area the mower should mow."
}
},
"name": "Override schedule work area"
}
}
}
}

View File

@@ -108,12 +108,12 @@ class AutomowerScheduleSwitchEntity(AutomowerControlEntity, SwitchEntity):
"""Return the state of the switch."""
return self.mower_attributes.mower.mode != MowerModes.HOME
@handle_sending_exception
@handle_sending_exception()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.coordinator.api.commands.park_until_further_notice(self.mower_id)
@handle_sending_exception
@handle_sending_exception()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.coordinator.api.commands.resume_schedule(self.mower_id)

View File

@@ -18,7 +18,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
from .entity import HuumBaseEntity
@@ -56,12 +55,12 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
@property
def min_temp(self) -> int:
"""Return configured minimal temperature."""
return self.coordinator.data.sauna_config.min_temp or CONFIG_DEFAULT_MIN_TEMP
return self.coordinator.data.sauna_config.min_temp
@property
def max_temp(self) -> int:
"""Return configured maximum temperature."""
return self.coordinator.data.sauna_config.max_temp or CONFIG_DEFAULT_MAX_TEMP
return self.coordinator.data.sauna_config.max_temp
@property
def hvac_mode(self) -> HVACMode:

View File

@@ -9,6 +9,3 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.
CONFIG_STEAMER = 1
CONFIG_LIGHT = 2
CONFIG_STEAMER_AND_LIGHT = 3
CONFIG_DEFAULT_MIN_TEMP = 40
CONFIG_DEFAULT_MAX_TEMP = 110

View File

@@ -1,95 +0,0 @@
"""The iNELS integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from inelsmqtt import InelsMqtt
from inelsmqtt.devices import Device
from inelsmqtt.discovery import InelsDiscovery
from homeassistant.components import mqtt as ha_mqtt
from homeassistant.components.mqtt import (
ReceiveMessage,
async_prepare_subscribe_topics,
async_subscribe_topics,
async_unsubscribe_topics,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .const import LOGGER, PLATFORMS
type InelsConfigEntry = ConfigEntry[InelsData]
@dataclass
class InelsData:
"""Represents the data structure for INELS runtime data."""
mqtt: InelsMqtt
devices: list[Device]
async def async_setup_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
"""Set up iNELS from a config entry."""
async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None:
"""Publish an MQTT message using the Home Assistant MQTT client."""
await ha_mqtt.async_publish(hass, topic, payload, qos, retain)
async def mqtt_subscribe(
sub_state: dict[str, Any] | None,
topic: str,
callback_func: Callable[[str, str], None],
) -> dict[str, Any]:
"""Subscribe to MQTT topics using the Home Assistant MQTT client."""
@callback
def mqtt_message_received(msg: ReceiveMessage) -> None:
"""Handle iNELS mqtt messages."""
# Payload is always str at runtime since we don't set encoding=None
# HA uses UTF-8 by default
callback_func(msg.topic, msg.payload) # type: ignore[arg-type]
topics = {
"inels_subscribe_topic": {
"topic": topic,
"msg_callback": mqtt_message_received,
}
}
sub_state = async_prepare_subscribe_topics(hass, sub_state, topics)
await async_subscribe_topics(hass, sub_state)
return sub_state
async def mqtt_unsubscribe(sub_state: dict[str, Any]) -> None:
async_unsubscribe_topics(hass, sub_state)
if not await ha_mqtt.async_wait_for_mqtt_client(hass):
LOGGER.error("MQTT integration not available")
raise ConfigEntryNotReady("MQTT integration not available")
inels_mqtt = InelsMqtt(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe)
devices: list[Device] = await InelsDiscovery(inels_mqtt).start()
# If no devices are discovered, continue with the setup
if not devices:
LOGGER.info("No devices discovered")
entry.runtime_data = InelsData(mqtt=inels_mqtt, devices=devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.mqtt.unsubscribe_topics()
entry.runtime_data.mqtt.unsubscribe_listeners()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

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