Compare commits

..

4 Commits

Author SHA1 Message Date
Joostlek ff43e12449 Merge branch 'dev' into electrolux
# Conflicts:
#	requirements_test_all.txt
2026-05-21 14:22:34 +02:00
Joost Lekkerkerker 7dfef5c82a Add icon translations to Electrolux (#170422) 2026-05-13 07:54:58 +02:00
Joostlek b75cd0f6a7 Merge branch 'dev' into electrolux 2026-05-12 16:50:46 +02:00
ferenc-fustos-electrolux 7859aba432 Add electrolux integration (#157176) 2026-05-12 12:40:40 +01:00
510 changed files with 6527 additions and 15250 deletions
-1
View File
@@ -15,7 +15,6 @@ Dockerfile.dev linguist-language=Dockerfile
# Generated files
CODEOWNERS linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
pylint/plugins/pylint_home_assistant/generated/*.py linguist-generated=true
machine/* linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
-1
View File
@@ -25,7 +25,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
+2 -2
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
with:
extra-args: --all-files zizmor
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: "/language:python"
-1
View File
@@ -15,7 +15,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
## Python Syntax Notes
Generated
+4 -2
View File
@@ -466,6 +466,8 @@ CLAUDE.md @home-assistant/core
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
/tests/components/electric_kiwi/ @mikey0000
/homeassistant/components/electrolux/ @electrolux-oss
/tests/components/electrolux/ @electrolux-oss
/homeassistant/components/elevenlabs/ @sorgfresser
/tests/components/elevenlabs/ @sorgfresser
/homeassistant/components/elgato/ @frenck
@@ -1413,8 +1415,8 @@ CLAUDE.md @home-assistant/core
/tests/components/pushover/ @engrbm87
/homeassistant/components/pvoutput/ @frenck
/tests/components/pvoutput/ @frenck
/homeassistant/components/pvpc_hourly_pricing/ @azogue @chiro79
/tests/components/pvpc_hourly_pricing/ @azogue @chiro79
/homeassistant/components/pvpc_hourly_pricing/ @azogue
/tests/components/pvpc_hourly_pricing/ @azogue
/homeassistant/components/pyload/ @tr4nt0r
/tests/components/pyload/ @tr4nt0r
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
+1 -1
View File
@@ -134,7 +134,7 @@ class AuthManagerFlowManager(
"""
flow = cast(LoginFlow, flow)
if result["type"] is not FlowResultType.CREATE_ENTRY:
if result["type"] != FlowResultType.CREATE_ENTRY:
return result
# we got final result
@@ -11,7 +11,7 @@
"service": "mdi:dialpad"
},
"alarm_toggle_chime": {
"service": "mdi:bell-ring"
"service": "mdi:abc"
}
}
}
@@ -91,6 +91,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_placeholders={"error": repr(err)},
) from err
except CannotAuthenticate as err:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
@@ -102,9 +102,6 @@
"entry_not_loaded": {
"message": "Entry not loaded: {entry}"
},
"invalid_auth": {
"message": "Invalid authentication credentials: {error}"
},
"invalid_device_id": {
"message": "Invalid device ID specified: {device_id}"
},
+18 -52
View File
@@ -5,12 +5,8 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import labs, websocket_api
from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.start import async_at_started
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -53,7 +49,6 @@ CONFIG_SCHEMA = vol.Schema(
)
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
LABS_SNAPSHOT_FEATURE = "snapshots"
@@ -62,39 +57,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the analytics integration."""
analytics_config = config.get(DOMAIN, {})
snapshots_url: str | None = None
if CONF_SNAPSHOTS_URL in analytics_config:
await labs.async_update_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
)
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
else:
snapshots_url = None
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Analytics from a config entry."""
snapshots_url = hass.data.get(_DATA_SNAPSHOTS_URL)
analytics = Analytics(hass, snapshots_url)
try:
await analytics.load()
except HassioNotReadyError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="supervisor_not_ready",
) from err
# Load stored data
await analytics.load()
started = False
@@ -106,30 +80,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if started:
await analytics.async_schedule()
async def start_schedule(hass: HomeAssistant) -> None:
"""Start the send schedule once Home Assistant has started."""
async def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
nonlocal started
started = True
await analytics.async_schedule()
entry.async_on_unload(
labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
)
labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
)
entry.async_on_unload(async_at_started(hass, start_schedule))
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
hass.data[DATA_COMPONENT] = analytics
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Analytics config entry."""
analytics = hass.data.pop(DATA_COMPONENT)
analytics.cancel_scheduled()
return True
@callback
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "analytics"})
@@ -139,9 +109,7 @@ def websocket_analytics(
msg: dict[str, Any],
) -> None:
"""Return analytics preferences."""
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
analytics = hass.data[DATA_COMPONENT]
connection.send_result(
msg["id"],
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
@@ -162,10 +130,8 @@ async def websocket_analytics_preferences(
msg: dict[str, Any],
) -> None:
"""Update analytics preferences."""
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
preferences = msg[ATTR_PREFERENCES]
analytics = hass.data[DATA_COMPONENT]
await analytics.save_preferences(preferences)
await analytics.async_schedule()
@@ -299,8 +299,12 @@ class Analytics:
self._data = AnalyticsData.from_dict(stored)
if self.supervisor and not self.onboarded:
# This may raise HassioNotReadyError if Supervisor was unreachable.
# The caller is responsible for handling this and triggering a retry.
# This may raise HassioNotReadyError if Supervisor was unreachable
# during setup of the Supervisor integration. That will fail setup
# of this integration. However there is no better option at this time
# since we need to get the diagnostic setting from Supervisor to correctly
# setup this integration and we can't raise ConfigEntryNotReady to
# trigger a retry from async_setup.
supervisor_info = hassio.get_supervisor_info(self._hass)
# User have not configured analytics, get this setting from the supervisor
@@ -345,7 +349,8 @@ class Analytics:
await self._save()
if self.supervisor:
# The others may raise HassioNotReadyError if only some
# get_supervisor_info was called during setup so we can't get here
# if it raised. The others may raise HassioNotReadyError if only some
# data was successfully fetched from Supervisor
supervisor_info = hassio.get_supervisor_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
@@ -625,16 +630,6 @@ class Analytics:
err,
)
@callback
def cancel_scheduled(self) -> None:
"""Cancel all scheduled analytics tasks."""
if self._basic_scheduled is not None:
self._basic_scheduled()
self._basic_scheduled = None
if self._snapshot_scheduled is not None:
self._snapshot_scheduled()
self._snapshot_scheduled = None
async def async_schedule(self) -> None:
"""Schedule analytics."""
if not self.onboarded:
@@ -1,19 +0,0 @@
"""Config flow for Analytics integration."""
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Analytics."""
VERSION = 1
async def async_step_system(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
return self.async_create_entry(title="Analytics", data={})
@@ -3,7 +3,6 @@
"name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"],
"codeowners": ["@home-assistant/core"],
"config_flow": true,
"dependencies": ["api", "websocket_api", "http"],
"documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system",
@@ -15,6 +14,5 @@
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
}
},
"quality_scale": "internal",
"single_config_entry": true
"quality_scale": "internal"
}
@@ -1,9 +1,4 @@
{
"exceptions": {
"supervisor_not_ready": {
"message": "Supervisor was not ready during setup, will retry"
}
},
"preview_features": {
"snapshots": {
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
@@ -226,7 +226,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
) -> SubentryFlowResult:
"""Set initial options."""
# abort if entry is not loaded
if self._get_entry().state is not ConfigEntryState.LOADED:
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
hass_apis: list[SelectOptionDict] = [
@@ -89,7 +89,7 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity):
def supported_features(self) -> WaterHeaterEntityFeature:
"""Return the list of supported features."""
supports_vacation_mode = any(
supported_mode.mode is AOSmithOperationMode.VACATION
supported_mode.mode == AOSmithOperationMode.VACATION
for supported_mode in self.device.supported_modes
)
@@ -122,7 +122,7 @@ class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity):
@property
def is_away_mode_on(self) -> bool:
"""Return True if away mode is on."""
return self.device.status.current_mode is AOSmithOperationMode.VACATION
return self.device.status.current_mode == AOSmithOperationMode.VACATION
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new target operation mode."""
@@ -369,7 +369,7 @@ class AppleTVManager(DeviceListener):
attrs[ATTR_MODEL] = (
dev_info.raw_model
if dev_info.model is DeviceModel.Unknown and dev_info.raw_model
if dev_info.model == DeviceModel.Unknown and dev_info.raw_model
else model_str(dev_info.model)
)
attrs[ATTR_SW_VERSION] = dev_info.version
@@ -63,7 +63,7 @@ class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener
# Listen to keyboard updates
atv.keyboard.listener = self
# Set initial state based on current focus state
self._update_state(atv.keyboard.text_focus_state is KeyboardFocusState.Focused)
self._update_state(atv.keyboard.text_focus_state == KeyboardFocusState.Focused)
@callback
def async_device_disconnected(self) -> None:
@@ -78,7 +78,7 @@ class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener
This is a callback function from pyatv.interface.KeyboardListener.
"""
self._update_state(new_state is KeyboardFocusState.Focused)
self._update_state(new_state == KeyboardFocusState.Focused)
def _update_state(self, new_state: bool) -> None:
"""Update and report."""
@@ -354,7 +354,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
"name": self.atv.name,
"type": (
dev_info.raw_model
if dev_info.model is DeviceModel.Unknown and dev_info.raw_model
if dev_info.model == DeviceModel.Unknown and dev_info.raw_model
else model_str(dev_info.model)
),
}
@@ -441,12 +441,12 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_password()
# Figure out, depending on protocol, what kind of pairing is needed
if service.pairing is PairingRequirement.Unsupported:
if service.pairing == PairingRequirement.Unsupported:
_LOGGER.debug("%s does not support pairing", self.protocol)
return await self.async_pair_next_protocol()
if service.pairing is PairingRequirement.Disabled:
if service.pairing == PairingRequirement.Disabled:
return await self.async_step_protocol_disabled()
if service.pairing is PairingRequirement.NotNeeded:
if service.pairing == PairingRequirement.NotNeeded:
_LOGGER.debug("%s does not require pairing", self.protocol)
self.credentials[self.protocol.value] = None
return await self.async_pair_next_protocol()
@@ -457,7 +457,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
pair_args: dict[str, Any] = {}
if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}:
pair_args["name"] = "Home Assistant"
if self.protocol is Protocol.DMAP:
if self.protocol == Protocol.DMAP:
pair_args["zeroconf"] = await zeroconf.async_get_instance(self.hass)
# Initiate the pairing process
@@ -139,7 +139,7 @@ class AppleTvMediaPlayer(
all_features = atv.features.all_features()
for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items():
feature_info = all_features.get(feature_name)
if feature_info and feature_info.state is not FeatureState.Unsupported:
if feature_info and feature_info.state != FeatureState.Unsupported:
self._attr_supported_features |= support_flag
# No need to schedule state update here as that will happen when the first
@@ -188,14 +188,14 @@ class AppleTvMediaPlayer(
return MediaPlayerState.OFF
if (
self._is_feature_available(FeatureName.PowerState)
and self.atv.power.power_state is PowerState.Off
and self.atv.power.power_state == PowerState.Off
):
return MediaPlayerState.OFF
if self._playing:
state = self._playing.device_state
if state in (DeviceState.Idle, DeviceState.Loading):
return MediaPlayerState.IDLE
if state is DeviceState.Playing:
if state == DeviceState.Playing:
return MediaPlayerState.PLAYING
if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped):
return MediaPlayerState.PAUSED
@@ -446,7 +446,7 @@ class AppleTvMediaPlayer(
def shuffle(self) -> bool | None:
"""Boolean if shuffle is enabled."""
if self._playing and self._is_feature_available(FeatureName.Shuffle):
return self._playing.shuffle is not ShuffleState.Off
return self._playing.shuffle != ShuffleState.Off
return None
def _is_feature_available(self, feature: FeatureName) -> bool:
@@ -506,7 +506,7 @@ class AppleTvMediaPlayer(
and (self._is_feature_available(FeatureName.TurnOff))
and (
not self._is_feature_available(FeatureName.PowerState)
or self.atv.power.power_state is PowerState.On
or self.atv.power.power_state == PowerState.On
)
):
await self.atv.power.turn_off()
@@ -59,7 +59,7 @@ def _check_keyboard_focus(atv: AppleTVInterface) -> None:
translation_domain=DOMAIN,
translation_key="keyboard_not_available",
) from err
if focus_state is not KeyboardFocusState.Focused:
if focus_state != KeyboardFocusState.Focused:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="keyboard_not_focused",
@@ -263,9 +263,9 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
def media_channel(self) -> str | None:
"""Channel currently playing."""
source = self._state.get_source()
if source is SourceCodes.DAB:
if source == SourceCodes.DAB:
value = self._state.get_dab_station()
elif source is SourceCodes.FM:
elif source == SourceCodes.FM:
value = self._state.get_rds_information()
else:
value = None
@@ -274,7 +274,7 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
@property
def media_artist(self) -> str | None:
"""Artist of current playing media, music track only."""
if self._state.get_source() is SourceCodes.DAB:
if self._state.get_source() == SourceCodes.DAB:
value = self._state.get_dls_pdt()
else:
value = None
@@ -1355,7 +1355,7 @@ class PipelineRun:
) -> bool:
"""Return true if all targeted entities were in the same area as the device."""
if (
intent_response.response_type is not intent.IntentResponseType.ACTION_DONE
intent_response.response_type != intent.IntentResponseType.ACTION_DONE
or not intent_response.matched_states
):
return False
+4 -4
View File
@@ -251,12 +251,12 @@ class AuthProvidersView(HomeAssistantView):
def _prepare_result_json(result: AuthFlowResult) -> dict[str, Any]:
"""Convert result to JSON serializable dict."""
if result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY:
if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY:
return {
key: val for key, val in result.items() if key not in ("result", "data")
}
if result["type"] is not data_entry_flow.FlowResultType.FORM:
if result["type"] != data_entry_flow.FlowResultType.FORM:
return result # type: ignore[return-value]
data = dict(result)
@@ -289,11 +289,11 @@ class LoginFlowBaseView(HomeAssistantView):
result: AuthFlowResult,
) -> web.Response:
"""Convert the flow result to a response."""
if result["type"] is not data_entry_flow.FlowResultType.CREATE_ENTRY:
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
# @log_invalid_auth does not work here since it returns HTTP 200.
# We need to manually log failed login attempts.
if (
result["type"] is data_entry_flow.FlowResultType.FORM
result["type"] == data_entry_flow.FlowResultType.FORM
and (errors := result.get("errors"))
and errors.get("base")
in (
@@ -142,9 +142,9 @@ def websocket_depose_mfa(
def _prepare_result_json(result: data_entry_flow.FlowResult) -> dict[str, Any]:
"""Convert result to JSON serializable dict."""
if result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY:
if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY:
return dict(result)
if result["type"] is not data_entry_flow.FlowResultType.FORM:
if result["type"] != data_entry_flow.FlowResultType.FORM:
return result # type: ignore[return-value]
data = dict(result)
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.29.11",
"dbus-fast==5.0.3",
"habluetooth==6.2.0"
"dbus-fast==5.0.0",
"habluetooth==6.1.0"
]
}
@@ -273,7 +273,7 @@ async def ws_subscribe_scanner_details(
def _async_registration_changed(registration: HaScannerRegistration) -> None:
added_event = HaScannerRegistrationEvent.ADDED
event_type = "add" if registration.event is added_event else "remove"
event_type = "add" if registration.event == added_event else "remove"
_async_event_message({event_type: [registration.scanner.details]})
manager = _get_manager(hass)
+1 -4
View File
@@ -158,10 +158,7 @@ def process_service_info(
)
# If payload is encrypted and the bindkey is not verified then we need to reauth
if (
data.encryption_scheme is not EncryptionScheme.NONE
and not data.bindkey_verified
):
if data.encryption_scheme != EncryptionScheme.NONE and not data.bindkey_verified:
entry.async_start_reauth(hass, data={"device": data})
return update
@@ -59,7 +59,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovery_info = discovery_info
self._discovered_device = device
if device.encryption_scheme is EncryptionScheme.BTHOME_BINDKEY:
if device.encryption_scheme == EncryptionScheme.BTHOME_BINDKEY:
return await self.async_step_get_encryption_key()
return await self.async_step_bluetooth_confirm()
@@ -125,7 +125,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovery_info = discovery.discovery_info
self._discovered_device = discovery.device
if discovery.device.encryption_scheme is EncryptionScheme.BTHOME_BINDKEY:
if discovery.device.encryption_scheme == EncryptionScheme.BTHOME_BINDKEY:
return await self.async_step_get_encryption_key()
return self._async_get_or_create_entry()
@@ -164,7 +164,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovery_info = device.last_service_info
if device.encryption_scheme is EncryptionScheme.BTHOME_BINDKEY:
if device.encryption_scheme == EncryptionScheme.BTHOME_BINDKEY:
return await self.async_step_get_encryption_key()
# Otherwise there wasn't actually encryption so abort
@@ -1,11 +1,12 @@
"""Config flow for the Cert Expiry platform."""
from collections.abc import Mapping
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_IMPORT, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from .const import DEFAULT_PORT, DOMAIN
@@ -18,6 +19,8 @@ from .errors import (
)
from .helper import get_cert_expiry_timestamp
_LOGGER = logging.getLogger(__name__)
class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
@@ -72,6 +75,9 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
title=title,
data={CONF_HOST: host, CONF_PORT: port},
)
if self.source == SOURCE_IMPORT:
_LOGGER.error("Config import failed for %s", user_input[CONF_HOST])
return self.async_abort(reason="import_failed")
else:
user_input = {}
user_input[CONF_HOST] = ""
@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"import_failed": "Import from config failed",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
@@ -110,7 +110,7 @@ class ComelitAlarmEntity(
@property
def available(self) -> bool:
"""Return True if alarm is available."""
if self._area.human_status is AlarmAreaState.UNKNOWN:
if self._area.human_status == AlarmAreaState.UNKNOWN:
return False
return super().available
@@ -124,7 +124,7 @@ class ComelitAlarmEntity(
self._area.human_status,
self._area.armed,
)
if self._area.human_status is AlarmAreaState.ARMED:
if self._area.human_status == AlarmAreaState.ARMED:
if self._area.armed == ALARM_AREA_ARMED_STATUS[AWAY]:
return AlarmControlPanelState.ARMED_AWAY
if self._area.armed == ALARM_AREA_ARMED_STATUS[NIGHT]:
@@ -43,7 +43,7 @@ BINARY_SENSOR_TYPES: Final[tuple[ComelitBinarySensorEntityDescription, ...]] = (
device_class=BinarySensorDeviceClass.PROBLEM,
is_on_fn=lambda obj: cast(ComelitVedoAreaObject, obj).anomaly,
available_fn=lambda obj: (
cast(ComelitVedoAreaObject, obj).human_status is not AlarmAreaState.UNKNOWN
cast(ComelitVedoAreaObject, obj).human_status != AlarmAreaState.UNKNOWN
),
),
ComelitBinarySensorEntityDescription(
@@ -67,7 +67,7 @@ BINARY_SENSOR_TYPES: Final[tuple[ComelitBinarySensorEntityDescription, ...]] = (
object_type=ALARM_ZONE,
device_class=BinarySensorDeviceClass.PROBLEM,
is_on_fn=lambda obj: (
cast(ComelitVedoZoneObject, obj).human_status is AlarmZoneState.FAULTY
cast(ComelitVedoZoneObject, obj).human_status == AlarmZoneState.FAULTY
),
available_fn=lambda obj: (
cast(ComelitVedoZoneObject, obj).human_status
@@ -65,9 +65,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
translation_placeholders={"error": repr(err)},
) from err
except aiocomelit_exceptions.CannotAuthenticate as err:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise InvalidAuth(
translation_domain=DOMAIN,
translation_key="cannot_authenticate",
translation_placeholders={"error": repr(err)},
) from err
finally:
await api.logout()
+2 -2
View File
@@ -166,12 +166,12 @@ class ComelitVedoSensorEntity(
@property
def available(self) -> bool:
"""Sensor availability."""
return self._zone_object.human_status is not AlarmZoneState.UNAVAILABLE
return self._zone_object.human_status != AlarmZoneState.UNAVAILABLE
@property
def native_value(self) -> StateType:
"""Sensor value."""
if (status := self._zone_object.human_status) is AlarmZoneState.UNKNOWN:
if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN:
return None
return cast(str, status.value)
@@ -148,7 +148,7 @@ def _prepare_config_flow_result_json(
prepare_result_json: Callable[[data_entry_flow.FlowResult], dict[str, Any]],
) -> dict[str, Any]:
"""Convert result to JSON."""
if result["type"] is not data_entry_flow.FlowResultType.CREATE_ENTRY:
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
return prepare_result_json(result)
data = {key: val for key, val in result.items() if key not in ("data", "context")}
@@ -646,7 +646,7 @@ class DefaultAgent(ConversationEntity):
cache_value = self._intent_cache.get(cache_key)
if cache_value is not None:
if (cache_value.result is not None) and (
cache_value.stage is IntentMatchingStage.EXPOSED_ENTITIES_ONLY
cache_value.stage == IntentMatchingStage.EXPOSED_ENTITIES_ONLY
):
_LOGGER.debug("Got cached result for exposed entities")
return cache_value.result
@@ -686,7 +686,7 @@ class DefaultAgent(ConversationEntity):
skip_unexposed_entities_match = False
if cache_value is not None:
if (cache_value.result is not None) and (
cache_value.stage is IntentMatchingStage.UNEXPOSED_ENTITIES
cache_value.stage == IntentMatchingStage.UNEXPOSED_ENTITIES
):
_LOGGER.debug("Got cached result for all entities")
return cache_value.result
@@ -731,7 +731,7 @@ class DefaultAgent(ConversationEntity):
skip_unknown_names = False
if cache_value is not None:
if (cache_value.result is not None) and (
cache_value.stage is IntentMatchingStage.UNKNOWN_NAMES
cache_value.stage == IntentMatchingStage.UNKNOWN_NAMES
):
_LOGGER.debug("Got cached result for unknown names")
return cache_value.result
@@ -1447,7 +1447,7 @@ class DefaultAgent(ConversationEntity):
response = await self._async_process_intent_result(result, user_input, chat_log)
if (
response.response_type is intent.IntentResponseType.ERROR
response.response_type == intent.IntentResponseType.ERROR
and response.error_code
not in (
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
@@ -1546,7 +1546,7 @@ def _get_match_error_response(
# device_class only
return ErrorKey.NO_DEVICE_CLASS, {"device_class": device_class}
if (reason is intent.MatchFailedReason.DOMAIN) and constraints.domains:
if (reason == intent.MatchFailedReason.DOMAIN) and constraints.domains:
domain = next(iter(constraints.domains)) # first domain
if constraints.area_name:
# domain in area
@@ -1565,7 +1565,7 @@ def _get_match_error_response(
# domain only
return ErrorKey.NO_DOMAIN, {"domain": domain}
if reason is intent.MatchFailedReason.DUPLICATE_NAME:
if reason == intent.MatchFailedReason.DUPLICATE_NAME:
if constraints.floor_name:
# duplicate on floor
return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, {
@@ -1582,26 +1582,26 @@ def _get_match_error_response(
return ErrorKey.DUPLICATE_ENTITIES, {"entity": result.no_match_name}
if reason is intent.MatchFailedReason.INVALID_AREA:
if reason == intent.MatchFailedReason.INVALID_AREA:
# Invalid area name
return ErrorKey.NO_AREA, {"area": result.no_match_name}
if reason is intent.MatchFailedReason.INVALID_FLOOR:
if reason == intent.MatchFailedReason.INVALID_FLOOR:
# Invalid floor name
return ErrorKey.NO_FLOOR, {"floor": result.no_match_name}
if reason is intent.MatchFailedReason.FEATURE:
if reason == intent.MatchFailedReason.FEATURE:
# Feature not supported by entity
return ErrorKey.FEATURE_NOT_SUPPORTED, {}
if reason is intent.MatchFailedReason.STATE:
if reason == intent.MatchFailedReason.STATE:
# Entity is not in correct state
assert constraints.states
state = next(iter(constraints.states))
return ErrorKey.ENTITY_WRONG_STATE, {"state": state}
if reason is intent.MatchFailedReason.ASSISTANT:
if reason == intent.MatchFailedReason.ASSISTANT:
# Not exposed
if constraints.name:
if constraints.area_name:
@@ -61,7 +61,7 @@ class CurrencylayerSensor(SensorEntity):
"""Implementing the Currencylayer sensor."""
_attr_attribution = "Data provided by currencylayer.com"
_attr_icon = "mdi:currency-usd"
_attr_icon = "mdi:currency"
def __init__(self, rest: CurrencylayerData, base: str, quote: str) -> None:
"""Initialize the sensor."""
+2 -2
View File
@@ -26,12 +26,12 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_LOCKED, ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .const import ATTR_OFFSET, ATTR_VALVE
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
from .entity import DeconzDevice
from .hub import DeconzHub
+2
View File
@@ -43,6 +43,8 @@ PLATFORMS = [
]
ATTR_DARK = "dark"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCKED = "locked"
ATTR_OFFSET = "offset"
ATTR_ON = "on"
ATTR_VALVE = "valve"
@@ -80,7 +80,7 @@ async def async_validate_device_automation_config(
# the checks below which look for a config entry matching the device automation
# domain
if (
automation_type is DeviceAutomationType.ACTION
automation_type == DeviceAutomationType.ACTION
and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS
):
# Pass the unvalidated config to avoid mutating the raw config twice
@@ -1,18 +1,11 @@
"""Provide functionality to keep track of devices."""
import asyncio
from typing import Any
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from .config_entry import ( # noqa: F401
DATA_COMPONENT,
BaseScannerEntity,
BaseTrackerEntity,
ScannerEntity,
ScannerEntityDescription,
TrackerEntity,
@@ -40,8 +33,6 @@ from .const import ( # noqa: F401
DEFAULT_TRACK_NEW,
DOMAIN,
ENTITY_ID_FORMAT,
LOGGER,
PLATFORM_TYPE_LEGACY,
SCAN_INTERVAL,
SourceType,
)
@@ -53,9 +44,7 @@ from .legacy import ( # noqa: F401
SOURCE_TYPES,
AsyncSeeCallback,
DeviceScanner,
DeviceTracker,
SeeCallback,
async_create_platform_type,
async_setup_integration as async_setup_legacy_integration,
see,
)
@@ -68,43 +57,5 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the device tracker."""
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
component.config = {}
component.register_shutdown()
# The tracker is loaded in the async_setup_legacy_integration task so
# we create a future to avoid waiting on it here so that only
# async_platform_discovered will have to wait in the rare event
# a custom component still uses the legacy device tracker discovery.
tracker_future: asyncio.Future[DeviceTracker] = hass.loop.create_future()
async def async_platform_discovered(
p_type: str, info: dict[str, Any] | None
) -> None:
"""Load a platform."""
platform = await async_create_platform_type(hass, config, p_type, {})
if platform is None:
return
if platform.type != PLATFORM_TYPE_LEGACY:
await component.async_setup_platform(p_type, {}, info)
return
tracker = await tracker_future
await platform.async_setup_legacy(hass, tracker, info)
discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
#
# Legacy and platforms load in a non-awaited tracked task
# to ensure device tracker setup can continue and config
# entry integrations are not waiting for legacy device
# tracker platforms to be set up.
#
hass.async_create_task(
async_setup_legacy_integration(hass, config, tracker_future),
eager_start=True,
)
async_setup_legacy_integration(hass, config)
return True
@@ -37,7 +37,11 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers import (
config_validation as cv,
discovery,
entity_registry as er,
)
from homeassistant.helpers.event import (
async_track_time_interval,
async_track_utc_time_change,
@@ -200,7 +204,40 @@ def see(
hass.services.call(DOMAIN, SERVICE_SEE, data)
async def async_setup_integration(
@callback
def async_setup_integration(hass: HomeAssistant, config: ConfigType) -> None:
"""Set up the legacy integration."""
# The tracker is loaded in the _async_setup_integration task so
# we create a future to avoid waiting on it here so that only
# async_platform_discovered will have to wait in the rare event
# a custom component still uses the legacy device tracker discovery.
tracker_future: asyncio.Future[DeviceTracker] = hass.loop.create_future()
async def async_platform_discovered(
p_type: str, info: dict[str, Any] | None
) -> None:
"""Load a platform."""
platform = await async_create_platform_type(hass, config, p_type, {})
if platform is None or platform.type != PLATFORM_TYPE_LEGACY:
return
tracker = await tracker_future
await platform.async_setup_legacy(hass, tracker, info)
discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
#
# Legacy and platforms load in a non-awaited tracked task
# to ensure device tracker setup can continue and config
# entry integrations are not waiting for legacy device
# tracker platforms to be set up.
#
hass.async_create_task(
_async_setup_integration(hass, config, tracker_future), eager_start=True
)
async def _async_setup_integration(
hass: HomeAssistant,
config: ConfigType,
tracker_future: asyncio.Future[DeviceTracker],
+1 -1
View File
@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.1",
"aiodiscover==3.2.3",
"aiodiscover==3.2.0",
"cached-ipaddress==1.0.1"
]
}
@@ -261,7 +261,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
except KeyError, ValueError:
bootid = None
if change is ssdp.SsdpChange.UPDATE:
if change == ssdp.SsdpChange.UPDATE:
# This is an announcement that bootid is about to change
if self._bootid is not None and self._bootid == bootid:
# Store the new value (because our old value matches) so that we
@@ -281,7 +281,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
await self._device_disconnect()
self._bootid = bootid
if change is ssdp.SsdpChange.BYEBYE:
if change == ssdp.SsdpChange.BYEBYE:
# Device is going away
if self._device:
# Disconnect from gone device
@@ -290,7 +290,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
self._ssdp_connect_failed = False
if (
change is ssdp.SsdpChange.ALIVE
change == ssdp.SsdpChange.ALIVE
and not self._device
and not self._ssdp_connect_failed
):
@@ -718,7 +718,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
# If already playing, or don't want to autoplay, no need to call Play
autoplay = extra.get("autoplay", True)
if self._device.transport_state is TransportState.PLAYING or not autoplay:
if self._device.transport_state == TransportState.PLAYING or not autoplay:
return
# Play it
@@ -748,7 +748,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
if not (play_mode := self._device.play_mode):
return None
if play_mode is PlayMode.VENDOR_DEFINED:
if play_mode == PlayMode.VENDOR_DEFINED:
return None
return play_mode in (PlayMode.SHUFFLE, PlayMode.RANDOM)
@@ -782,10 +782,10 @@ class DlnaDmrEntity(MediaPlayerEntity):
if not (play_mode := self._device.play_mode):
return None
if play_mode is PlayMode.VENDOR_DEFINED:
if play_mode == PlayMode.VENDOR_DEFINED:
return None
if play_mode is PlayMode.REPEAT_ONE:
if play_mode == PlayMode.REPEAT_ONE:
return RepeatMode.ONE
if play_mode in (PlayMode.REPEAT_ALL, PlayMode.RANDOM):
+3 -3
View File
@@ -236,7 +236,7 @@ class DmsDeviceSource:
except KeyError, ValueError:
bootid = None
if change is ssdp.SsdpChange.UPDATE:
if change == ssdp.SsdpChange.UPDATE:
# This is an announcement that bootid is about to change
if self._bootid is not None and self._bootid == bootid:
# Store the new value (because our old value matches) so that we
@@ -258,7 +258,7 @@ class DmsDeviceSource:
await self.device_disconnect()
self._bootid = bootid
if change is ssdp.SsdpChange.BYEBYE:
if change == ssdp.SsdpChange.BYEBYE:
# Device is going away
if self._device:
# Disconnect from gone device
@@ -267,7 +267,7 @@ class DmsDeviceSource:
self._ssdp_connect_failed = False
if (
change is ssdp.SsdpChange.ALIVE
change == ssdp.SsdpChange.ALIVE
and not self._device
and not self._ssdp_connect_failed
):
+6 -1
View File
@@ -6,6 +6,7 @@ import logging
import aiodns
from aiodns.error import DNSError
from pycares import AresError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
@@ -77,7 +78,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
) from err
errors = [
result for result in results if isinstance(result, (TimeoutError, DNSError))
result
for result in results
if isinstance(
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
)
]
if errors and len(errors) == len(results):
await _close_resolvers()
@@ -8,12 +8,6 @@
},
"step": {
"user": {
"data": {
"download_dir": "Download directory"
},
"data_description": {
"download_dir": "The directory where downloaded files will be stored. This can be an absolute path or a path relative to the Home Assistant configuration directory."
},
"description": "Select a location to get to store downloads. The setup will check if the directory exists."
}
}
@@ -15,8 +15,6 @@ _LOGGER = logging.getLogger(__name__)
class EcobeeBaseEntity(Entity):
"""Base methods for Ecobee entities."""
_attr_has_entity_name = True
def __init__(self, data: EcobeeData, thermostat_index: int) -> None:
"""Initiate base methods for Ecobee entities."""
self.data = data
@@ -1,11 +1,4 @@
{
"entity": {
"number": {
"fan_min_on_time": {
"default": "mdi:fan-clock"
}
}
},
"services": {
"create_vacation": {
"service": "mdi:umbrella-beach"
@@ -24,6 +24,7 @@ class EcobeeNotifyEntity(EcobeeBaseEntity, NotifyEntity):
"""Implement the notification entity for the Ecobee thermostat."""
_attr_name = None
_attr_has_entity_name = True
def __init__(self, data: EcobeeData, thermostat_index: int) -> None:
"""Initialize the thermostat."""
+2 -40
View File
@@ -74,10 +74,6 @@ async def async_setup_entry(
)
)
entities.extend(
EcobeeFanMinOnTime(data, index) for index in range(len(data.ecobee.thermostats))
)
async_add_entities(entities, True)
@@ -90,6 +86,7 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity):
_attr_native_max_value = 60
_attr_native_step = 5
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
_attr_has_entity_name = True
def __init__(
self,
@@ -133,6 +130,7 @@ class EcobeeCompressorMinTemp(EcobeeBaseEntity, NumberEntity):
"""
_attr_device_class = NumberDeviceClass.TEMPERATURE
_attr_has_entity_name = True
_attr_icon = "mdi:thermometer-off"
_attr_mode = NumberMode.BOX
_attr_native_min_value = -25
@@ -167,39 +165,3 @@ class EcobeeCompressorMinTemp(EcobeeBaseEntity, NumberEntity):
"""Set new compressor minimum temperature."""
self.data.ecobee.set_aux_cutover_threshold(self.thermostat_index, value)
self.update_without_throttle = True
class EcobeeFanMinOnTime(EcobeeBaseEntity, NumberEntity):
"""Minimum minutes per hour that the fan must run on an ecobee thermostat."""
_attr_native_min_value = 0
_attr_native_max_value = 60
_attr_native_step = 5
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
_attr_translation_key = "fan_min_on_time"
def __init__(
self,
data: EcobeeData,
thermostat_index: int,
) -> None:
"""Initialize ecobee fan minimum on time."""
super().__init__(data, thermostat_index)
self._attr_unique_id = f"{self.base_unique_id}_fan_min_on_time"
self.update_without_throttle = False
async def async_update(self) -> None:
"""Get the latest state from the thermostat."""
if self.update_without_throttle:
await self.data.update(no_throttle=True)
self.update_without_throttle = False
else:
await self.data.update()
self._attr_native_value = self.thermostat["settings"]["fanMinOnTime"]
def set_native_value(self, value: float) -> None:
"""Set new fan minimum on time value."""
step = self._attr_native_step
aligned_value = int(round(value / step) * step)
self.data.ecobee.set_fan_min_on_time(self.thermostat_index, aligned_value)
self.update_without_throttle = True
@@ -37,9 +37,6 @@
"compressor_protection_min_temp": {
"name": "Compressor minimum temperature"
},
"fan_min_on_time": {
"name": "Fan minimum on time"
},
"ventilator_min_type_away": {
"name": "Ventilator minimum time away"
},
@@ -53,6 +53,7 @@ async def async_setup_entry(
class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity):
"""Represent 20 min timer for an ecobee thermostat with ventilator."""
_attr_has_entity_name = True
_attr_name = "Ventilator 20m Timer"
def __init__(
@@ -103,6 +104,7 @@ class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity):
class EcobeeSwitchAuxHeatOnly(EcobeeBaseEntity, SwitchEntity):
"""Representation of a aux_heat_only ecobee switch."""
_attr_has_entity_name = True
_attr_translation_key = "aux_heat_only"
def __init__(
@@ -0,0 +1,220 @@
"""The Electrolux integration."""
from asyncio import CancelledError
from collections.abc import Awaitable, Callable
import logging
from electrolux_group_developer_sdk.auth.token_manager import TokenManager
from electrolux_group_developer_sdk.client.appliance_client import ApplianceClient
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
BadCredentialsException,
)
from electrolux_group_developer_sdk.client.client_exception import (
ApplianceClientException,
)
from electrolux_group_developer_sdk.client.failed_connection_exception import (
FailedConnectionException,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import CONF_REFRESH_TOKEN, DOMAIN, NEW_APPLIANCE_SIGNAL, USER_AGENT
from .coordinator import (
ElectroluxConfigEntry,
ElectroluxData,
ElectroluxDataUpdateCoordinator,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
PLATFORMS = [
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: ElectroluxConfigEntry) -> bool:
"""Set up Electrolux integration entry."""
token_manager = create_token_manager(hass, entry)
client = ApplianceClient(
token_manager=token_manager, external_user_agent=USER_AGENT
)
try:
await client.test_connection()
except BadCredentialsException as e:
raise ConfigEntryAuthFailed("Bad credentials detected.") from e
except FailedConnectionException as e:
raise ConfigEntryNotReady("Connection with client failed.") from e
try:
appliances = await fetch_appliance_data(client)
except ApplianceClientException as e:
raise ConfigEntryNotReady from e
coordinators: dict[str, ElectroluxDataUpdateCoordinator] = {}
on_livestream_opening_callback_list: list[Callable[[], Awaitable[None]]] = []
async def check_for_new_devices_callback() -> None:
"""Trigger _check_for_new_devices asynchronously."""
await _check_for_new_devices(
hass, entry, client, on_livestream_opening_callback_list
)
on_livestream_opening_callback_list.append(check_for_new_devices_callback)
for appliance in appliances:
appliance_id = appliance.appliance.applianceId
coordinator = ElectroluxDataUpdateCoordinator(
hass, entry, client=client, appliance_id=appliance_id
)
await coordinator.async_config_entry_first_refresh()
# Subscribe this coordinator to its appliance events
coordinator.add_client_listener()
coordinators[appliance_id] = coordinator
# Device state is refreshed whenever the SSE connection opens.
on_livestream_opening_callback_list.append(coordinator.async_refresh)
sse_task = entry.async_create_background_task(
hass,
client.start_event_stream(on_livestream_opening_callback_list),
"electrolux event listener",
)
entry.runtime_data = ElectroluxData(
client=client,
appliances=appliances,
coordinators=coordinators,
sse_task=sse_task,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ElectroluxConfigEntry) -> bool:
"""Unload a config entry."""
# Remove SSE listeners
coordinators = entry.runtime_data.coordinators
for coordinator in coordinators.values():
coordinator.remove_client_listeners()
# Cancel SSE task
sse_task = entry.runtime_data.sse_task
sse_task.cancel()
try:
await sse_task
except CancelledError:
_LOGGER.info("SSE stream cancelled for entry %s", entry.entry_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def create_token_manager(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
) -> TokenManager:
"""Create a token manager for the Electrolux integration."""
def save_tokens(new_access: str, new_refresh: str, new_api_key: str) -> None:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_API_KEY: new_api_key,
CONF_ACCESS_TOKEN: new_access,
CONF_REFRESH_TOKEN: new_refresh,
},
)
api_key = entry.data.get(CONF_API_KEY)
refresh_token = entry.data.get(CONF_REFRESH_TOKEN)
access_token = entry.data.get(CONF_ACCESS_TOKEN)
if access_token and refresh_token and api_key:
return TokenManager(access_token, refresh_token, api_key, save_tokens)
raise ConfigEntryAuthFailed("Missing access token, refresh token or API key")
async def _check_for_new_devices(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
client: ApplianceClient,
on_livestream_opening_callback_list: list[Callable[[], Awaitable[None]]],
) -> None:
"""Fetch appliances from API and trigger discovery for any new ones."""
_LOGGER.info("Checking for new devices")
coordinators = entry.runtime_data.coordinators
appliances = await fetch_appliance_data(client)
entry.runtime_data.appliances = appliances
existing_ids = set(coordinators.keys())
for appliance in appliances:
appliance_id = appliance.appliance.applianceId
# Detect NEW appliances
if appliance_id not in existing_ids:
# Create coordinator for appliance
coordinator = ElectroluxDataUpdateCoordinator(
hass, entry, client=client, appliance_id=appliance_id
)
await coordinator.async_refresh()
coordinator.add_client_listener()
coordinators[appliance_id] = coordinator
on_livestream_opening_callback_list.append(coordinator.async_refresh)
# Notify all platforms
async_dispatcher_send(
hass, f"{NEW_APPLIANCE_SIGNAL}_{entry.entry_id}", appliance
)
# Detect MISSING appliances
discovered_ids = {appliance.appliance.applianceId for appliance in appliances}
missing_ids = existing_ids - discovered_ids
device_registry = dr.async_get(hass)
for missing_id in missing_ids:
_LOGGER.warning("Appliance %s no longer found, removing", missing_id)
# Remove coordinator
coordinator = coordinators.pop(missing_id)
coordinator.remove_client_listeners()
on_livestream_opening_callback_list.remove(coordinator.async_refresh)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, missing_id)}
)
if device_entry:
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=entry.entry_id
)
async def fetch_appliance_data(client: ApplianceClient) -> list[ApplianceData]:
"""Helper method to retrieve all the appliances data from the Electrolux APIs."""
try:
appliances = await client.get_appliance_data()
except ApplianceClientException as e:
_LOGGER.warning("Failed to get appliances: %s", e)
raise
# Filter out appliances where details or state is None
return [
appliance
for appliance in appliances
if appliance.details is not None and appliance.state is not None
]
@@ -0,0 +1,99 @@
"""Config flow for Electrolux integration."""
from collections.abc import Mapping
import logging
from typing import Any
from electrolux_group_developer_sdk.auth.invalid_credentials_exception import (
InvalidCredentialsException,
)
from electrolux_group_developer_sdk.auth.token_manager import TokenManager
from electrolux_group_developer_sdk.client.appliance_client import ApplianceClient
from electrolux_group_developer_sdk.client.bad_credentials_exception import (
BadCredentialsException,
)
from electrolux_group_developer_sdk.client.failed_connection_exception import (
FailedConnectionException,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_API_KEY
from .const import CONF_REFRESH_TOKEN, DOMAIN, USER_AGENT
_LOGGER: logging.Logger = logging.getLogger(__name__)
class ElectroluxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for the Electrolux integration."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step of the config flow."""
errors: dict[str, str] = {}
if user_input is not None:
token_manager: TokenManager
email: str
try:
token_manager = await _authenticate_user(user_input)
client = ApplianceClient(
token_manager=token_manager, external_user_agent=USER_AGENT
)
email = (await client.get_user_email()).email
except InvalidCredentialsException, BadCredentialsException:
errors["base"] = "invalid_auth"
except FailedConnectionException:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(token_manager.get_user_id())
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=f"Electrolux for {email}",
data=user_input,
)
return self._show_form(step_id="user", errors=errors)
def _show_form(self, step_id: str, errors: dict[str, str]) -> ConfigFlowResult:
return self.async_show_form(
step_id=step_id,
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_ACCESS_TOKEN): str,
vol.Required(CONF_REFRESH_TOKEN): str,
}
),
errors=errors,
description_placeholders={
"portal_link": "https://developer.electrolux.one/generateToken"
},
)
async def _authenticate_user(user_input: Mapping[str, Any]) -> TokenManager:
token_manager = TokenManager(
access_token=user_input[CONF_ACCESS_TOKEN],
refresh_token=user_input[CONF_REFRESH_TOKEN],
api_key=user_input[CONF_API_KEY],
)
token_manager.ensure_credentials()
appliance_client = ApplianceClient(
token_manager=token_manager, external_user_agent=USER_AGENT
)
# Test a connection in the config flow
await appliance_client.test_connection()
return token_manager
@@ -0,0 +1,11 @@
"""Constants for Electrolux integration."""
from homeassistant.const import __version__ as HA_VERSION
DOMAIN = "electrolux"
CONF_REFRESH_TOKEN = "refresh_token"
NEW_APPLIANCE_SIGNAL = "electrolux_new_appliance"
USER_AGENT = f"HomeAssistant/{HA_VERSION}"
@@ -0,0 +1,96 @@
"""Electrolux coordinator class."""
from __future__ import annotations
from asyncio import Task
from dataclasses import dataclass
import logging
from electrolux_group_developer_sdk.client.appliance_client import (
ApplianceClient,
apply_sse_update,
)
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.client_exception import (
ApplianceClientException,
)
from electrolux_group_developer_sdk.client.dto.appliance_state import ApplianceState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER: logging.Logger = logging.getLogger(__name__)
@dataclass(kw_only=True, slots=True)
class ElectroluxData:
"""Electrolux data type."""
client: ApplianceClient
appliances: list[ApplianceData]
coordinators: dict[str, ElectroluxDataUpdateCoordinator]
sse_task: Task
type ElectroluxConfigEntry = ConfigEntry[ElectroluxData]
class ElectroluxDataUpdateCoordinator(DataUpdateCoordinator[ApplianceState]):
"""Class for fetching appliance data from the API."""
def __init__(
self,
hass: HomeAssistant,
config_entry: ElectroluxConfigEntry,
client: ApplianceClient,
appliance_id: str,
) -> None:
"""Initialize."""
self.client = client
self._appliance_id = appliance_id
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_{config_entry.entry_id}_{appliance_id}",
update_interval=None,
always_update=False,
)
async def _async_update_data(self) -> ApplianceState:
"""Return the current appliance state (SSE keeps it updated)."""
try:
appliance_state = await self.client.get_appliance_state(self._appliance_id)
except ValueError as exception:
raise UpdateFailed(exception) from exception
except ApplianceClientException as exception:
raise UpdateFailed(exception) from exception
else:
return appliance_state
def add_client_listener(self) -> None:
"""Register an SSE listener to the appliance client for appliance state updates."""
self.client.add_listener(self._appliance_id, self.callback_handle_event)
def remove_client_listeners(self) -> None:
"""Remove all SSE listeners."""
self.client.remove_all_listeners_by_appliance_id(self._appliance_id)
def callback_handle_event(self, event: dict) -> None:
"""Handle an incoming SSE event. Event will look like: {"userId": "...", "applianceId": "...", "property": "timeToEnd", "value": 720}."""
current_state = self.data
if not current_state:
return
updated_state = apply_sse_update(
current_state,
event,
)
self.async_set_updated_data(updated_state)
@@ -0,0 +1,80 @@
"""Base entity for Electrolux integration."""
from abc import abstractmethod
import logging
from typing import TYPE_CHECKING
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ElectroluxDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
class ElectroluxBaseEntity[T: ApplianceData](
CoordinatorEntity[ElectroluxDataUpdateCoordinator]
):
"""Base class for Electrolux entities."""
_attr_has_entity_name = True
def __init__(
self,
appliance_data: T,
coordinator: ElectroluxDataUpdateCoordinator,
unique_id_suffix: str,
) -> None:
"""Initialize the base device."""
super().__init__(coordinator)
appliance_name = appliance_data.appliance.applianceName
appliance_id = appliance_data.appliance.applianceId
if TYPE_CHECKING:
assert appliance_data.details
assert appliance_data.state
appliance_info = appliance_data.details.applianceInfo
self._appliance_data = appliance_data
self._attr_unique_id = f"{appliance_id}_{unique_id_suffix}"
self._appliance_id = appliance_id
self._appliance_capabilities = appliance_data.details.capabilities
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance_id)},
name=appliance_name,
manufacturer=appliance_info.brand,
model=appliance_info.model,
serial_number=appliance_info.serialNumber,
)
async def async_added_to_hass(self) -> None:
"""When entity is added to HA."""
await super().async_added_to_hass()
self._handle_coordinator_update()
@abstractmethod
def _update_attr_state(self) -> bool:
"""Update entity-specific attributes. Returns True if any attributes were changed, otherwise False."""
@callback
def _handle_coordinator_update(self) -> None:
"""When the coordinator updates."""
appliance_state = self.coordinator.data
if not appliance_state:
_LOGGER.warning("Appliance %s not found in update", self._appliance_id)
return
# Update state
self._appliance_data.update_state(appliance_state)
state_changed = self._update_attr_state()
if state_changed:
self.async_write_ha_state()
@@ -0,0 +1,49 @@
"""Contains entity helper methods."""
from collections.abc import Callable
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEW_APPLIANCE_SIGNAL
from .coordinator import ElectroluxConfigEntry, ElectroluxDataUpdateCoordinator
from .entity import ElectroluxBaseEntity
async def async_setup_entities_helper(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
build_entities_fn: Callable[
[ApplianceData, dict[str, ElectroluxDataUpdateCoordinator]],
list[ElectroluxBaseEntity],
],
) -> None:
"""Provide async_setup_entry helper."""
appliances: list[ApplianceData] = entry.runtime_data.appliances
coordinators = entry.runtime_data.coordinators
entities: list[ElectroluxBaseEntity] = []
for appliance_data in appliances:
entities.extend(build_entities_fn(appliance_data, coordinators))
async_add_entities(entities)
# Listen for new/removed appliances
async def _new_appliance(appliance_data: ApplianceData):
new_entities = build_entities_fn(appliance_data, coordinators)
if new_entities:
async_add_entities(new_entities)
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{NEW_APPLIANCE_SIGNAL}_{entry.entry_id}", _new_appliance
)
)
@@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"appliance_state": {
"default": "mdi:information-outline"
},
"food_probe_state": {
"default": "mdi:thermometer-probe"
},
"food_probe_temperature": {
"default": "mdi:thermometer-probe"
},
"remote_control": {
"default": "mdi:remote"
}
}
}
}
@@ -0,0 +1,11 @@
{
"domain": "electrolux",
"name": "Electrolux",
"codeowners": ["@electrolux-oss"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/electrolux",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["electrolux-group-developer-sdk==0.5.0"]
}
@@ -0,0 +1,70 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No actions are implemented currently.
appropriate-polling:
status: exempt
comment: |
Polling is only performed on infrequent events (when the livestream of events is opened, in order to sync),
otherwise the integration works via push
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No actions are implemented currently.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,290 @@
"""Sensor entity for Electrolux Integration."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import cast
from electrolux_group_developer_sdk.client.appliances.appliance_data import (
ApplianceData,
)
from electrolux_group_developer_sdk.client.appliances.cr_appliance import CRAppliance
from electrolux_group_developer_sdk.client.appliances.ov_appliance import OVAppliance
from electrolux_group_developer_sdk.feature_constants import (
APPLIANCE_STATE,
DISPLAY_FOOD_PROBE_TEMPERATURE_C,
DISPLAY_FOOD_PROBE_TEMPERATURE_F,
DISPLAY_TEMPERATURE_C,
DISPLAY_TEMPERATURE_F,
FOOD_PROBE_STATE,
REMOTE_CONTROL,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from .coordinator import ElectroluxConfigEntry, ElectroluxDataUpdateCoordinator
from .entity import ElectroluxBaseEntity
from .entity_helper import async_setup_entities_helper
_LOGGER = logging.getLogger(__name__)
ELECTROLUX_TO_HA_TEMPERATURE_UNIT = {
"CELSIUS": UnitOfTemperature.CELSIUS,
"FAHRENHEIT": UnitOfTemperature.FAHRENHEIT,
}
@dataclass(frozen=True, kw_only=True)
class ElectroluxSensorDescription(SensorEntityDescription):
"""Custom sensor description for Electrolux sensors."""
value_fn: Callable[..., StateType]
exists_fn: Callable[[ApplianceData], bool] = lambda *args: True
feature_name: str | None = None
known_values: set[str] | None = None
OVEN_ELECTROLUX_SENSORS: tuple[ElectroluxSensorDescription, ...] = (
ElectroluxSensorDescription(
key="appliance_state",
translation_key="appliance_state",
value_fn=lambda appliance: appliance.get_current_appliance_state(),
device_class=SensorDeviceClass.ENUM,
feature_name=APPLIANCE_STATE,
exists_fn=lambda appliance: appliance.is_feature_supported(APPLIANCE_STATE),
known_values={
"alarm",
"delayed_start",
"end_of_cycle",
"idle",
"off",
"paused",
"ready_to_start",
"running",
},
),
ElectroluxSensorDescription(
key="food_probe_state",
translation_key="food_probe_state",
value_fn=lambda appliance: appliance.get_current_food_probe_insertion_state(),
device_class=SensorDeviceClass.ENUM,
feature_name=FOOD_PROBE_STATE,
exists_fn=lambda appliance: appliance.is_feature_supported(FOOD_PROBE_STATE),
known_values={
"inserted",
"not_inserted",
},
),
ElectroluxSensorDescription(
key="remote_control",
translation_key="remote_control",
value_fn=lambda appliance: appliance.get_current_remote_control(),
device_class=SensorDeviceClass.ENUM,
feature_name=REMOTE_CONTROL,
exists_fn=lambda appliance: appliance.is_feature_supported(REMOTE_CONTROL),
known_values={
"disabled",
"enabled",
"not_safety_relevant_enabled",
"temporary_locked",
},
),
)
OVEN_TEMPERATURE_ELECTROLUX_SENSORS: tuple[ElectroluxSensorDescription, ...] = (
ElectroluxSensorDescription(
key="food_probe_temperature",
translation_key="food_probe_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda appliance, temp_unit=None: (
appliance.get_current_display_food_probe_temperature_f()
if temp_unit == UnitOfTemperature.FAHRENHEIT
else appliance.get_current_display_food_probe_temperature_c()
),
exists_fn=lambda appliance: appliance.is_feature_supported(
[DISPLAY_FOOD_PROBE_TEMPERATURE_F, DISPLAY_FOOD_PROBE_TEMPERATURE_C]
),
),
ElectroluxSensorDescription(
key="display_temperature",
translation_key="display_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda appliance, temp_unit=None: (
appliance.get_current_display_temperature_f()
if temp_unit == UnitOfTemperature.FAHRENHEIT
else appliance.get_current_display_temperature_c()
),
exists_fn=lambda appliance: appliance.is_feature_supported(
[DISPLAY_TEMPERATURE_C, DISPLAY_TEMPERATURE_F]
),
),
)
def build_entities_for_appliance(
appliance_data: ApplianceData,
coordinators: dict[str, ElectroluxDataUpdateCoordinator],
) -> list[ElectroluxBaseEntity]:
"""Return all entities for a single appliance."""
appliance = appliance_data.appliance
coordinator = coordinators[appliance.applianceId]
entities: list[ElectroluxBaseEntity] = []
if isinstance(appliance_data, OVAppliance):
entities.extend(
ElectroluxSensor(appliance_data, coordinator, description)
for description in OVEN_ELECTROLUX_SENSORS
if description.exists_fn(appliance_data)
)
entities.extend(
ElectroluxTemperatureSensor(appliance_data, coordinator, description)
for description in OVEN_TEMPERATURE_ELECTROLUX_SENSORS
if description.exists_fn(appliance_data)
)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: ElectroluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set sensor for Electrolux Integration."""
await async_setup_entities_helper(
hass, entry, async_add_entities, build_entities_for_appliance
)
class ElectroluxSensor(ElectroluxBaseEntity[ApplianceData], SensorEntity):
"""Representation of a generic sensor for Electrolux appliances."""
entity_description: ElectroluxSensorDescription
def __init__(
self,
appliance_data: ApplianceData,
coordinator: ElectroluxDataUpdateCoordinator,
description: ElectroluxSensorDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(appliance_data, coordinator, description.key)
if (
description.feature_name is not None
and description.known_values is not None
):
options = appliance_data.get_feature_state_string_options(
description.feature_name
)
snake_case_options = [
snake_case_option
for option in options
if (snake_case_option := _convert_to_snake_case(option))
in description.known_values
]
if len(snake_case_options) > 0:
self._attr_options = snake_case_options
self.entity_description = description
def _update_attr_state(self) -> bool:
new_value = self._get_value()
if isinstance(new_value, str):
new_value = _convert_to_snake_case(new_value)
if self.entity_description.known_values:
new_value = _map_to_known_value(
self.entity_description.known_values,
self.entity_description.key,
new_value,
)
if self._attr_native_value != new_value:
self._attr_native_value = new_value
return True
return False
def _get_value(self) -> StateType:
return self.entity_description.value_fn(self._appliance_data)
class ElectroluxTemperatureSensor(ElectroluxSensor):
"""Representation of a temperature sensor for Electrolux appliances."""
def __init__(
self,
appliance_data: ApplianceData,
coordinator: ElectroluxDataUpdateCoordinator,
description: ElectroluxSensorDescription,
) -> None:
"""Initialize the sensor."""
self._appliance = cast(OVAppliance | CRAppliance, appliance_data)
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
super().__init__(appliance_data, coordinator, description)
def _get_value(self) -> StateType:
temp_unit = self._get_temperature_unit()
temp_value: float | None = cast(
float | None,
self.entity_description.value_fn(self._appliance_data, temp_unit=temp_unit),
)
if temp_value is None:
return None
return TemperatureConverter.convert(
temp_value, temp_unit, UnitOfTemperature.CELSIUS
)
def _get_temperature_unit(self) -> UnitOfTemperature:
temp_unit = self._appliance.get_current_temperature_unit()
if temp_unit is not None:
temp_unit = temp_unit.upper()
return ELECTROLUX_TO_HA_TEMPERATURE_UNIT.get(
temp_unit, UnitOfTemperature.CELSIUS
)
def _convert_to_snake_case(x: str) -> str:
"""Converts a string to snake case."""
lower_case = x.lower()
return "".join([_convert_char_to_snake_case(char) for char in lower_case])
def _convert_char_to_snake_case(char: str) -> str:
if char.isspace():
return "_"
return char
def _map_to_known_value(
known_values: set[str], entity_key: str, value: str
) -> str | None:
"""Return provided value if it is known, otherwise log warn message and return None."""
if value not in known_values:
_LOGGER.warning(
"An unknown value %s was reported for a sensor of the Electrolux integration. "
"Please report it for the integration, and include the following information: "
'entity key="%s", reported value="%s"',
value,
entity_key,
value,
)
return None
return value
@@ -0,0 +1,66 @@
{
"config": {
"abort": {
"already_configured": "This Electrolux account is already configured."
},
"error": {
"cannot_connect": "Unable to connect to the Electrolux API. Please check credentials and try again.",
"invalid_auth": "Authentication failed. Please check your credentials."
},
"step": {
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"refresh_token": "Refresh token"
},
"data_description": {
"access_token": "The access token from Electrolux Group for Developer.",
"api_key": "Your Electrolux Group for Developer API key.",
"refresh_token": "The refresh token used to renew your access token."
},
"description": "Please go to the [developer portal]({portal_link}) to generate new access and refresh tokens, then paste them below.",
"title": "Configure your Electrolux Group account"
}
}
},
"entity": {
"sensor": {
"appliance_state": {
"name": "Appliance state",
"state": {
"alarm": "Alarm",
"delayed_start": "Delayed start",
"end_of_cycle": "Cycle ended",
"idle": "[%key:common::state::idle%]",
"off": "[%key:common::state::off%]",
"paused": "[%key:common::state::paused%]",
"ready_to_start": "Ready to start",
"running": "Running"
}
},
"display_temperature": {
"name": "Current temperature"
},
"food_probe_state": {
"name": "Food probe state",
"state": {
"inserted": "Inserted",
"not_inserted": "Not inserted"
}
},
"food_probe_temperature": {
"name": "Food probe temperature"
},
"remote_control": {
"name": "Remote control",
"state": {
"disabled": "[%key:common::state::disabled%]",
"enabled": "[%key:common::state::enabled%]",
"not_safety_relevant_enabled": "Not safety relevant enabled",
"temporary_locked": "Temporarily locked"
}
}
}
}
}
+3 -3
View File
@@ -150,9 +150,9 @@ class ElevenLabsSTTEntity(SpeechToTextEntity):
raw_pcm_compatible = (
metadata.codec == AudioCodecs.PCM
and metadata.sample_rate is AudioSampleRates.SAMPLERATE_16000
and metadata.channel is AudioChannels.CHANNEL_MONO
and metadata.bit_rate is AudioBitRates.BITRATE_16
and metadata.sample_rate == AudioSampleRates.SAMPLERATE_16000
and metadata.channel == AudioChannels.CHANNEL_MONO
and metadata.bit_rate == AudioBitRates.BITRATE_16
)
if raw_pcm_compatible:
file_format = "pcm_s16le_16"
@@ -50,5 +50,5 @@ class ElkBinarySensor(ElkAttachedEntity, BinarySensorEntity):
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
# Zone in NORMAL state is OFF; any other state is ON
self._attr_is_on = bool(
self._element.logical_status is not ZoneLogicalStatus.NORMAL
self._element.logical_status != ZoneLogicalStatus.NORMAL
)
+2 -2
View File
@@ -104,7 +104,7 @@ class ElkThermostat(ElkEntity, ClimateEntity):
ThermostatMode.EMERGENCY_HEAT,
):
return self._element.heat_setpoint
if self._element.mode is ThermostatMode.COOL:
if self._element.mode == ThermostatMode.COOL:
return self._element.cool_setpoint
return None
@@ -162,6 +162,6 @@ class ElkThermostat(ElkEntity, ClimateEntity):
self._attr_hvac_mode = ELK_TO_HASS_HVAC_MODES[self._element.mode]
if (
self._attr_hvac_mode == HVACMode.OFF
and self._element.fan is ThermostatFan.ON
and self._element.fan == ThermostatFan.ON
):
self._attr_hvac_mode = HVACMode.FAN_ONLY
+1 -1
View File
@@ -56,7 +56,7 @@ class ElkNumberSetting(ElkAttachedEntity, NumberEntity):
def __init__(self, element: Setting, elk: Any, elk_data: ELKM1Data) -> None:
"""Initialize the number setting."""
super().__init__(element, elk, elk_data)
if element.value_format is SettingFormat.TIMER:
if element.value_format == SettingFormat.TIMER:
self._attr_device_class = NumberDeviceClass.DURATION
self._attr_native_unit_of_measurement = UnitOfTime.SECONDS
+6 -6
View File
@@ -75,7 +75,7 @@ async def async_setup_entry(
for setting in elk.settings:
setting = cast(Setting, setting)
domain = (
"time" if setting.value_format is SettingFormat.TIME_OF_DAY else "number"
"time" if setting.value_format == SettingFormat.TIME_OF_DAY else "number"
)
orig_unique_id = generate_unique_id(elk_data.prefix, setting)
@@ -288,7 +288,7 @@ class ElkZone(ElkSensor):
@property
def temperature_unit(self) -> str | None:
"""Return the temperature unit."""
if self._element.definition is ZoneType.TEMPERATURE:
if self._element.definition == ZoneType.TEMPERATURE:
return self._temperature_unit
return None
@@ -305,18 +305,18 @@ class ElkZone(ElkSensor):
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
if self._element.definition is ZoneType.TEMPERATURE:
if self._element.definition == ZoneType.TEMPERATURE:
return self._temperature_unit
if self._element.definition is ZoneType.ANALOG_ZONE:
if self._element.definition == ZoneType.ANALOG_ZONE:
return UnitOfElectricPotential.VOLT
return None
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
if self._element.definition is ZoneType.TEMPERATURE:
if self._element.definition == ZoneType.TEMPERATURE:
self._attr_native_value = temperature_to_state(
self._element.temperature, UNDEFINED_TEMPERATURE
)
elif self._element.definition is ZoneType.ANALOG_ZONE:
elif self._element.definition == ZoneType.ANALOG_ZONE:
self._attr_native_value = f"{self._element.voltage}"
else:
self._attr_native_value = pretty_const(self._element.logical_status.name)
+1 -1
View File
@@ -66,7 +66,7 @@ class ElkThermostatEMHeat(ElkEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""Get the current emergency heat status."""
return self._element.mode is ThermostatMode.EMERGENCY_HEAT
return self._element.mode == ThermostatMode.EMERGENCY_HEAT
def _elk_set(self, mode: ThermostatMode) -> None:
"""Set the thermostat mode."""
+1 -1
View File
@@ -30,7 +30,7 @@ async def async_setup_entry(
time_settings = [
setting
for setting in cast(list[Setting], elk.settings)
if setting.value_format is SettingFormat.TIME_OF_DAY
if setting.value_format == SettingFormat.TIME_OF_DAY
]
create_elk_entities(
@@ -96,7 +96,7 @@ def __get_coordinator(call: ServiceCall) -> EnergyZeroDataUpdateCoordinator:
"config_entry": entry_id,
},
)
if entry.state is not ConfigEntryState.LOADED:
if entry.state != ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unloaded_config_entry",
@@ -125,7 +125,7 @@ async def __get_prices(
data: Electricity | Gas
if price_type is PriceType.GAS:
if price_type == PriceType.GAS:
data = await coordinator.energyzero.get_gas_prices_legacy(
start_date=start,
end_date=end,
+1 -1
View File
@@ -23,7 +23,7 @@
"service": "mdi:refresh"
},
"set_dhw_override": {
"service": "mdi:water-boiler"
"service": "mdi:water-heater"
},
"set_system_mode": {
"service": "mdi:pencil"
@@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FastdotcomConfigEntry) -
async def _async_finish_startup(hass: HomeAssistant) -> None:
"""Run this only when HA has finished its startup."""
if entry.state is ConfigEntryState.LOADED:
if entry.state == ConfigEntryState.LOADED:
await coordinator.async_refresh()
else:
await coordinator.async_config_entry_first_refresh()
+1 -1
View File
@@ -16,7 +16,7 @@ class DeviceType(Enum):
GAME_CONSOLE = "mdi:nintendo-game-boy"
STREAMING_DONGLE = "mdi:cast"
LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker"
DISC_PLAYER = "mdi:disc-player"
DISC_PLAYER = "mdi:disk-player"
REMOTE_CONTROL = "mdi:remote-tv"
RADIO = "mdi:radio"
PHOTO_CAMERA = PHOTOS = "mdi:camera"
@@ -284,7 +284,7 @@ class FishAudioSubentryFlowHandler(ConfigSubentryFlow):
) -> SubentryFlowResult:
"""Manage initial options."""
entry = self._get_entry()
if entry.state is not ConfigEntryState.LOADED:
if entry.state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
self.client = entry.runtime_data
+1 -1
View File
@@ -109,7 +109,7 @@ def setup_service(hass: HomeAssistant) -> None:
entry: FlumeConfigEntry | None = hass.config_entries.async_get_entry(entry_id)
if not entry:
raise ValueError(f"Invalid config entry: {entry_id}")
if entry.state is not ConfigEntryState.LOADED:
if not entry.state == ConfigEntryState.LOADED:
raise ValueError(f"Config entry not loaded: {entry_id}")
return {
"notifications": entry.runtime_data.notifications_coordinator.notifications # type: ignore[dict-item]
@@ -136,7 +136,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN):
ConfigEntryState.SETUP_IN_PROGRESS,
ConfigEntryState.NOT_LOADED,
)
) or entry.state is ConfigEntryState.SETUP_RETRY:
) or entry.state == ConfigEntryState.SETUP_RETRY:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
else:
async_dispatcher_send(
+1 -1
View File
@@ -51,7 +51,7 @@ async def async_setup_entry(
entry.data.get(CONF_NAME, entry.title)
base_unique_id = entry.unique_id or entry.entry_id
if device.device_type is DeviceType.Switch:
if device.device_type == DeviceType.Switch:
entities.append(FluxPowerStateSelect(coordinator.device, entry))
if device.operating_modes:
entities.append(
+1 -1
View File
@@ -32,7 +32,7 @@ async def async_setup_entry(
entities: list[FluxSwitch | FluxRemoteAccessSwitch | FluxMusicSwitch] = []
base_unique_id = entry.unique_id or entry.entry_id
if coordinator.device.device_type is DeviceType.Switch:
if coordinator.device.device_type == DeviceType.Switch:
entities.append(FluxSwitch(coordinator, base_unique_id, None))
if entry.data.get(CONF_REMOTE_ACCESS_HOST):
+1 -9
View File
@@ -9,12 +9,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfDataRate,
UnitOfTemperature,
)
from homeassistant.const import PERCENTAGE, UnitOfDataRate, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -50,7 +45,6 @@ CALL_SENSORS: tuple[SensorEntityDescription, ...] = (
translation_key="missed",
native_unit_of_measurement="calls",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
@@ -59,7 +53,6 @@ DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = (
key="partition_free_space",
translation_key="partition_free_space",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
@@ -87,7 +80,6 @@ async def async_setup_entry(
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
for sensor_id, sensor_name in router.sensors_temperature_names.items()
@@ -6,6 +6,7 @@ from typing import Any
from freebox_api.exceptions import InsufficientPermissionsError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -18,6 +19,7 @@ SWITCH_DESCRIPTIONS = [
SwitchEntityDescription(
key="wifi",
translation_key="wifi",
entity_category=EntityCategory.CONFIG,
)
]
+3 -3
View File
@@ -212,7 +212,7 @@ class FroniusSolarNet:
inverter_info=_inverter_info,
config_entry=self.config_entry,
)
if self.config_entry.state is ConfigEntryState.LOADED:
if self.config_entry.state == ConfigEntryState.LOADED:
await _coordinator.async_refresh()
else:
await _coordinator.async_config_entry_first_refresh()
@@ -220,7 +220,7 @@ class FroniusSolarNet:
# Only for re-scans. Initial setup adds entities
# through sensor.async_setup_entry
if self.config_entry.state is ConfigEntryState.LOADED:
if self.config_entry.state == ConfigEntryState.LOADED:
async_dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator)
_LOGGER.debug(
@@ -235,7 +235,7 @@ class FroniusSolarNet:
try:
_inverter_info = await self.fronius.inverter_info()
except FroniusError as err:
if self.config_entry.state is ConfigEntryState.LOADED:
if self.config_entry.state == ConfigEntryState.LOADED:
# During a re-scan we will attempt again as per schedule.
_LOGGER.debug("Re-scan failed for %s", self.host)
return inverter_infos
@@ -42,7 +42,7 @@ async def _collect_coordinators(
raise HomeAssistantError(f"Device '{target}' not found in device registry")
coordinators = list[FullyKioskDataUpdateCoordinator]()
for config_entry in config_entries:
if config_entry.state is not ConfigEntryState.LOADED:
if config_entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(f"{config_entry.title} is not loaded")
coordinators.append(config_entry.runtime_data)
return coordinators
+1 -1
View File
@@ -2,7 +2,7 @@
"entity": {
"button": {
"sync_clock": {
"default": "mdi:clock-check"
"default": "mdi:clock-sync"
}
},
"number": {
@@ -75,7 +75,7 @@ async def async_setup_entry(
mfg_data = await async_get_manufacturer_data({address})
product_type = mfg_data[address].product_type
if product_type is ProductType.UNKNOWN:
if product_type == ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
client = Client(get_connection(hass, address), product_type)
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioghost"],
"quality_scale": "gold",
"requirements": ["aioghost==0.4.16"]
"requirements": ["aioghost==0.4.0"]
}
+3 -3
View File
@@ -143,7 +143,7 @@ def _get_entity_descriptions(
local_sync = True
if (
search := data.get(CONF_SEARCH)
) or calendar_item.access_role is AccessRole.FREE_BUSY_READER:
) or calendar_item.access_role == AccessRole.FREE_BUSY_READER:
read_only = True
local_sync = False
entity_description = GoogleCalendarEntityDescription(
@@ -386,14 +386,14 @@ class GoogleCalendarEntity(
"""Return True if the event is visible and not declined."""
if any(
attendee.is_self and attendee.response_status is ResponseStatus.DECLINED
attendee.is_self and attendee.response_status == ResponseStatus.DECLINED
for attendee in event.attendees
):
return False
# Calendar enttiy may be limited to a specific event type
if (
self.entity_description.event_type is not None
and self.entity_description.event_type is not event.event_type
and self.entity_description.event_type != event.event_type
):
return False
# Default calendar entity omits the special types but includes all the others
@@ -247,7 +247,7 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
user_input: dict[str, Any] | None = None,
) -> SubentryFlowResult:
"""Handle the location step."""
if self._get_entry().state is not ConfigEntryState.LOADED:
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
errors: dict[str, str] = {}
@@ -225,7 +225,7 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
) -> SubentryFlowResult:
"""Set conversation options."""
# abort if entry is not loaded
if self._get_entry().state is not ConfigEntryState.LOADED:
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
errors: dict[str, str] = {}
@@ -754,7 +754,7 @@ async def async_prepare_files_for_prompt(
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
)
if uploaded_file.state is FileState.FAILED:
if uploaded_file.state == FileState.FAILED:
raise HomeAssistantError(
f"File `{uploaded_file.name}` processing"
" failed, reason:"
@@ -766,7 +766,7 @@ async def async_prepare_files_for_prompt(
tasks = [
asyncio.create_task(wait_for_file_processing(part))
for part in prompt_parts
if part.state is not FileState.ACTIVE
if part.state != FileState.ACTIVE
]
async with asyncio.timeout(TIMEOUT_MILLIS / 1000):
await asyncio.gather(*tasks)
@@ -237,7 +237,7 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
user_input: dict[str, Any] | None = None,
) -> SubentryFlowResult:
"""Handle the location step."""
if self._get_entry().state is not ConfigEntryState.LOADED:
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
errors: dict[str, str] = {}
@@ -26,7 +26,7 @@ def _get_coordinators(
coordinators: dict[str, GrowattCoordinator] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.state is not ConfigEntryState.LOADED:
if entry.state != ConfigEntryState.LOADED:
continue
for coord in entry.runtime_data.devices.values():
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["guntamatic==1.9.0"]
"requirements": ["guntamatic==1.8.0"]
}
+2 -2
View File
@@ -247,7 +247,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
def release_url(self) -> str | None:
"""URL to the full release notes of the latest version available."""
version = AwesomeVersion(self.latest_version)
if version.dev or version.strategy is AwesomeVersionStrategy.UNKNOWN:
if version.dev or version.strategy == AwesomeVersionStrategy.UNKNOWN:
return "https://github.com/home-assistant/operating-system/commits/dev"
return (
f"https://github.com/home-assistant/operating-system/releases/tag/{version}"
@@ -304,7 +304,7 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
def release_url(self) -> str | None:
"""URL to the full release notes of the latest version available."""
version = AwesomeVersion(self.latest_version)
if version.dev or version.strategy is AwesomeVersionStrategy.UNKNOWN:
if version.dev or version.strategy == AwesomeVersionStrategy.UNKNOWN:
return "https://github.com/home-assistant/supervisor/commits/main"
return f"https://github.com/home-assistant/supervisor/releases/tag/{version}"
+1 -1
View File
@@ -150,7 +150,7 @@ def _get_controller(hass: HomeAssistant) -> Heos:
hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN)
)
if not entry or entry.state is not ConfigEntryState.LOADED:
if not entry or not entry.state == ConfigEntryState.LOADED:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="integration_not_loaded"
)
+2
View File
@@ -2,6 +2,8 @@
from homeassistant.const import Platform
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODE = "mode"
ATTR_TIME_PERIOD = "time_period"
ATTR_ONOFF = "on_off"
CONF_CODE = "2fa"
+1 -1
View File
@@ -12,12 +12,12 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from . import HiveConfigEntry, refresh_system
from .const import ATTR_MODE
from .entity import HiveEntity
PARALLEL_UPDATES = 0

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