Compare commits

..

3 Commits

Author SHA1 Message Date
mib1185
a7cc4e1282 adjust switch platform 2025-12-12 20:36:17 +00:00
mib1185
c6aed73d2b Merge branch 'dev' into strings/make-trigger_behavior-selector-common 2025-12-12 20:35:33 +00:00
mib1185
c019331de1 make trigger_behavior selector translations common 2025-12-11 17:36:54 +00:00
1431 changed files with 11242 additions and 70622 deletions

View File

@@ -51,9 +51,6 @@ rules:
- **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements
- **Compatibility**: Python 3.13+

View File

@@ -15,7 +15,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2025.12.0"
BASE_IMAGE_VERSION: "2025.11.3"
ARCHITECTURES: '["amd64", "aarch64"]'
jobs:
@@ -70,7 +70,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: translations
path: translations.tar.gz
@@ -100,7 +100,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -111,7 +111,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -169,7 +169,7 @@ jobs:
fi
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: translations
@@ -197,7 +197,7 @@ jobs:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build variables
id: vars
@@ -405,7 +405,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -482,7 +482,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: translations
@@ -551,7 +551,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}

View File

@@ -263,7 +263,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
with:
path: venv
key: &key-pre-commit-venv >-
@@ -304,7 +304,7 @@ jobs:
- &cache-restore-pre-commit-venv
name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-restore actions/cache/restore@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
with:
path: venv
fail-on-cache-miss: true
@@ -511,7 +511,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-save actions/cache/save@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -534,7 +534,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -864,7 +864,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: pytest_buckets
- &compile-english-translations

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
category: "/language:python"

View File

@@ -10,7 +10,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"

View File

@@ -74,7 +74,7 @@ jobs:
) > .env_file
- name: Upload env_file
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: env_file
path: ./.env_file
@@ -119,7 +119,7 @@ jobs:
- &download-env-file
name: Download env_file
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: env_file

1
.gitignore vendored
View File

@@ -92,7 +92,6 @@ pip-selfcheck.json
venv
.venv
Pipfile*
uv.lock
share/*
/Scripts/

View File

@@ -567,7 +567,6 @@ homeassistant.components.wake_word.*
homeassistant.components.wallbox.*
homeassistant.components.waqi.*
homeassistant.components.water_heater.*
homeassistant.components.watts.*
homeassistant.components.watttime.*
homeassistant.components.weather.*
homeassistant.components.webhook.*

27
CODEOWNERS generated
View File

@@ -220,8 +220,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
/homeassistant/components/blebox/ @bbx-a @swistakm
/tests/components/blebox/ @bbx-a @swistakm
/homeassistant/components/blink/ @fronzbot
/tests/components/blink/ @fronzbot
/homeassistant/components/blink/ @fronzbot @mkmer
/tests/components/blink/ @fronzbot @mkmer
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/homeassistant/components/bluemaestro/ @bdraco
@@ -308,8 +308,8 @@ build.json @home-assistant/supervisor
/tests/components/config/ @home-assistant/core
/homeassistant/components/configurator/ @home-assistant/core
/tests/components/configurator/ @home-assistant/core
/homeassistant/components/control4/ @lawtancool @davidrecordon
/tests/components/control4/ @lawtancool @davidrecordon
/homeassistant/components/control4/ @lawtancool
/tests/components/control4/ @lawtancool
/homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/cookidoo/ @miaucl
@@ -516,8 +516,6 @@ build.json @home-assistant/supervisor
/tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP
/tests/components/firmata/ @DaAwesomeP
/homeassistant/components/fish_audio/ @noambav
/tests/components/fish_audio/ @noambav
/homeassistant/components/fitbit/ @allenporter
/tests/components/fitbit/ @allenporter
/homeassistant/components/fivem/ @Sander0542
@@ -532,8 +530,6 @@ build.json @home-assistant/supervisor
/tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
@@ -668,8 +664,7 @@ build.json @home-assistant/supervisor
/tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger
/tests/components/here_travel_time/ @eifinger
/homeassistant/components/hikvision/ @mezz64 @ptarjan
/tests/components/hikvision/ @mezz64 @ptarjan
/homeassistant/components/hikvision/ @mezz64
/homeassistant/components/hikvisioncam/ @fbradyirl
/homeassistant/components/hisense_aehw4a1/ @bannhead
/tests/components/hisense_aehw4a1/ @bannhead
@@ -798,8 +793,6 @@ build.json @home-assistant/supervisor
/tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intent_script/ @arturpragacz
/tests/components/intent_script/ @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs
@@ -1201,8 +1194,8 @@ build.json @home-assistant/supervisor
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera
/tests/components/overseerr/ @joostlek @AmGarera
/homeassistant/components/overseerr/ @joostlek
/tests/components/overseerr/ @joostlek
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -1699,8 +1692,8 @@ build.json @home-assistant/supervisor
/tests/components/trafikverket_train/ @gjohansson-ST
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/homeassistant/components/transmission/ @engrbm87 @JPHutchins
/tests/components/transmission/ @engrbm87 @JPHutchins
/homeassistant/components/trend/ @jpbede
/tests/components/trend/ @jpbede
/homeassistant/components/triggercmd/ @rvmey
@@ -1804,8 +1797,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai
/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/homeassistant/components/watttime/ @bachya
/tests/components/watttime/ @bachya
/homeassistant/components/waze_travel_time/ @eifinger

4
Dockerfile generated
View File

@@ -24,13 +24,13 @@ ENV \
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc
RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv==0.9.17
&& pip3 install uv==0.9.6
WORKDIR /usr/src

View File

@@ -402,8 +402,6 @@ class AuthManager:
if user.is_owner:
raise ValueError("Unable to deactivate the owner")
await self._store.async_deactivate_user(user)
for refresh_token in list(user.refresh_tokens.values()):
self.async_remove_refresh_token(refresh_token)
async def async_remove_credentials(self, credentials: models.Credentials) -> None:
"""Remove credentials."""

View File

@@ -624,16 +624,13 @@ async def async_enable_logging(
if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ:
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
def rename_old_file() -> None:
"""Rename old log file in executor."""
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
await hass.async_add_executor_job(rename_old_file)
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
err_log_path = None
else:
err_log_path = default_log_path

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"requirements": ["accuweather==5.0.0"]
"requirements": ["accuweather==4.2.2"]
}

View File

@@ -9,16 +9,15 @@ from actron_neo_api import (
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import _LOGGER, DOMAIN
from .const import _LOGGER
from .coordinator import (
ActronAirConfigEntry,
ActronAirRuntimeData,
ActronAirSystemCoordinator,
)
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH]
PLATFORM = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
@@ -30,13 +29,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
try:
systems = await api.get_ac_systems()
await api.update_status()
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except ActronAirAuthError:
_LOGGER.error("Authentication error while setting up Actron Air integration")
raise
except ActronAirAPIError as err:
raise ConfigEntryNotReady from err
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
raise
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
@@ -50,10 +48,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
system_coordinators=system_coordinators,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
return await hass.config_entries.async_unload_platforms(entry, PLATFORM)

View File

@@ -15,10 +15,12 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
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 ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity
PARALLEL_UPDATES = 0
@@ -54,7 +56,8 @@ async def async_setup_entry(
for coordinator in system_coordinators.values():
status = coordinator.data
entities.append(ActronSystemClimate(coordinator))
name = status.ac_system.system_name
entities.append(ActronSystemClimate(coordinator, name))
entities.extend(
ActronZoneClimate(coordinator, zone)
@@ -65,9 +68,10 @@ async def async_setup_entry(
async_add_entities(entities)
class ActronAirClimateEntity(ClimateEntity):
class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity):
"""Base class for Actron Air climate entities."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
@@ -79,17 +83,43 @@ class ActronAirClimateEntity(ClimateEntity):
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
class ActronSystemClimate(BaseClimateEntity):
"""Representation of the Actron Air system."""
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator)
self._attr_unique_id = self._serial_number
super().__init__(coordinator, name)
serial_number = coordinator.serial_number
self._attr_unique_id = serial_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=self._status.ac_system.system_name,
manufacturer="Actron Air",
model_id=self._status.ac_system.master_wc_model,
sw_version=self._status.ac_system.master_wc_firmware_version,
serial_number=serial_number,
)
@property
def min_temp(self) -> float:
@@ -118,7 +148,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
fan_mode = self._status.user_aircon_settings.base_fan_mode
fan_mode = self._status.user_aircon_settings.fan_mode
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
@property
@@ -138,7 +168,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower())
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
@@ -152,7 +182,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
await self._status.user_aircon_settings.set_temperature(temperature=temp)
class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
class ActronZoneClimate(BaseClimateEntity):
"""Representation of a zone within the Actron Air system."""
_attr_supported_features = (
@@ -167,8 +197,18 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
zone: ActronAirZone,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator, zone)
self._attr_unique_id: str = self._zone_identifier
super().__init__(coordinator, zone.title)
serial_number = coordinator.serial_number
self._zone_id: int = zone.zone_id
self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}"
self._attr_device_info: DeviceInfo = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, serial_number),
)
@property
def min_temp(self) -> float:
@@ -216,4 +256,4 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature."""
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))
await self._zone.set_temperature(temperature=kwargs["temperature"])

View File

@@ -1,12 +1,11 @@
"""Setup config flow for Actron Air integration."""
import asyncio
from collections.abc import Mapping
from typing import Any
from actron_neo_api import ActronAirAPI, ActronAirAuthError
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
@@ -96,16 +95,8 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
# Check if this is a reauth flow
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_TOKEN: self._api.refresh_token_value},
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
@@ -123,21 +114,6 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
del self.login_task
return await self.async_step_user()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication request."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is not None:
return await self.async_step_user()
return self.async_show_form(step_id="reauth_confirm")
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -5,24 +5,16 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
ActronAirStatus,
)
from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN
from .const import _LOGGER
SCAN_INTERVAL = timedelta(seconds=30)
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
ERROR_UNKNOWN = "unknown_error"
@@ -37,6 +29,9 @@ class ActronAirRuntimeData:
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
AUTH_ERROR_THRESHOLD = 3
SCAN_INTERVAL = timedelta(seconds=30)
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
"""System coordinator for Actron Air integration."""
@@ -64,20 +59,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
async def _async_update_data(self) -> ActronAirStatus:
"""Fetch updates and merge incremental changes into the full state."""
try:
await self.api.update_status()
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except ActronAirAPIError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": repr(err)},
) from err
await self.api.update_status()
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status

View File

@@ -1,63 +0,0 @@
"""Base entity classes for Actron Air integration."""
from actron_neo_api import ActronAirZone
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirSystemCoordinator
class ActronAirEntity(CoordinatorEntity[ActronAirSystemCoordinator]):
"""Base class for Actron Air entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
@property
def available(self) -> bool:
"""Return True if entity is available."""
return not self.coordinator.is_device_stale()
class ActronAirAcEntity(ActronAirEntity):
"""Base class for Actron Air entities."""
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._serial_number)},
name=coordinator.data.ac_system.system_name,
manufacturer="Actron Air",
model_id=coordinator.data.ac_system.master_wc_model,
sw_version=coordinator.data.ac_system.master_wc_firmware_version,
serial_number=self._serial_number,
)
class ActronAirZoneEntity(ActronAirEntity):
"""Base class for Actron Air zone entities."""
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
zone: ActronAirZone,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._zone_id: int = zone.zone_id
self._zone_identifier = f"{self._serial_number}_zone_{zone.zone_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._zone_identifier)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, self._serial_number),
)

View File

@@ -1,30 +0,0 @@
{
"entity": {
"switch": {
"away_mode": {
"default": "mdi:home-export-outline",
"state": {
"off": "mdi:home-import-outline"
}
},
"continuous_fan": {
"default": "mdi:fan",
"state": {
"off": "mdi:fan-off"
}
},
"quiet_mode": {
"default": "mdi:volume-low",
"state": {
"off": "mdi:volume-high"
}
},
"turbo_mode": {
"default": "mdi:fan-plus",
"state": {
"off": "mdi:fan"
}
}
}
}
}

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["actron-neo-api==0.4.1"]
"requirements": ["actron-neo-api==0.1.87"]
}

View File

@@ -36,7 +36,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: todo
# Gold

View File

@@ -2,12 +2,10 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"oauth2_error": "Failed to start authentication flow",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured."
"oauth2_error": "Failed to start OAuth2 flow"
},
"error": {
"oauth2_error": "Failed to start authentication flow. Please try again later."
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
},
"progress": {
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
@@ -18,42 +16,14 @@
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
"title": "Connection error"
},
"reauth_confirm": {
"description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.",
"title": "Authentication expired"
},
"timeout": {
"data": {},
"description": "The authentication process timed out. Please try again.",
"title": "Authentication timeout"
"description": "The authorization process timed out. Please try again.",
"title": "Authorization timeout"
},
"user": {
"title": "Actron Air Authentication"
"title": "Actron Air OAuth2 Authorization"
}
}
},
"entity": {
"switch": {
"away_mode": {
"name": "Away mode"
},
"continuous_fan": {
"name": "Continuous fan"
},
"quiet_mode": {
"name": "Quiet mode"
},
"turbo_mode": {
"name": "Turbo mode"
}
}
},
"exceptions": {
"auth_error": {
"message": "Authentication failed, please reauthenticate"
},
"update_error": {
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
}
}
}

View File

@@ -1,102 +0,0 @@
"""Switch platform for Actron Air integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ActronAirSwitchEntityDescription(SwitchEntityDescription):
"""Class describing Actron Air switch entities."""
is_on_fn: Callable[[ActronAirSystemCoordinator], bool]
set_fn: Callable[[ActronAirSystemCoordinator, bool], Awaitable[None]]
is_supported_fn: Callable[[ActronAirSystemCoordinator], bool] = lambda _: True
SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = (
ActronAirSwitchEntityDescription(
key="away_mode",
translation_key="away_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="continuous_fan",
translation_key="continuous_fan",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="quiet_mode",
translation_key="quiet_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="turbo_mode",
translation_key="turbo_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled),
is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air switch entities."""
system_coordinators = entry.runtime_data.system_coordinators
async_add_entities(
ActronAirSwitch(coordinator, description)
for coordinator in system_coordinators.values()
for description in SWITCHES
if description.is_supported_fn(coordinator)
)
class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
"""Actron Air switch."""
_attr_entity_category = EntityCategory.CONFIG
entity_description: ActronAirSwitchEntityDescription
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
description: ActronAirSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/adax",
"iot_class": "local_polling",
"loggers": ["adax", "adax_local"],
"requirements": ["adax==0.4.0", "Adax-local==0.3.0"]
"requirements": ["adax==0.4.0", "Adax-local==0.2.0"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Bre77"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/advantage_air",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["advantage_air"],
"requirements": ["advantage-air==0.4.4"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Noltari"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aemet",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.6.4"]

View File

@@ -4,7 +4,6 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aftership",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["pyaftership==21.11.0"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@ispysoftware"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/agent_dvr",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["agent"],
"requirements": ["agent-py==0.0.24"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@asymworks"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airnow",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyairnow"],
"requirements": ["pyairnow==1.3.1"]

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:

View File

@@ -175,42 +175,6 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Verify the device ID matches the existing config entry
await self.async_set_unique_id(info.device_id)
self._abort_if_unique_id_mismatch(reason="wrong_device")
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates=user_input,
title=info.title,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, reconfigure_entry.data
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:

View File

@@ -1,9 +0,0 @@
{
"entity": {
"number": {
"hysteresis_band": {
"default": "mdi:delta"
}
}
}
}

View File

@@ -1,99 +0,0 @@
"""Number platform for Airobot thermostat."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pyairobotrest.const import HYSTERESIS_BAND_MAX, HYSTERESIS_BAND_MIN
from pyairobotrest.exceptions import AirobotError
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirobotConfigEntry
from .const import DOMAIN
from .coordinator import AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotNumberEntityDescription(NumberEntityDescription):
"""Describes Airobot number entity."""
value_fn: Callable[[AirobotDataUpdateCoordinator], float]
set_value_fn: Callable[[AirobotDataUpdateCoordinator, float], Awaitable[None]]
NUMBERS: tuple[AirobotNumberEntityDescription, ...] = (
AirobotNumberEntityDescription(
key="hysteresis_band",
translation_key="hysteresis_band",
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
native_min_value=HYSTERESIS_BAND_MIN / 10.0,
native_max_value=HYSTERESIS_BAND_MAX / 10.0,
native_step=0.1,
value_fn=lambda coordinator: coordinator.data.settings.hysteresis_band,
set_value_fn=lambda coordinator, value: coordinator.client.set_hysteresis_band(
value
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot number platform."""
coordinator = entry.runtime_data
async_add_entities(
AirobotNumber(coordinator, description) for description in NUMBERS
)
class AirobotNumber(AirobotEntity, NumberEntity):
"""Representation of an Airobot number entity."""
entity_description: AirobotNumberEntityDescription
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
description: AirobotNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def native_value(self) -> float:
"""Return the current value."""
return self.entity_description.value_fn(self.coordinator)
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
try:
await self.entity_description.set_value_fn(self.coordinator, value)
except AirobotError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="set_value_failed",
translation_placeholders={"error": str(err)},
) from err
else:
await self.coordinator.async_request_refresh()

View File

@@ -48,7 +48,7 @@ rules:
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Single device integration, no dynamic device discovery needed.
@@ -57,8 +57,8 @@ rules:
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration doesn't have any cases where raising an issue is needed.

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from pyairobotrest.models import ThermostatStatus
@@ -24,8 +23,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from . import AirobotConfigEntry
from .entity import AirobotEntity
@@ -37,15 +34,10 @@ PARALLEL_UPDATES = 0
class AirobotSensorEntityDescription(SensorEntityDescription):
"""Describes Airobot sensor entity."""
value_fn: Callable[[ThermostatStatus], StateType | datetime]
value_fn: Callable[[ThermostatStatus], StateType]
supported_fn: Callable[[ThermostatStatus], bool] = lambda _: True
uptime_to_stable_datetime = ignore_variance(
lambda value: utcnow().replace(microsecond=0) - timedelta(seconds=value),
timedelta(minutes=2),
)
SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
AirobotSensorEntityDescription(
key="air_temperature",
@@ -104,14 +96,6 @@ SENSOR_TYPES: tuple[AirobotSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: status.errors,
),
AirobotSensorEntityDescription(
key="device_uptime",
translation_key="device_uptime",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda status: uptime_to_stable_datetime(status.device_uptime),
entity_registry_enabled_default=False,
),
)
@@ -145,6 +129,6 @@ class AirobotSensor(AirobotEntity, SensorEntity):
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def native_value(self) -> StateType | datetime:
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data.status)

View File

@@ -2,9 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_device": "Device ID does not match the existing configuration. Please use the correct device credentials."
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -30,19 +28,6 @@
},
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "Device ID"
},
"data_description": {
"host": "[%key:component::airobot::config::step::user::data_description::host%]",
"password": "[%key:component::airobot::config::step::user::data_description::password%]",
"username": "[%key:component::airobot::config::step::user::data_description::username%]"
},
"description": "Update your Airobot thermostat connection details. Note: The Device ID must remain the same as the original configuration."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -59,11 +44,6 @@
}
},
"entity": {
"number": {
"hysteresis_band": {
"name": "Hysteresis band"
}
},
"sensor": {
"air_temperature": {
"name": "Air temperature"
@@ -94,9 +74,6 @@
},
"set_temperature_failed": {
"message": "Failed to set temperature to {temperature}."
},
"set_value_failed": {
"message": "Failed to set value: {error}"
}
}
}

View File

@@ -88,11 +88,21 @@ class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
super().__init__(coordinator, unit_id)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data."""
return self.device_data.get("climate") or {}
@property
def params(self) -> dict[str, Any]:
"""Return the current parameters for the climate entity."""
return self.climate_data.get("ParametersData") or {}
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and bool(self.climate_data)
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""

View File

@@ -10,7 +10,7 @@ from homeassistant.const import Platform
DOMAIN = "airpatrol"
LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
PLATFORMS = [Platform.CLIMATE]
SCAN_INTERVAL = timedelta(minutes=1)
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)

View File

@@ -38,17 +38,7 @@ class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
"""Return the device data."""
return self.coordinator.data[self._unit_id]
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data for this unit."""
return self.device_data["climate"]
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self._unit_id in self.coordinator.data
and "climate" in self.device_data
and self.climate_data is not None
)
return super().available and self._unit_id in self.coordinator.data

View File

@@ -1,89 +0,0 @@
"""Sensors for AirPatrol integration."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirPatrolConfigEntry
from .coordinator import AirPatrolDataUpdateCoordinator
from .entity import AirPatrolEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirPatrolSensorEntityDescription(SensorEntityDescription):
"""Describes AirPatrol sensor entity."""
data_field: str
SENSOR_DESCRIPTIONS = (
AirPatrolSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
data_field="RoomTemp",
),
AirPatrolSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
data_field="RoomHumidity",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirPatrolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirPatrol sensors."""
coordinator = config_entry.runtime_data
units = coordinator.data
async_add_entities(
AirPatrolSensor(coordinator, unit_id, description)
for unit_id, unit in units.items()
for description in SENSOR_DESCRIPTIONS
if "climate" in unit and unit["climate"] is not None
)
class AirPatrolSensor(AirPatrolEntity, SensorEntity):
"""AirPatrol sensor entity."""
entity_description: AirPatrolSensorEntityDescription
def __init__(
self,
coordinator: AirPatrolDataUpdateCoordinator,
unit_id: str,
description: AirPatrolSensorEntityDescription,
) -> None:
"""Initialize AirPatrol sensor."""
super().__init__(coordinator, unit_id)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}-{unit_id}-{description.key}"
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
if value := self.climate_data.get(self.entity_description.data_field):
return float(value)
return None

View File

@@ -17,7 +17,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/airthings",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["airthings"],
"requirements": ["airthings-cloud==0.2.0"]

View File

@@ -27,7 +27,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["airthings-ble==1.2.0"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@samsinnamon"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airtouch4",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["airtouch4pyapi"],
"requirements": ["airtouch4pyapi==1.0.5"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@danzel"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["airtouch5py"],
"requirements": ["airtouch5py==0.3.0"]

View File

@@ -9,7 +9,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/airzone",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==1.0.4"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Noltari"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.7.2"]

View File

@@ -78,9 +78,9 @@
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},

View File

@@ -4,10 +4,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityStateTriggerBase,
Trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
make_conditional_entity_state_trigger,
make_entity_state_trigger,
)
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
@@ -21,7 +21,7 @@ def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool
return False
class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
"""Trigger for entity state changes."""
_required_features: int
@@ -38,21 +38,21 @@ class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityTargetStateTriggerBase]:
) -> type[EntityStateTriggerBase]:
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""
_domain = domain
_to_states = {to_state}
_to_state = to_state
_required_features = required_features
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"armed": make_entity_transition_trigger(
"armed": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
AlarmControlPanelState.ARMING,
@@ -89,12 +89,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"disarmed": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.DISARMED
),
"triggered": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.TRIGGERED
),
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@madpilot"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/amberelectric",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["amberelectric"],
"requirements": ["amberelectric==2.0.12"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@engrbm87"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pydroid-ipcam==3.0.0"]
}

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
from aiohttp import CookieJar
from pyanglianwater import AnglianWater
@@ -30,11 +30,14 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Required(CONF_ACCOUNT_NUMBER): selector.TextSelector(),
}
)
async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
async def validate_credentials(
auth: MSOB2CAuth, account_number: str
) -> str | MSOB2CAuth:
"""Validate the provided credentials."""
try:
await auth.send_login_request()
@@ -43,33 +46,6 @@ async def validate_credentials(auth: MSOB2CAuth) -> str | MSOB2CAuth:
except Exception:
_LOGGER.exception("Unexpected exception")
return "unknown"
return auth
def humanize_account_data(account: dict) -> str:
"""Convert an account data into a human-readable format."""
if account["address"]["company_name"] != "":
return f"{account['account_number']} - {account['address']['company_name']}"
if account["address"]["building_name"] != "":
return f"{account['account_number']} - {account['address']['building_name']}"
return f"{account['account_number']} - {account['address']['postcode']}"
async def get_accounts(auth: MSOB2CAuth) -> list[selector.SelectOptionDict]:
"""Retrieve the list of accounts associated with the authenticated user."""
_aw = AnglianWater(authenticator=auth)
accounts = await _aw.api.get_associated_accounts()
return [
selector.SelectOptionDict(
value=str(account["account_number"]),
label=humanize_account_data(account),
)
for account in accounts["result"]["active"]
]
async def validate_account(auth: MSOB2CAuth, account_number: str) -> str | MSOB2CAuth:
"""Validate the provided account number."""
_aw = AnglianWater(authenticator=auth)
try:
await _aw.validate_smart_meter(account_number)
@@ -81,91 +57,36 @@ async def validate_account(auth: MSOB2CAuth, account_number: str) -> str | MSOB2
class AnglianWaterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anglian Water."""
def __init__(self) -> None:
"""Initialize the config flow."""
self.authenticator: MSOB2CAuth | None = None
self.accounts: list[selector.SelectOptionDict] = []
self.user_input: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self.authenticator = MSOB2CAuth(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=async_create_clientsession(
self.hass,
cookie_jar=CookieJar(quote_cookie=False),
validation_response = await validate_credentials(
MSOB2CAuth(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
session=async_create_clientsession(
self.hass,
cookie_jar=CookieJar(quote_cookie=False),
),
),
user_input[CONF_ACCOUNT_NUMBER],
)
validation_response = await validate_credentials(self.authenticator)
if isinstance(validation_response, str):
errors["base"] = validation_response
else:
self.accounts = await get_accounts(self.authenticator)
if len(self.accounts) > 1:
self.user_input = user_input
return await self.async_step_select_account()
account_number = self.accounts[0]["value"]
self.user_input = user_input
return await self.async_step_complete(
{
CONF_ACCOUNT_NUMBER: account_number,
}
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_ACCOUNT_NUMBER],
data={
**user_input,
CONF_ACCESS_TOKEN: validation_response.refresh_token,
},
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_select_account(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the account selection step."""
errors = {}
if user_input is not None:
if TYPE_CHECKING:
assert self.authenticator
validation_result = await validate_account(
self.authenticator,
user_input[CONF_ACCOUNT_NUMBER],
)
if isinstance(validation_result, str):
errors["base"] = validation_result
else:
return await self.async_step_complete(user_input)
return self.async_show_form(
step_id="select_account",
data_schema=vol.Schema(
{
vol.Required(CONF_ACCOUNT_NUMBER): selector.SelectSelector(
selector.SelectSelectorConfig(
options=self.accounts,
multiple=False,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
}
),
errors=errors,
)
async def async_step_complete(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle the final configuration step."""
await self.async_set_unique_id(user_input[CONF_ACCOUNT_NUMBER])
self._abort_if_unique_id_configured()
if TYPE_CHECKING:
assert self.authenticator
assert self.user_input
config_entry_data = {
**self.user_input,
CONF_ACCOUNT_NUMBER: user_input[CONF_ACCOUNT_NUMBER],
CONF_ACCESS_TOKEN: self.authenticator.refresh_token,
}
return self.async_create_entry(
title=user_input[CONF_ACCOUNT_NUMBER],
data=config_entry_data,
)

View File

@@ -4,28 +4,13 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pyanglianwater import AnglianWater
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
statistics_during_period,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import VolumeConverter
from .const import CONF_ACCOUNT_NUMBER, DOMAIN
@@ -59,107 +44,6 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None:
"""Update data from Anglian Water's API."""
try:
await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
await self._insert_statistics()
return await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
except (ExpiredAccessTokenError, UnknownEndpointError) as err:
raise UpdateFailed from err
async def _insert_statistics(self) -> None:
"""Insert statistics for water meters into Home Assistant."""
for meter in self.api.meters.values():
id_prefix = (
f"{self.config_entry.data[CONF_ACCOUNT_NUMBER]}_{meter.serial_number}"
)
usage_statistic_id = f"{DOMAIN}:{id_prefix}_usage".lower()
_LOGGER.debug("Updating statistics for meter %s", meter.serial_number)
name_prefix = (
f"Anglian Water {self.config_entry.data[CONF_ACCOUNT_NUMBER]} "
f"{meter.serial_number}"
)
usage_metadata = StatisticMetaData(
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} Usage",
source=DOMAIN,
statistic_id=usage_statistic_id,
unit_class=VolumeConverter.UNIT_CLASS,
unit_of_measurement=UnitOfVolume.CUBIC_METERS,
)
last_stat = await get_instance(self.hass).async_add_executor_job(
get_last_statistics, self.hass, 1, usage_statistic_id, True, set()
)
if not last_stat:
_LOGGER.debug("Updating statistics for the first time")
usage_sum = 0.0
last_stats_time = None
else:
if not meter.readings or len(meter.readings) == 0:
_LOGGER.debug("No recent usage statistics found, skipping update")
continue
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping update",
meter.readings[0]["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
_LOGGER.debug("Getting statistics at %s", start)
for end in (start + timedelta(seconds=1), None):
stats = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
self.hass,
start,
end,
{
usage_statistic_id,
},
"hour",
None,
{"sum"},
)
if stats:
break
if end:
_LOGGER.debug(
"Not found, trying to find oldest statistic after %s",
start,
)
assert stats
def _safe_get_sum(records: list[Any]) -> float:
if records and "sum" in records[0]:
return float(records[0]["sum"])
return 0.0
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
last_stats_time = stats[usage_statistic_id][0]["start"]
usage_statistics = []
for read in meter.readings:
parsed_read_at = dt_util.parse_datetime(read["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping reading",
read["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
if last_stats_time is not None and start.timestamp() <= last_stats_time:
continue
usage_state = max(0, read["consumption"] / 1000)
usage_sum = max(0, read["read"])
usage_statistics.append(
StatisticData(
start=start,
state=usage_state,
sum=usage_sum,
)
)
_LOGGER.debug(
"Adding %s statistics for %s", len(usage_statistics), usage_statistic_id
)
async_add_external_statistics(self.hass, usage_metadata, usage_statistics)

View File

@@ -1,13 +1,11 @@
{
"domain": "anglian_water",
"name": "Anglian Water",
"after_dependencies": ["recorder"],
"codeowners": ["@pantherale0"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyanglianwater"],
"quality_scale": "bronze",
"requirements": ["pyanglianwater==3.1.0"]
"requirements": ["pyanglianwater==3.0.0"]
}

View File

@@ -10,21 +10,14 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"select_account": {
"data": {
"account_number": "Billing account number"
},
"data_description": {
"account_number": "Select the billing account you wish to use."
},
"description": "Multiple active billing accounts were found with your credentials. Please select the account you wish to use. If this is unexpected, contact Anglian Water to confirm your active accounts."
},
"user": {
"data": {
"account_number": "Billing Account Number",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"account_number": "Your account number found on your latest bill.",
"password": "Your password",
"username": "Username or email used to log in to the Anglian Water website."
},

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Lash-L"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anova",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.17.0"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@hyralex"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anthemav",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["anthemav"],
"requirements": ["anthemav==1.4.1"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@bdr99"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.15"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@yuxincs"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["apcaccess"],
"quality_scale": "platinum",

View File

@@ -4,7 +4,6 @@
"codeowners": ["@elupus"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["arcam"],
"requirements": ["arcam-fmj==1.8.2"],

View File

@@ -4,7 +4,6 @@
"codeowners": ["@ikalnyi"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/arve",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["asyncarve==0.1.1"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@milanmeu"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aseko_pool_live",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioaseko"],
"requirements": ["aioaseko==1.0.0"]

View File

@@ -23,9 +23,9 @@
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},

View File

@@ -1,22 +1,16 @@
"""Provides triggers for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from .const import DOMAIN
from .entity import AssistSatelliteState
TRIGGERS: dict[str, type[Trigger]] = {
"idle": make_entity_target_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.LISTENING
),
"processing": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.PROCESSING
),
"responding": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.RESPONDING
),
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
"requirements": ["aioasuswrt==1.5.4", "asusrouter==1.21.3"]
"requirements": ["aioasuswrt==1.5.2", "asusrouter==1.21.3"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@MatsNL"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/atag",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyatag"],
"requirements": ["pyatag==0.3.5.3"]

View File

@@ -27,8 +27,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/august",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@djtimca"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aurora",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["auroranoaa"],
"requirements": ["auroranoaa==0.0.5"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@nickw444", "@Bre77"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aussie_broadband",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aussiebb"],
"requirements": ["pyaussiebb==0.1.5"]

View File

@@ -4,8 +4,6 @@
"codeowners": ["@klaasnicolaas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/autarco",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["autarco==3.2.0"]
}

View File

@@ -27,7 +27,6 @@ from homeassistant.const import (
CONF_EVENT_DATA,
CONF_ID,
CONF_MODE,
CONF_OPTIONS,
CONF_PATH,
CONF_PLATFORM,
CONF_TRIGGERS,
@@ -126,21 +125,14 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"binary_sensor",
"button",
"climate",
"cover",
"device_tracker",
"fan",
"humidifier",
"lawn_mower",
"light",
"lock",
"media_player",
"scene",
"siren",
"switch",
"text",
"update",
"vacuum",
}
@@ -1219,7 +1211,7 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return]
if trigger_conf[CONF_PLATFORM] == "calendar":
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
return [trigger_conf[CONF_ENTITY_ID]]
if trigger_conf[CONF_PLATFORM] == "zone":
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]

View File

@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==66"],
"requirements": ["axis==65"],
"ssdp": [
{
"manufacturer": "AXIS"

View File

@@ -80,7 +80,7 @@ class AzureDataExplorerClient:
def test_connection(self) -> None:
"""Test connection, will throw Exception if it cannot connect."""
query = f"['{self._table}'] | take 1"
query = f"{self._table} | take 1"
self.query_client.execute_query(self._database, query)

View File

@@ -45,7 +45,7 @@ class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def validate_input(self, data: dict[str, Any]) -> dict[str, str]:
async def validate_input(self, data: dict[str, Any]) -> dict[str, Any] | None:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
@@ -54,40 +54,36 @@ class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
try:
await self.hass.async_add_executor_job(client.test_connection)
except KustoAuthenticationError as err:
_LOGGER.error("Authentication failed: %s", err)
except KustoAuthenticationError as exp:
_LOGGER.error(exp)
return {"base": "invalid_auth"}
except KustoServiceError as err:
_LOGGER.error("Could not connect: %s", err)
except KustoServiceError as exp:
_LOGGER.error(exp)
return {"base": "cannot_connect"}
return {}
return None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
data_schema = STEP_USER_DATA_SCHEMA
if user_input is not None:
errors = await self.validate_input(user_input)
errors: dict = {}
if user_input:
errors = await self.validate_input(user_input) # type: ignore[assignment]
if not errors:
return self.async_create_entry(
data=user_input,
title=f"{user_input[CONF_ADX_CLUSTER_INGEST_URI].replace('https://', '')} / {user_input[CONF_ADX_DATABASE_NAME]} ({user_input[CONF_ADX_TABLE_NAME]})",
title=user_input[CONF_ADX_CLUSTER_INGEST_URI].replace(
"https://", ""
),
options=DEFAULT_OPTIONS,
)
# Keep previously entered values when we re-show the form after an error.
data_schema = self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
last_step=True,
)

View File

@@ -4,7 +4,6 @@
"codeowners": ["@kaareseras"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/azure_data_explorer",
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["azure"],
"requirements": ["azure-kusto-ingest==4.5.1", "azure-kusto-data[aio]==4.5.1"]

View File

@@ -20,7 +20,6 @@
"use_queued_ingestion": "Use queued ingestion"
},
"data_description": {
"authority_id": "In Azure portal this is also known as Directory (tenant) ID",
"cluster_ingest_uri": "Ingestion URI of the cluster",
"use_queued_ingestion": "Must be enabled when using ADX free cluster"
},

View File

@@ -4,7 +4,6 @@
"codeowners": ["@timmo001"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/azure_devops",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aioazuredevops"],
"requirements": ["aioazuredevops==2.2.2"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@eavanvalkenburg"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/azure_event_hub",
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["azure"],
"requirements": ["azure-eventhub==5.11.1"],

View File

@@ -6,15 +6,13 @@ from datetime import timedelta
import logging
from typing import Any
from b2sdk.v2 import Bucket, exception
from b2sdk.v2 import B2Api, Bucket, InMemoryAccountInfo, exception
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
# Import from b2_client to ensure timeout configuration is applied
from .b2_client import B2Api, InMemoryAccountInfo
from .const import (
BACKBLAZE_REALM,
CONF_APPLICATION_KEY,
@@ -74,11 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
translation_domain=DOMAIN,
translation_key="invalid_bucket_name",
) from err
except (
exception.B2ConnectionError,
exception.B2RequestTimeout,
exception.ConnectionReset,
) as err:
except exception.ConnectionReset as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",

View File

@@ -1,39 +0,0 @@
"""Backblaze B2 client with extended timeouts.
The b2sdk library uses class-level timeout attributes. To avoid modifying
global library state, we subclass the relevant classes to provide extended
timeouts suitable for backup operations involving large files.
"""
from b2sdk.v2 import B2Api as BaseB2Api, InMemoryAccountInfo
from b2sdk.v2.b2http import B2Http as BaseB2Http
from b2sdk.v2.session import B2Session as BaseB2Session
# Extended timeouts for Home Assistant backup operations
# Default CONNECTION_TIMEOUT is 46 seconds, which can be too short for slow connections
CONNECTION_TIMEOUT = 120 # 2 minutes
# Default TIMEOUT_FOR_UPLOAD is 128 seconds, which is too short for large backups
TIMEOUT_FOR_UPLOAD = 43200 # 12 hours
class B2Http(BaseB2Http): # type: ignore[misc]
"""B2Http with extended timeouts for backup operations."""
CONNECTION_TIMEOUT = CONNECTION_TIMEOUT
TIMEOUT_FOR_UPLOAD = TIMEOUT_FOR_UPLOAD
class B2Session(BaseB2Session): # type: ignore[misc]
"""B2Session using custom B2Http with extended timeouts."""
B2HTTP_CLASS = B2Http
class B2Api(BaseB2Api): # type: ignore[misc]
"""B2Api using custom session with extended timeouts."""
SESSION_CLASS = B2Session
__all__ = ["B2Api", "InMemoryAccountInfo"]

View File

@@ -6,7 +6,7 @@ from collections.abc import Mapping
import logging
from typing import Any
from b2sdk.v2 import exception
from b2sdk.v2 import B2Api, InMemoryAccountInfo, exception
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
@@ -17,8 +17,6 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
# Import from b2_client to ensure timeout configuration is applied
from .b2_client import B2Api, InMemoryAccountInfo
from .const import (
BACKBLAZE_REALM,
CONF_APPLICATION_KEY,
@@ -174,12 +172,8 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
"Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET]
)
errors[CONF_BUCKET] = "invalid_bucket_name"
except (
exception.B2ConnectionError,
exception.B2RequestTimeout,
exception.ConnectionReset,
) as err:
_LOGGER.error("Failed to connect to Backblaze B2: %s", err)
except exception.ConnectionReset:
_LOGGER.error("Failed to connect to Backblaze B2. Connection reset")
errors["base"] = "cannot_connect"
except exception.MissingAccountData:
# This generally indicates an issue with how InMemoryAccountInfo is used

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["b2sdk"],
"quality_scale": "bronze",
"requirements": ["b2sdk==2.10.1"]
"requirements": ["b2sdk==2.8.1"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@bdraco", "@jfroy"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/baf",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aiobafi6==0.9.0"],
"zeroconf": [

View File

@@ -12,7 +12,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/balboa",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pybalboa"],
"requirements": ["pybalboa==1.1.3"]

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er
from . import BeoConfigEntry
from .const import DOMAIN
from .util import get_device_buttons, get_remote_keys, get_remotes
from .util import get_device_buttons
async def async_get_config_entry_diagnostics(
@@ -53,23 +53,4 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context")
data[f"{device_button}_event"] = state_dict
# Get remotes
for remote in await get_remotes(config_entry.runtime_data.client):
# Get key Event entity states (if enabled)
for key_type in get_remote_keys():
if entity_id := entity_registry.async_get_entity_id(
EVENT_DOMAIN,
DOMAIN,
f"{remote.serial_number}_{config_entry.unique_id}_{key_type}",
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data[f"remote_{remote.serial_number}_{key_type}_event"] = state_dict
# Add remote Mozart model
data[f"remote_{remote.serial_number}"] = dict(remote)
return data

View File

@@ -16,7 +16,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry
from .const import (
BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEY_EVENTS,
BEO_REMOTE_KEYS,
BEO_REMOTE_SUBMENU_CONTROL,
BEO_REMOTE_SUBMENU_LIGHT,
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DOMAIN,
@@ -25,7 +29,7 @@ from .const import (
WebsocketNotification,
)
from .entity import BeoEntity
from .util import get_device_buttons, get_remote_keys, get_remotes
from .util import get_device_buttons, get_remotes
PARALLEL_UPDATES = 0
@@ -36,19 +40,38 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Event entities from config entry."""
entities: list[BeoEvent] = [
entities: list[BeoEvent] = []
async_add_entities(
BeoButtonEvent(config_entry, button_type)
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
]
)
# Check for connected Beoremote One
remotes = await get_remotes(config_entry.runtime_data.client)
for remote in remotes:
# Add Light keys
entities.extend(
[
BeoRemoteKeyEvent(config_entry, remote, key_type)
for key_type in get_remote_keys()
BeoRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
)
for key_type in BEO_REMOTE_KEYS
]
)
# Add Control keys
entities.extend(
[
BeoRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
)
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
]
)

View File

@@ -11,16 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from .const import (
BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEYS,
BEO_REMOTE_SUBMENU_CONTROL,
BEO_REMOTE_SUBMENU_LIGHT,
DEVICE_BUTTONS,
DOMAIN,
BeoButtons,
BeoModel,
)
from .const import DEVICE_BUTTONS, DOMAIN, BeoButtons, BeoModel
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
@@ -73,14 +64,3 @@ def get_device_buttons(model: BeoModel) -> list[str]:
buttons.remove(BeoButtons.BLUETOOTH)
return buttons
def get_remote_keys() -> list[str]:
"""Get remote keys for the Beoremote One. Formatted for Home Assistant use."""
return [
*[f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}" for key_type in BEO_REMOTE_KEYS],
*[
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}"
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
],
]

View File

@@ -324,9 +324,9 @@
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
"any": "[%key:common::selector::trigger_behavior::options::any%]",
"first": "[%key:common::selector::trigger_behavior::options::first%]",
"last": "[%key:common::selector::trigger_behavior::options::last%]"
}
}
},

View File

@@ -4,7 +4,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import DOMAIN, BinarySensorDeviceClass
@@ -20,7 +20,7 @@ def get_device_class_or_undefined(
return UNDEFINED
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None
@@ -47,7 +47,7 @@ def make_binary_sensor_trigger(
"""Trigger for entity state changes."""
_device_class = device_class
_to_states = {to_state}
_to_state = to_state
return CustomTrigger

View File

@@ -4,7 +4,6 @@
"codeowners": ["@bbx-a", "@swistakm"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/blebox",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.0"],

View File

@@ -1,7 +1,7 @@
{
"domain": "blink",
"name": "Blink",
"codeowners": ["@fronzbot"],
"codeowners": ["@fronzbot", "@mkmer"],
"config_flow": true,
"dhcp": [
{
@@ -18,8 +18,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/blink",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["blinkpy"],
"requirements": ["blinkpy==0.25.2"]
"requirements": ["blinkpy==0.25.1"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@gleeuwen", "@NickKoepr", "@jtodorova23"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/blue_current",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["bluecurrent_api"],
"requirements": ["bluecurrent-api==1.3.2"]

View File

@@ -11,7 +11,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bluemaestro",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["bluemaestro-ble==0.4.1"]
}

View File

@@ -2,25 +2,16 @@
from pyblu import Player
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_MASTER,
DOMAIN,
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
SERVICE_UNJOIN,
)
from .const import DOMAIN
from .coordinator import (
BluesoundConfigEntry,
BluesoundCoordinator,
@@ -37,38 +28,6 @@ PLATFORMS = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_TIMER,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_increase_timer",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_CLEAR_TIMER,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_clear_timer",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_JOIN,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_MASTER): cv.entity_id},
func="async_bluesound_join",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_UNJOIN,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="async_bluesound_unjoin",
)
return True

View File

@@ -4,8 +4,3 @@ DOMAIN = "bluesound"
INTEGRATION_TITLE = "Bluesound"
ATTR_BLUESOUND_GROUP = "bluesound_group"
ATTR_MASTER = "master"
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"

View File

@@ -5,7 +5,6 @@
"codeowners": ["@thrawnarn", "@LouisChrist"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyblu==2.0.5"],
"zeroconf": [

View File

@@ -8,6 +8,7 @@ import logging
from typing import TYPE_CHECKING, Any
from pyblu import Input, Player, Preset, Status, SyncStatus
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@@ -21,7 +22,11 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers import (
config_validation as cv,
entity_platform,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
@@ -35,22 +40,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util, slugify
from .const import (
ATTR_BLUESOUND_GROUP,
ATTR_MASTER,
DOMAIN,
SERVICE_CLEAR_TIMER,
SERVICE_JOIN,
SERVICE_SET_TIMER,
SERVICE_UNJOIN,
)
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
from .coordinator import BluesoundCoordinator
from .utils import (
dispatcher_join_signal,
dispatcher_unjoin_signal,
format_unique_id,
id_to_paired_player,
)
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
if TYPE_CHECKING:
from . import BluesoundConfigEntry
@@ -62,6 +54,11 @@ SCAN_INTERVAL = timedelta(minutes=15)
DATA_BLUESOUND = DOMAIN
DEFAULT_PORT = 11000
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
POLL_TIMEOUT = 120
@@ -78,6 +75,18 @@ async def async_setup_entry(
config_entry.runtime_data.player,
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_TIMER, None, "async_increase_timer"
)
platform.async_register_entity_service(
SERVICE_CLEAR_TIMER, None, "async_clear_timer"
)
platform.async_register_entity_service(
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_join"
)
platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
async_add_entities([bluesound_player], update_before_add=True)
@@ -111,7 +120,6 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
self._presets: list[Preset] = coordinator.data.presets
self._group_name: str | None = None
self._group_list: list[str] = []
self._group_members: list[str] | None = None
self._bluesound_device_name = sync_status.name
self._player = player
self._last_status_update = dt_util.utcnow()
@@ -172,7 +180,6 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
self._last_status_update = dt_util.utcnow()
self._group_list = self.rebuild_bluesound_group()
self._group_members = self.rebuild_group_members()
self.async_write_ha_state()
@@ -358,13 +365,11 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.GROUPING
)
supported = (
MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.GROUPING
)
if not self._status.indexing:
@@ -416,57 +421,8 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
return shuffle
@property
def group_members(self) -> list[str] | None:
"""Get list of group members. Leader is always first."""
return self._group_members
async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player."""
if self.entity_id in group_members:
raise ServiceValidationError("Cannot join player to itself")
entity_ids_with_sync_status = self._entity_ids_with_sync_status()
paired_players = []
for group_member in group_members:
sync_status = entity_ids_with_sync_status.get(group_member)
if sync_status is None:
continue
paired_player = id_to_paired_player(sync_status.id)
if paired_player:
paired_players.append(paired_player)
if paired_players:
await self._player.add_followers(paired_players)
async def async_unjoin_player(self) -> None:
"""Remove this player from any group."""
if self._sync_status.leader is not None:
leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}"
async_dispatcher_send(
self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port
)
if self._sync_status.followers is not None:
await self._player.remove_follower(self.host, self.port)
async def async_bluesound_join(self, master: str) -> None:
async def async_join(self, master: str) -> None:
"""Join the player to a group."""
ir.async_create_issue(
self.hass,
DOMAIN,
f"deprecated_service_{SERVICE_JOIN}",
is_fixable=False,
breaks_in_ha_version="2026.7.0",
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_service_join",
translation_placeholders={
"name": slugify(self.sync_status.name),
},
)
if master == self.entity_id:
raise ServiceValidationError("Cannot join player to itself")
@@ -475,23 +431,17 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
self.hass, dispatcher_join_signal(master), self.host, self.port
)
async def async_bluesound_unjoin(self) -> None:
async def async_unjoin(self) -> None:
"""Unjoin the player from a group."""
ir.async_create_issue(
self.hass,
DOMAIN,
f"deprecated_service_{SERVICE_UNJOIN}",
is_fixable=False,
breaks_in_ha_version="2026.7.0",
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_service_unjoin",
translation_placeholders={
"name": slugify(self.sync_status.name),
},
)
if self._sync_status.leader is None:
return
await self.async_unjoin_player()
leader_id = f"{self._sync_status.leader.ip}:{self._sync_status.leader.port}"
_LOGGER.debug("Trying to unjoin player: %s", self.id)
async_dispatcher_send(
self.hass, dispatcher_unjoin_signal(leader_id), self.host, self.port
)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
@@ -538,63 +488,6 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
follower_names.insert(0, leader_sync_status.name)
return follower_names
def rebuild_group_members(self) -> list[str] | None:
"""Get list of group members. Leader is always first."""
if self.sync_status.leader is None and self.sync_status.followers is None:
return None
entity_ids_with_sync_status = self._entity_ids_with_sync_status()
leader_entity_id = None
followers = None
if self.sync_status.followers is not None:
leader_entity_id = self.entity_id
followers = self.sync_status.followers
elif self.sync_status.leader is not None:
leader_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}"
for entity_id, sync_status in entity_ids_with_sync_status.items():
if sync_status.id == leader_id:
leader_entity_id = entity_id
followers = sync_status.followers
break
if leader_entity_id is None or followers is None:
return None
grouped_entity_ids = [leader_entity_id]
for follower in followers:
follower_id = f"{follower.ip}:{follower.port}"
entity_ids = [
entity_id
for entity_id, sync_status in entity_ids_with_sync_status.items()
if sync_status.id == follower_id
]
match entity_ids:
case [entity_id]:
grouped_entity_ids.append(entity_id)
return grouped_entity_ids
def _entity_ids_with_sync_status(self) -> dict[str, SyncStatus]:
result = {}
entity_registry = er.async_get(self.hass)
config_entries: list[BluesoundConfigEntry] = (
self.hass.config_entries.async_entries(DOMAIN)
)
for config_entry in config_entries:
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
)
for entity_entry in entity_entries:
if entity_entry.domain == "media_player":
result[entity_entry.entity_id] = (
config_entry.runtime_data.coordinator.data.sync_status
)
return result
async def async_add_follower(self, host: str, port: int) -> None:
"""Add follower to leader."""
await self._player.add_follower(host, port)

View File

@@ -41,17 +41,9 @@
"description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
"title": "Detected use of deprecated action bluesound.clear_sleep_timer"
},
"deprecated_service_join": {
"description": "Use the `media_player.join` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
"title": "Detected use of deprecated action bluesound.join"
},
"deprecated_service_set_sleep_timer": {
"description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts.",
"title": "Detected use of deprecated action bluesound.set_sleep_timer"
},
"deprecated_service_unjoin": {
"description": "Use the `media_player.unjoin` action instead.\n\nPlease replace this action and adjust your automations and scripts.",
"title": "Detected use of deprecated action bluesound.unjoin"
}
},
"services": {

View File

@@ -1,7 +1,5 @@
"""Utility functions for the Bluesound component."""
from pyblu import PairedPlayer
from homeassistant.helpers.device_registry import format_mac
@@ -21,12 +19,3 @@ def dispatcher_unjoin_signal(leader_id: str) -> str:
Id is ip_address:port. This can be obtained from sync_status.id.
"""
return f"bluesound_unjoin_{leader_id}"
def id_to_paired_player(id: str) -> PairedPlayer | None:
"""Try to convert id in format 'ip:port' to PairedPlayer. Returns None if unable to do so."""
match id.rsplit(":", 1):
case [str() as ip, str() as port] if port.isdigit():
return PairedPlayer(ip, int(port))
case _:
return None

View File

@@ -4,7 +4,6 @@
"codeowners": ["@gerard33", "@rikroe"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
"requirements": ["bimmer-connected[china]==0.17.3"]

View File

@@ -14,7 +14,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/bond",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["bond_async"],
"requirements": ["bond-async==0.2.1"],

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