diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index cc100c48fd8..f4e4de97e78 100644
--- a/.github/workflows/builder.yml
+++ b/.github/workflows/builder.yml
@@ -94,7 +94,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
- uses: dawidd6/action-download-artifact@v6
+ uses: dawidd6/action-download-artifact@v7
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -105,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
- uses: dawidd6/action-download-artifact@v6
+ uses: dawidd6/action-download-artifact@v7
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package
@@ -509,7 +509,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
+ uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -522,7 +522,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
+ uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index dc9270ebe9a..34c2fa838a6 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 11
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9
- HA_SHORT_VERSION: "2024.12"
+ HA_SHORT_VERSION: "2025.1"
DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12', '3.13']"
# 10.3 is the oldest supported version
@@ -485,7 +485,6 @@ jobs:
uses: actions/cache@v4.1.2
with:
path: venv
- lookup-only: true
key: >-
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
@@ -531,6 +530,26 @@ jobs:
python -m script.gen_requirements_all ci
uv pip install -r requirements_all_pytest.txt -r requirements_test.txt
uv pip install -e . --config-settings editable_mode=compat
+ - name: Dump pip freeze
+ run: |
+ python -m venv venv
+ . venv/bin/activate
+ python --version
+ uv pip freeze >> pip_freeze.txt
+ - name: Upload pip_freeze artifact
+ uses: actions/upload-artifact@v4.4.3
+ with:
+ name: pip-freeze-${{ matrix.python-version }}
+ path: pip_freeze.txt
+ overwrite: true
+ - name: Remove pip_freeze
+ run: rm pip_freeze.txt
+ - name: Remove generated requirements_all
+ if: steps.cache-venv.outputs.cache-hit != 'true'
+ run: rm requirements_all_pytest.txt requirements_all_wheels_*.txt
+ - name: Check dirty
+ run: |
+ ./script/check_dirty
hassfest:
name: Check hassfest
@@ -819,6 +838,12 @@ jobs:
needs:
- info
- base
+ - gen-requirements-all
+ - hassfest
+ - lint-other
+ - lint-ruff
+ - lint-ruff-format
+ - mypy
name: Split tests for full run
steps:
- name: Install additional OS dependencies
@@ -1248,7 +1273,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
- uses: codecov/codecov-action@v5.0.2
+ uses: codecov/codecov-action@v5.0.7
with:
fail_ci_if_error: true
flags: full-suite
@@ -1386,7 +1411,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
- uses: codecov/codecov-action@v5.0.2
+ uses: codecov/codecov-action@v5.0.7
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index b9ccece34b9..4977139f5dc 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3.27.4
+ uses: github/codeql-action/init@v3.27.5
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3.27.4
+ uses: github/codeql-action/analyze@v3.27.5
with:
category: "/language:python"
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index b9f54bba081..749f95fa922 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -143,7 +143,7 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev"
- skip-binary: aiohttp;multidict;yarl
+ skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements.txt"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f2b2a77ae17..9947ee05ad1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.7.4
+ rev: v0.8.1
hooks:
- id: ruff
args:
@@ -83,7 +83,7 @@ repos:
pass_filenames: false
language: script
types: [text]
- files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
+ files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(quality_scale)\.yaml|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
diff --git a/.strict-typing b/.strict-typing
index b0fd74bce54..ed698c26ea0 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -41,6 +41,7 @@ homeassistant.util.unit_system
# --- Add components below this line ---
homeassistant.components
homeassistant.components.abode.*
+homeassistant.components.acaia.*
homeassistant.components.accuweather.*
homeassistant.components.acer_projector.*
homeassistant.components.acmeda.*
@@ -385,6 +386,7 @@ homeassistant.components.recollect_waste.*
homeassistant.components.recorder.*
homeassistant.components.remote.*
homeassistant.components.renault.*
+homeassistant.components.reolink.*
homeassistant.components.repairs.*
homeassistant.components.rest.*
homeassistant.components.rest_command.*
@@ -404,6 +406,7 @@ homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsungtv.*
homeassistant.components.scene.*
homeassistant.components.schedule.*
+homeassistant.components.schlage.*
homeassistant.components.scrape.*
homeassistant.components.script.*
homeassistant.components.search.*
@@ -437,6 +440,7 @@ homeassistant.components.starlink.*
homeassistant.components.statistics.*
homeassistant.components.steamist.*
homeassistant.components.stookalert.*
+homeassistant.components.stookwijzer.*
homeassistant.components.stream.*
homeassistant.components.streamlabswater.*
homeassistant.components.stt.*
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 2495249af66..1f95c5eef8f 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -56,6 +56,20 @@
},
"problemMatcher": []
},
+ {
+ "label": "Pre-commit",
+ "type": "shell",
+ "command": "pre-commit run --show-diff-on-failure",
+ "group": {
+ "kind": "test",
+ "isDefault": true
+ },
+ "presentation": {
+ "reveal": "always",
+ "panel": "new"
+ },
+ "problemMatcher": []
+ },
{
"label": "Pylint",
"type": "shell",
@@ -87,6 +101,22 @@
},
"problemMatcher": []
},
+ {
+ "label": "Update syrupy snapshots",
+ "detail": "Update syrupy snapshots for a given integration.",
+ "type": "shell",
+ "command": "python3 -m pytest ./tests/components/${input:integrationName} --snapshot-update",
+ "dependsOn": ["Compile English translations"],
+ "group": {
+ "kind": "test",
+ "isDefault": true
+ },
+ "presentation": {
+ "reveal": "always",
+ "panel": "new"
+ },
+ "problemMatcher": []
+ },
{
"label": "Generate Requirements",
"type": "shell",
diff --git a/CODEOWNERS b/CODEOWNERS
index 5bea90913b0..7755c3eb4ae 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -588,8 +588,8 @@ build.json @home-assistant/supervisor
/tests/components/group/ @home-assistant/core
/homeassistant/components/guardian/ @bachya
/tests/components/guardian/ @bachya
-/homeassistant/components/habitica/ @ASMfreaK @leikoilja @tr4nt0r
-/tests/components/habitica/ @ASMfreaK @leikoilja @tr4nt0r
+/homeassistant/components/habitica/ @tr4nt0r
+/tests/components/habitica/ @tr4nt0r
/homeassistant/components/hardkernel/ @home-assistant/core
/tests/components/hardkernel/ @home-assistant/core
/homeassistant/components/hardware/ @home-assistant/core
@@ -1004,6 +1004,8 @@ build.json @home-assistant/supervisor
/tests/components/nice_go/ @IceBotYT
/homeassistant/components/nightscout/ @marciogranzotto
/tests/components/nightscout/ @marciogranzotto
+/homeassistant/components/niko_home_control/ @VandeurenGlenn
+/tests/components/niko_home_control/ @VandeurenGlenn
/homeassistant/components/nilu/ @hfurubotten
/homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum
@@ -1573,6 +1575,8 @@ build.json @home-assistant/supervisor
/tests/components/unifi/ @Kane610
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifiled/ @florisvdk
+/homeassistant/components/unifiprotect/ @RaHehl
+/tests/components/unifiprotect/ @RaHehl
/homeassistant/components/upb/ @gwww
/tests/components/upb/ @gwww
/homeassistant/components/upc_connect/ @pvizeli @fabaff
diff --git a/Dockerfile b/Dockerfile
index 15574192093..61d64212b40 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,7 +13,7 @@ ENV \
ARG QEMU_CPU
# Install uv
-RUN pip3 install uv==0.5.0
+RUN pip3 install uv==0.5.4
WORKDIR /usr/src
diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py
index 3aa3ac63764..464df006f5f 100644
--- a/homeassistant/auth/jwt_wrapper.py
+++ b/homeassistant/auth/jwt_wrapper.py
@@ -18,7 +18,7 @@ from homeassistant.util.json import json_loads
JWT_TOKEN_CACHE_SIZE = 16
MAX_TOKEN_SIZE = 8192
-_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss")
+_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss", "sub", "jti")
_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | {
"require": []
diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py
index 1c0186e1003..01b6c7f568f 100644
--- a/homeassistant/components/abode/config_flow.py
+++ b/homeassistant/components/abode/config_flow.py
@@ -112,9 +112,6 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=vol.Schema(self.data_schema)
diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json
index 9f5806d544a..c1ffb9f699b 100644
--- a/homeassistant/components/abode/manifest.json
+++ b/homeassistant/components/abode/manifest.json
@@ -9,5 +9,6 @@
},
"iot_class": "cloud_push",
"loggers": ["jaraco.abode", "lomond"],
- "requirements": ["jaraco.abode==6.2.1"]
+ "requirements": ["jaraco.abode==6.2.1"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json
index 4b98b69eb19..b3d57042754 100644
--- a/homeassistant/components/abode/strings.json
+++ b/homeassistant/components/abode/strings.json
@@ -28,7 +28,6 @@
"invalid_mfa_code": "Invalid MFA code"
},
"abort": {
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py
index 50671eecbba..a41233bfc17 100644
--- a/homeassistant/components/acaia/button.py
+++ b/homeassistant/components/acaia/button.py
@@ -13,6 +13,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
+PARALLEL_UPDATES = 0
+
@dataclass(kw_only=True, frozen=True)
class AcaiaButtonEntityDescription(ButtonEntityDescription):
diff --git a/homeassistant/components/acaia/config_flow.py b/homeassistant/components/acaia/config_flow.py
index 36727059c8a..fb2639fc886 100644
--- a/homeassistant/components/acaia/config_flow.py
+++ b/homeassistant/components/acaia/config_flow.py
@@ -42,7 +42,7 @@ class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
- mac = format_mac(user_input[CONF_ADDRESS])
+ mac = user_input[CONF_ADDRESS]
try:
is_new_style_scale = await is_new_scale(mac)
except AcaiaDeviceNotFound:
@@ -53,12 +53,12 @@ class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
except AcaiaUnknownDevice:
return self.async_abort(reason="unsupported_device")
else:
- await self.async_set_unique_id(mac)
+ await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
- title=self._discovered_devices[user_input[CONF_ADDRESS]],
+ title=self._discovered_devices[mac],
data={
CONF_ADDRESS: mac,
CONF_IS_NEW_STYLE_SCALE: is_new_style_scale,
@@ -99,10 +99,10 @@ class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a discovered Bluetooth device."""
- self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address)
+ self._discovered[CONF_ADDRESS] = discovery_info.address
self._discovered[CONF_NAME] = discovery_info.name
- await self.async_set_unique_id(mac)
+ await self.async_set_unique_id(format_mac(discovery_info.address))
self._abort_if_unique_id_configured()
try:
diff --git a/homeassistant/components/acaia/diagnostics.py b/homeassistant/components/acaia/diagnostics.py
new file mode 100644
index 00000000000..2d9f4511804
--- /dev/null
+++ b/homeassistant/components/acaia/diagnostics.py
@@ -0,0 +1,31 @@
+"""Diagnostics support for Acaia."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import AcaiaConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant,
+ entry: AcaiaConfigEntry,
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator = entry.runtime_data
+ scale = coordinator.scale
+
+ # collect all data sources
+ return {
+ "model": scale.model,
+ "device_state": (
+ asdict(scale.device_state) if scale.device_state is not None else ""
+ ),
+ "mac": scale.mac,
+ "last_disconnect_time": scale.last_disconnect_time,
+ "timer": scale.timer,
+ "weight": scale.weight,
+ }
diff --git a/homeassistant/components/acaia/entity.py b/homeassistant/components/acaia/entity.py
index 8a2108d2687..bef1ac313ca 100644
--- a/homeassistant/components/acaia/entity.py
+++ b/homeassistant/components/acaia/entity.py
@@ -2,7 +2,11 @@
from dataclasses import dataclass
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.device_registry import (
+ CONNECTION_BLUETOOTH,
+ DeviceInfo,
+ format_mac,
+)
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -25,13 +29,15 @@ class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]):
super().__init__(coordinator)
self.entity_description = entity_description
self._scale = coordinator.scale
- self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}"
+ formatted_mac = format_mac(self._scale.mac)
+ self._attr_unique_id = f"{formatted_mac}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, self._scale.mac)},
+ identifiers={(DOMAIN, formatted_mac)},
manufacturer="Acaia",
model=self._scale.model,
suggested_area="Kitchen",
+ connections={(CONNECTION_BLUETOOTH, self._scale.mac)},
)
@property
diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json
index c907a70a38e..3f3e1c14d58 100644
--- a/homeassistant/components/acaia/manifest.json
+++ b/homeassistant/components/acaia/manifest.json
@@ -25,5 +25,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioacaia"],
- "requirements": ["aioacaia==0.1.6"]
+ "requirements": ["aioacaia==0.1.10"]
}
diff --git a/homeassistant/components/acaia/quality_scale.yaml b/homeassistant/components/acaia/quality_scale.yaml
new file mode 100644
index 00000000000..9f9f8da8d5d
--- /dev/null
+++ b/homeassistant/components/acaia/quality_scale.yaml
@@ -0,0 +1,106 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ No explicit event subscriptions.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup:
+ status: exempt
+ comment: |
+ Device is expected to be offline most of the time, but needs to connect quickly once available.
+ unique-config-entry: done
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ Handled by coordinator.
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ No authentication required.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No IP discovery.
+ discovery:
+ status: done
+ comment: |
+ Bluetooth discovery.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ No noisy/non-essential entities.
+ entity-translations: done
+ exception-translations:
+ status: exempt
+ comment: |
+ No custom exceptions.
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: |
+ Only parameter that could be changed (MAC = unique_id) would force a new config entry.
+ repair-issues:
+ status: exempt
+ comment: |
+ No repairs/issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ Bluetooth connection.
+ strict-typing: done
diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py
index 49ee101b4a2..6e6ce6afcb8 100644
--- a/homeassistant/components/acaia/sensor.py
+++ b/homeassistant/components/acaia/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
SensorExtraStoredData,
SensorStateClass,
)
-from homeassistant.const import PERCENTAGE, UnitOfMass
+from homeassistant.const import PERCENTAGE, UnitOfMass, UnitOfVolumeFlowRate
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -49,6 +49,14 @@ SENSORS: tuple[AcaiaSensorEntityDescription, ...] = (
),
value_fn=lambda scale: scale.weight,
),
+ AcaiaDynamicUnitSensorEntityDescription(
+ key="flow_rate",
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND,
+ suggested_display_precision=1,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda scale: scale.flow_rate,
+ ),
)
RESTORE_SENSORS: tuple[AcaiaSensorEntityDescription, ...] = (
AcaiaSensorEntityDescription(
diff --git a/homeassistant/components/acaia/strings.json b/homeassistant/components/acaia/strings.json
index 0e52e2c0b2f..e0e97b7c2ff 100644
--- a/homeassistant/components/acaia/strings.json
+++ b/homeassistant/components/acaia/strings.json
@@ -18,6 +18,9 @@
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "address": "Select Acaia scale you want to set up"
}
}
}
diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json
index 1c21a72ee1a..75f4a265b5f 100644
--- a/homeassistant/components/accuweather/manifest.json
+++ b/homeassistant/components/accuweather/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
- "quality_scale": "platinum",
"requirements": ["accuweather==4.0.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/acer_projector/manifest.json b/homeassistant/components/acer_projector/manifest.json
index 58a2372e42a..026374bf53d 100644
--- a/homeassistant/components/acer_projector/manifest.json
+++ b/homeassistant/components/acer_projector/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pyserial==3.5"]
}
diff --git a/homeassistant/components/actiontec/manifest.json b/homeassistant/components/actiontec/manifest.json
index ff9cf85614f..e7aa33f1baf 100644
--- a/homeassistant/components/actiontec/manifest.json
+++ b/homeassistant/components/actiontec/manifest.json
@@ -3,5 +3,6 @@
"name": "Actiontec",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/actiontec",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py
index 541f8bfc82c..c7b0f4f2f8a 100644
--- a/homeassistant/components/ads/cover.py
+++ b/homeassistant/components/ads/cover.py
@@ -37,7 +37,7 @@ STATE_KEY_POSITION = "position"
PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend(
{
- vol.Optional(CONF_ADS_VAR): cv.string,
+ vol.Required(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_ADS_VAR_POSITION): cv.string,
vol.Optional(CONF_ADS_VAR_SET_POS): cv.string,
vol.Optional(CONF_ADS_VAR_CLOSE): cv.string,
diff --git a/homeassistant/components/ads/manifest.json b/homeassistant/components/ads/manifest.json
index 86fc54ea784..683c3cb619f 100644
--- a/homeassistant/components/ads/manifest.json
+++ b/homeassistant/components/ads/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ads",
"iot_class": "local_push",
"loggers": ["pyads"],
+ "quality_scale": "legacy",
"requirements": ["pyads==3.4.0"]
}
diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json
index a07d14896eb..553a641b603 100644
--- a/homeassistant/components/advantage_air/manifest.json
+++ b/homeassistant/components/advantage_air/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/advantage_air",
"iot_class": "local_polling",
"loggers": ["advantage_air"],
- "quality_scale": "platinum",
"requirements": ["advantage-air==0.4.4"]
}
diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py
index 29bc044c67d..9ec52faec00 100644
--- a/homeassistant/components/aemet/__init__.py
+++ b/homeassistant/components/aemet/__init__.py
@@ -3,7 +3,7 @@
import logging
from aemet_opendata.exceptions import AemetError, TownNotFound
-from aemet_opendata.interface import AEMET, ConnectionOptions
+from aemet_opendata.interface import AEMET, ConnectionOptions, UpdateFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
@@ -23,9 +23,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo
api_key = entry.data[CONF_API_KEY]
latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE]
- station_updates = entry.options.get(CONF_STATION_UPDATES, True)
+ update_features: int = UpdateFeature.FORECAST
+ if entry.options.get(CONF_STATION_UPDATES, True):
+ update_features |= UpdateFeature.STATION
- options = ConnectionOptions(api_key, station_updates)
+ options = ConnectionOptions(api_key, update_features)
aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options)
try:
await aemet.select_coordinates(latitude, longitude)
diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py
index 6b2eca3f5c9..e2b0b436c8c 100644
--- a/homeassistant/components/aemet/config_flow.py
+++ b/homeassistant/components/aemet/config_flow.py
@@ -45,7 +45,7 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(f"{latitude}-{longitude}")
self._abort_if_unique_id_configured()
- options = ConnectionOptions(user_input[CONF_API_KEY], False)
+ options = ConnectionOptions(user_input[CONF_API_KEY])
aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options)
try:
await aemet.select_coordinates(latitude, longitude)
diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json
index 3696e16b437..5c9d1ff7e5a 100644
--- a/homeassistant/components/aemet/manifest.json
+++ b/homeassistant/components/aemet/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
- "requirements": ["AEMET-OpenData==0.5.4"]
+ "requirements": ["AEMET-OpenData==0.6.3"]
}
diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml
new file mode 100644
index 00000000000..8d62e8515fc
--- /dev/null
+++ b/homeassistant/components/airgradient/quality_scale.yaml
@@ -0,0 +1,80 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: todo
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ 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:
+ status: exempt
+ comment: |
+ This integration has a fixed single device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/airly/manifest.json b/homeassistant/components/airly/manifest.json
index 233625ab04a..ccd37589e8c 100644
--- a/homeassistant/components/airly/manifest.json
+++ b/homeassistant/components/airly/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["airly"],
- "quality_scale": "platinum",
"requirements": ["airly==1.1.0"]
}
diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json
index 2b23928aba8..1ae7da14875 100644
--- a/homeassistant/components/airq/manifest.json
+++ b/homeassistant/components/airq/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairq"],
- "requirements": ["aioairq==0.3.2"]
+ "requirements": ["aioairq==0.4.3"]
}
diff --git a/homeassistant/components/airtouch5/manifest.json b/homeassistant/components/airtouch5/manifest.json
index 312a627d0e8..58ef8668ebe 100644
--- a/homeassistant/components/airtouch5/manifest.json
+++ b/homeassistant/components/airtouch5/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airtouch5",
"iot_class": "local_push",
"loggers": ["airtouch5py"],
- "requirements": ["airtouch5py==0.2.10"]
+ "requirements": ["airtouch5py==0.2.11"]
}
diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json
index 6bf374087a6..01fde7eb2fb 100644
--- a/homeassistant/components/airzone/manifest.json
+++ b/homeassistant/components/airzone/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
- "requirements": ["aioairzone==0.9.6"]
+ "requirements": ["aioairzone==0.9.7"]
}
diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py
index a9e433a3650..5bb00360177 100644
--- a/homeassistant/components/alarm_control_panel/__init__.py
+++ b/homeassistant/components/alarm_control_panel/__init__.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from datetime import timedelta
-from functools import partial
import logging
from typing import TYPE_CHECKING, Any, Final, final
@@ -27,26 +26,14 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import make_entity_service_schema
-from homeassistant.helpers.deprecation import (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
+from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
-from .const import ( # noqa: F401
- _DEPRECATED_FORMAT_NUMBER,
- _DEPRECATED_FORMAT_TEXT,
- _DEPRECATED_SUPPORT_ALARM_ARM_AWAY,
- _DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS,
- _DEPRECATED_SUPPORT_ALARM_ARM_HOME,
- _DEPRECATED_SUPPORT_ALARM_ARM_NIGHT,
- _DEPRECATED_SUPPORT_ALARM_ARM_VACATION,
- _DEPRECATED_SUPPORT_ALARM_TRIGGER,
+from .const import (
ATTR_CHANGED_BY,
ATTR_CODE_ARM_REQUIRED,
DOMAIN,
@@ -163,7 +150,6 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
_alarm_control_panel_option_default_code: str | None = None
__alarm_legacy_state: bool = False
- __alarm_legacy_state_reported: bool = False
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
@@ -173,17 +159,15 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
# setting the state directly.
cls.__alarm_legacy_state = True
- def __setattr__(self, __name: str, __value: Any) -> None:
+ def __setattr__(self, name: str, value: Any, /) -> None:
"""Set attribute.
Deprecation warning if setting '_attr_state' directly
unless already reported.
"""
- if __name == "_attr_state":
- if self.__alarm_legacy_state_reported is not True:
- self._report_deprecated_alarm_state_handling()
- self.__alarm_legacy_state_reported = True
- return super().__setattr__(__name, __value)
+ if name == "_attr_state":
+ self._report_deprecated_alarm_state_handling()
+ return super().__setattr__(name, value)
@callback
def add_to_platform_start(
@@ -194,7 +178,7 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
- if self.__alarm_legacy_state and not self.__alarm_legacy_state_reported:
+ if self.__alarm_legacy_state:
self._report_deprecated_alarm_state_handling()
@callback
@@ -203,19 +187,16 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
Integrations should implement alarm_state instead of using state directly.
"""
- self.__alarm_legacy_state_reported = True
- if "custom_components" in type(self).__module__:
- # Do not report on core integrations as they have been fixed.
- report_issue = "report it to the custom integration author."
- _LOGGER.warning(
- "Entity %s (%s) is setting state directly"
- " which will stop working in HA Core 2025.11."
- " Entities should implement the 'alarm_state' property and"
- " return its state using the AlarmControlPanelState enum, please %s",
- self.entity_id,
- type(self),
- report_issue,
- )
+ report_usage(
+ "is setting state directly."
+ f" Entity {self.entity_id} ({type(self)}) should implement the 'alarm_state'"
+ " property and return its state using the AlarmControlPanelState enum",
+ core_integration_behavior=ReportBehavior.ERROR,
+ custom_integration_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.11",
+ integration_domain=self.platform.platform_name if self.platform else None,
+ exclude_integrations={DOMAIN},
+ )
@final
@property
@@ -275,7 +256,6 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
"""Check if arm code is required, raise if no code is given."""
if not (_code := self.code_or_default_code(code)) and self.code_arm_required:
raise ServiceValidationError(
- f"Arming requires a code but none was given for {self.entity_id}",
translation_domain=DOMAIN,
translation_key="code_arm_required",
translation_placeholders={
@@ -418,13 +398,3 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
self._alarm_control_panel_option_default_code = default_code
return
self._alarm_control_panel_option_default_code = None
-
-
-# As we import constants of the const module here, we need to add the following
-# functions to check for deprecated constants again
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py
index f3218626ead..f9a5887513c 100644
--- a/homeassistant/components/alarm_control_panel/const.py
+++ b/homeassistant/components/alarm_control_panel/const.py
@@ -1,16 +1,8 @@
"""Provides the constants needed for component."""
from enum import IntFlag, StrEnum
-from functools import partial
from typing import Final
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
-
DOMAIN: Final = "alarm_control_panel"
ATTR_CHANGED_BY: Final = "changed_by"
@@ -39,12 +31,6 @@ class CodeFormat(StrEnum):
NUMBER = "number"
-# These constants are deprecated as of Home Assistant 2022.5, can be removed in 2025.1
-# Please use the CodeFormat enum instead.
-_DEPRECATED_FORMAT_TEXT: Final = DeprecatedConstantEnum(CodeFormat.TEXT, "2025.1")
-_DEPRECATED_FORMAT_NUMBER: Final = DeprecatedConstantEnum(CodeFormat.NUMBER, "2025.1")
-
-
class AlarmControlPanelEntityFeature(IntFlag):
"""Supported features of the alarm control panel entity."""
@@ -56,27 +42,6 @@ class AlarmControlPanelEntityFeature(IntFlag):
ARM_VACATION = 32
-# These constants are deprecated as of Home Assistant 2022.5
-# Please use the AlarmControlPanelEntityFeature enum instead.
-_DEPRECATED_SUPPORT_ALARM_ARM_HOME: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_HOME, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_ARM_AWAY: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_AWAY, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_ARM_NIGHT: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_NIGHT, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_TRIGGER: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.TRIGGER, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, "2025.1"
-)
-_DEPRECATED_SUPPORT_ALARM_ARM_VACATION: Final = DeprecatedConstantEnum(
- AlarmControlPanelEntityFeature.ARM_VACATION, "2025.1"
-)
-
CONDITION_TRIGGERED: Final = "is_triggered"
CONDITION_DISARMED: Final = "is_disarmed"
CONDITION_ARMED_HOME: Final = "is_armed_home"
@@ -84,10 +49,3 @@ CONDITION_ARMED_AWAY: Final = "is_armed_away"
CONDITION_ARMED_NIGHT: Final = "is_armed_night"
CONDITION_ARMED_VACATION: Final = "is_armed_vacation"
CONDITION_ARMED_CUSTOM_BYPASS: Final = "is_armed_custom_bypass"
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json
index 6dac4d069a1..5f718280566 100644
--- a/homeassistant/components/alarm_control_panel/strings.json
+++ b/homeassistant/components/alarm_control_panel/strings.json
@@ -130,7 +130,7 @@
},
"alarm_trigger": {
"name": "Trigger",
- "description": "Enables an external alarm trigger.",
+ "description": "Trigger the alarm manually.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -138,5 +138,10 @@
}
}
}
+ },
+ "exceptions": {
+ "code_arm_required": {
+ "message": "Arming requires a code but none was given for {entity_id}."
+ }
}
}
diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py
index 09b461428ac..b2cda8ad76e 100644
--- a/homeassistant/components/alexa/capabilities.py
+++ b/homeassistant/components/alexa/capabilities.py
@@ -816,13 +816,19 @@ class AlexaPlaybackController(AlexaCapability):
"""
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
- operations = {
- media_player.MediaPlayerEntityFeature.NEXT_TRACK: "Next",
- media_player.MediaPlayerEntityFeature.PAUSE: "Pause",
- media_player.MediaPlayerEntityFeature.PLAY: "Play",
- media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK: "Previous",
- media_player.MediaPlayerEntityFeature.STOP: "Stop",
- }
+ operations: dict[
+ cover.CoverEntityFeature | media_player.MediaPlayerEntityFeature, str
+ ]
+ if self.entity.domain == cover.DOMAIN:
+ operations = {cover.CoverEntityFeature.STOP: "Stop"}
+ else:
+ operations = {
+ media_player.MediaPlayerEntityFeature.NEXT_TRACK: "Next",
+ media_player.MediaPlayerEntityFeature.PAUSE: "Pause",
+ media_player.MediaPlayerEntityFeature.PLAY: "Play",
+ media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK: "Previous",
+ media_player.MediaPlayerEntityFeature.STOP: "Stop",
+ }
return [
value
diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py
index ca7b389a0f1..8c139d66369 100644
--- a/homeassistant/components/alexa/entities.py
+++ b/homeassistant/components/alexa/entities.py
@@ -559,6 +559,10 @@ class CoverCapabilities(AlexaEntity):
)
if supported & cover.CoverEntityFeature.SET_TILT_POSITION:
yield AlexaRangeController(self.entity, instance=f"{cover.DOMAIN}.tilt")
+ if supported & (
+ cover.CoverEntityFeature.STOP | cover.CoverEntityFeature.STOP_TILT
+ ):
+ yield AlexaPlaybackController(self.entity, instance=f"{cover.DOMAIN}.stop")
yield AlexaEndpointHealth(self.hass, self.entity)
yield Alexa(self.entity)
diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py
index 8ea61ddbceb..89e47673f07 100644
--- a/homeassistant/components/alexa/handlers.py
+++ b/homeassistant/components/alexa/handlers.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from collections.abc import Callable, Coroutine
import logging
import math
@@ -764,9 +765,25 @@ async def async_api_stop(
entity = directive.entity
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
- await hass.services.async_call(
- entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context
- )
+ if entity.domain == cover.DOMAIN:
+ supported: int = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ feature_services: dict[int, str] = {
+ cover.CoverEntityFeature.STOP.value: cover.SERVICE_STOP_COVER,
+ cover.CoverEntityFeature.STOP_TILT.value: cover.SERVICE_STOP_COVER_TILT,
+ }
+ await asyncio.gather(
+ *(
+ hass.services.async_call(
+ entity.domain, service, data, blocking=False, context=context
+ )
+ for feature, service in feature_services.items()
+ if feature & supported
+ )
+ )
+ else:
+ await hass.services.async_call(
+ entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context
+ )
return directive.response()
diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json
index c94da6bf487..cdfa847d115 100644
--- a/homeassistant/components/alpha_vantage/manifest.json
+++ b/homeassistant/components/alpha_vantage/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/alpha_vantage",
"iot_class": "cloud_polling",
"loggers": ["alpha_vantage"],
+ "quality_scale": "legacy",
"requirements": ["alpha-vantage==2.3.1"]
}
diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json
index b057967d1e2..e7fbf8edc74 100644
--- a/homeassistant/components/amazon_polly/manifest.json
+++ b/homeassistant/components/amazon_polly/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/amazon_polly",
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
+ "quality_scale": "legacy",
"requirements": ["boto3==1.34.131"]
}
diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py
index cd44886c9ef..29d8f166f2a 100644
--- a/homeassistant/components/amberelectric/__init__.py
+++ b/homeassistant/components/amberelectric/__init__.py
@@ -1,7 +1,6 @@
"""Support for Amber Electric."""
-from amberelectric import Configuration
-from amberelectric.api import amber_api
+import amberelectric
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN
@@ -15,8 +14,9 @@ type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool:
"""Set up Amber Electric from a config entry."""
- configuration = Configuration(access_token=entry.data[CONF_API_TOKEN])
- api_instance = amber_api.AmberApi.create(configuration)
+ configuration = amberelectric.Configuration(access_token=entry.data[CONF_API_TOKEN])
+ api_client = amberelectric.ApiClient(configuration)
+ api_instance = amberelectric.AmberApi(api_client)
site_id = entry.data[CONF_SITE_ID]
coordinator = AmberUpdateCoordinator(hass, api_instance, site_id)
diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py
index a94700c27d1..c25258e2e33 100644
--- a/homeassistant/components/amberelectric/config_flow.py
+++ b/homeassistant/components/amberelectric/config_flow.py
@@ -3,8 +3,8 @@
from __future__ import annotations
import amberelectric
-from amberelectric.api import amber_api
-from amberelectric.model.site import Site, SiteStatus
+from amberelectric.models.site import Site
+from amberelectric.models.site_status import SiteStatus
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -23,11 +23,15 @@ API_URL = "https://app.amber.com.au/developers"
def generate_site_selector_name(site: Site) -> str:
"""Generate the name to show in the site drop down in the configuration flow."""
+ # For some reason the generated API key returns this as any, not a string. Thanks pydantic
+ nmi = str(site.nmi)
if site.status == SiteStatus.CLOSED:
- return site.nmi + " (Closed: " + site.closed_on.isoformat() + ")" # type: ignore[no-any-return]
+ if site.closed_on is None:
+ return f"{nmi} (Closed)"
+ return f"{nmi} (Closed: {site.closed_on.isoformat()})"
if site.status == SiteStatus.PENDING:
- return site.nmi + " (Pending)" # type: ignore[no-any-return]
- return site.nmi # type: ignore[no-any-return]
+ return f"{nmi} (Pending)"
+ return nmi
def filter_sites(sites: list[Site]) -> list[Site]:
@@ -35,7 +39,7 @@ def filter_sites(sites: list[Site]) -> list[Site]:
filtered: list[Site] = []
filtered_nmi: set[str] = set()
- for site in sorted(sites, key=lambda site: site.status.value):
+ for site in sorted(sites, key=lambda site: site.status):
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
filtered.append(site)
filtered_nmi.add(site.nmi)
@@ -56,7 +60,8 @@ class AmberElectricConfigFlow(ConfigFlow, domain=DOMAIN):
def _fetch_sites(self, token: str) -> list[Site] | None:
configuration = amberelectric.Configuration(access_token=token)
- api: amber_api.AmberApi = amber_api.AmberApi.create(configuration)
+ api_client = amberelectric.ApiClient(configuration)
+ api = amberelectric.AmberApi(api_client)
try:
sites: list[Site] = filter_sites(api.get_sites())
diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py
index a95aa3fa529..57028e07d21 100644
--- a/homeassistant/components/amberelectric/coordinator.py
+++ b/homeassistant/components/amberelectric/coordinator.py
@@ -5,13 +5,13 @@ from __future__ import annotations
from datetime import timedelta
from typing import Any
-from amberelectric import ApiException
-from amberelectric.api import amber_api
-from amberelectric.model.actual_interval import ActualInterval
-from amberelectric.model.channel import ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.forecast_interval import ForecastInterval
-from amberelectric.model.interval import Descriptor
+import amberelectric
+from amberelectric.models.actual_interval import ActualInterval
+from amberelectric.models.channel import ChannelType
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.forecast_interval import ForecastInterval
+from amberelectric.models.price_descriptor import PriceDescriptor
+from amberelectric.rest import ApiException
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -31,22 +31,22 @@ def is_forecast(interval: ActualInterval | CurrentInterval | ForecastInterval) -
def is_general(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool:
"""Return true if the supplied interval is on the general channel."""
- return interval.channel_type == ChannelType.GENERAL # type: ignore[no-any-return]
+ return interval.channel_type == ChannelType.GENERAL
def is_controlled_load(
interval: ActualInterval | CurrentInterval | ForecastInterval,
) -> bool:
"""Return true if the supplied interval is on the controlled load channel."""
- return interval.channel_type == ChannelType.CONTROLLED_LOAD # type: ignore[no-any-return]
+ return interval.channel_type == ChannelType.CONTROLLEDLOAD
def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> bool:
"""Return true if the supplied interval is on the feed in channel."""
- return interval.channel_type == ChannelType.FEED_IN # type: ignore[no-any-return]
+ return interval.channel_type == ChannelType.FEEDIN
-def normalize_descriptor(descriptor: Descriptor) -> str | None:
+def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
if descriptor is None:
return None
@@ -71,7 +71,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
def __init__(
- self, hass: HomeAssistant, api: amber_api.AmberApi, site_id: str
+ self, hass: HomeAssistant, api: amberelectric.AmberApi, site_id: str
) -> None:
"""Initialise the data service."""
super().__init__(
@@ -93,12 +93,13 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
"grid": {},
}
try:
- data = self._api.get_current_price(self.site_id, next=48)
+ data = self._api.get_current_prices(self.site_id, next=48)
+ intervals = [interval.actual_instance for interval in data]
except ApiException as api_exception:
raise UpdateFailed("Missing price data, skipping update") from api_exception
- current = [interval for interval in data if is_current(interval)]
- forecasts = [interval for interval in data if is_forecast(interval)]
+ current = [interval for interval in intervals if is_current(interval)]
+ forecasts = [interval for interval in intervals if is_forecast(interval)]
general = [interval for interval in current if is_general(interval)]
if len(general) == 0:
@@ -137,7 +138,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
interval for interval in forecasts if is_feed_in(interval)
]
- LOGGER.debug("Fetched new Amber data: %s", data)
+ LOGGER.debug("Fetched new Amber data: %s", intervals)
return result
async def _async_update_data(self) -> dict[str, Any]:
diff --git a/homeassistant/components/amberelectric/manifest.json b/homeassistant/components/amberelectric/manifest.json
index 51be42cfa68..401eb1629a1 100644
--- a/homeassistant/components/amberelectric/manifest.json
+++ b/homeassistant/components/amberelectric/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/amberelectric",
"iot_class": "cloud_polling",
"loggers": ["amberelectric"],
- "requirements": ["amberelectric==1.1.1"]
+ "requirements": ["amberelectric==2.0.12"]
}
diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py
index 52c0c42e7bc..cdf40e5804d 100644
--- a/homeassistant/components/amberelectric/sensor.py
+++ b/homeassistant/components/amberelectric/sensor.py
@@ -8,9 +8,9 @@ from __future__ import annotations
from typing import Any
-from amberelectric.model.channel import ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.forecast_interval import ForecastInterval
+from amberelectric.models.channel import ChannelType
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.forecast_interval import ForecastInterval
from homeassistant.components.sensor import (
SensorEntity,
@@ -52,7 +52,7 @@ class AmberSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity):
self,
coordinator: AmberUpdateCoordinator,
description: SensorEntityDescription,
- channel_type: ChannelType,
+ channel_type: str,
) -> None:
"""Initialize the Sensor."""
super().__init__(coordinator)
@@ -73,7 +73,7 @@ class AmberPriceSensor(AmberSensor):
"""Return the current price in $/kWh."""
interval = self.coordinator.data[self.entity_description.key][self.channel_type]
- if interval.channel_type == ChannelType.FEED_IN:
+ if interval.channel_type == ChannelType.FEEDIN:
return format_cents_to_dollars(interval.per_kwh) * -1
return format_cents_to_dollars(interval.per_kwh)
@@ -87,9 +87,9 @@ class AmberPriceSensor(AmberSensor):
return data
data["duration"] = interval.duration
- data["date"] = interval.date.isoformat()
+ data["date"] = interval.var_date.isoformat()
data["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
- if interval.channel_type == ChannelType.FEED_IN:
+ if interval.channel_type == ChannelType.FEEDIN:
data["per_kwh"] = data["per_kwh"] * -1
data["nem_date"] = interval.nem_time.isoformat()
data["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
@@ -120,7 +120,7 @@ class AmberForecastSensor(AmberSensor):
return None
interval = intervals[0]
- if interval.channel_type == ChannelType.FEED_IN:
+ if interval.channel_type == ChannelType.FEEDIN:
return format_cents_to_dollars(interval.per_kwh) * -1
return format_cents_to_dollars(interval.per_kwh)
@@ -142,10 +142,10 @@ class AmberForecastSensor(AmberSensor):
for interval in intervals:
datum = {}
datum["duration"] = interval.duration
- datum["date"] = interval.date.isoformat()
+ datum["date"] = interval.var_date.isoformat()
datum["nem_date"] = interval.nem_time.isoformat()
datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
- if interval.channel_type == ChannelType.FEED_IN:
+ if interval.channel_type == ChannelType.FEEDIN:
datum["per_kwh"] = datum["per_kwh"] * -1
datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
datum["start_time"] = interval.start_time.isoformat()
diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json
index 8b8d87092c4..7d8f8f9e6c8 100644
--- a/homeassistant/components/amcrest/manifest.json
+++ b/homeassistant/components/amcrest/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/amcrest",
"iot_class": "local_polling",
"loggers": ["amcrest"],
+ "quality_scale": "legacy",
"requirements": ["amcrest==1.9.8"]
}
diff --git a/homeassistant/components/ampio/manifest.json b/homeassistant/components/ampio/manifest.json
index bc9c09d817a..17fc3eb3d96 100644
--- a/homeassistant/components/ampio/manifest.json
+++ b/homeassistant/components/ampio/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ampio",
"iot_class": "cloud_polling",
"loggers": ["asmog"],
+ "quality_scale": "legacy",
"requirements": ["asmog==0.0.6"]
}
diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json
index 2d0b062c750..fe8e36f0c2f 100644
--- a/homeassistant/components/androidtv/manifest.json
+++ b/homeassistant/components/androidtv/manifest.json
@@ -9,7 +9,7 @@
"loggers": ["adb_shell", "androidtv", "pure_python_adb"],
"requirements": [
"adb-shell[async]==0.4.4",
- "androidtv[async]==0.0.73",
+ "androidtv[async]==0.0.75",
"pure-python-adb[async]==0.3.0.dev0"
]
}
diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json
index a06152fa570..d9c2dd05c44 100644
--- a/homeassistant/components/androidtv_remote/manifest.json
+++ b/homeassistant/components/androidtv_remote/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
- "quality_scale": "platinum",
"requirements": ["androidtvremote2==0.1.2"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}
diff --git a/homeassistant/components/anel_pwrctrl/manifest.json b/homeassistant/components/anel_pwrctrl/manifest.json
index 48cc3b96ec0..67c881a3db2 100644
--- a/homeassistant/components/anel_pwrctrl/manifest.json
+++ b/homeassistant/components/anel_pwrctrl/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/anel_pwrctrl",
"iot_class": "local_polling",
"loggers": ["anel_pwrctrl"],
+ "quality_scale": "legacy",
"requirements": ["anel-pwrctrl-homeassistant==0.0.1.dev2"]
}
diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json
index 4cd1eb32cd1..eae7981d5b9 100644
--- a/homeassistant/components/aosmith/manifest.json
+++ b/homeassistant/components/aosmith/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"iot_class": "cloud_polling",
- "requirements": ["py-aosmith==1.0.10"]
+ "requirements": ["py-aosmith==1.0.11"]
}
diff --git a/homeassistant/components/apache_kafka/manifest.json b/homeassistant/components/apache_kafka/manifest.json
index f6593631bc0..05baaac32a2 100644
--- a/homeassistant/components/apache_kafka/manifest.json
+++ b/homeassistant/components/apache_kafka/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/apache_kafka",
"iot_class": "local_push",
"loggers": ["aiokafka", "kafka_python"],
+ "quality_scale": "legacy",
"requirements": ["aiokafka==0.10.0"]
}
diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json
index b20e0c8aacf..3713b74fff7 100644
--- a/homeassistant/components/apcupsd/manifest.json
+++ b/homeassistant/components/apcupsd/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
"iot_class": "local_polling",
"loggers": ["apcaccess"],
- "quality_scale": "silver",
"requirements": ["aioapcaccess==0.4.2"]
}
diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json
index b4e1b354878..b10a14af32b 100644
--- a/homeassistant/components/apple_tv/manifest.json
+++ b/homeassistant/components/apple_tv/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
- "requirements": ["pyatv==0.15.1"],
+ "requirements": ["pyatv==0.16.0"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_companion-link._tcp.local.",
diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json
index 838611e4798..4f3c4d7ef4e 100644
--- a/homeassistant/components/apprise/manifest.json
+++ b/homeassistant/components/apprise/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/apprise",
"iot_class": "cloud_push",
"loggers": ["apprise"],
+ "quality_scale": "legacy",
"requirements": ["apprise==1.9.0"]
}
diff --git a/homeassistant/components/aprs/manifest.json b/homeassistant/components/aprs/manifest.json
index 63826f5a385..7518405f1ec 100644
--- a/homeassistant/components/aprs/manifest.json
+++ b/homeassistant/components/aprs/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aprs",
"iot_class": "cloud_push",
"loggers": ["aprslib", "geographiclib", "geopy"],
+ "quality_scale": "legacy",
"requirements": ["aprslib==0.7.2", "geopy==2.3.0"]
}
diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py
index b6e951343f7..e56cb826840 100644
--- a/homeassistant/components/apsystems/coordinator.py
+++ b/homeassistant/components/apsystems/coordinator.py
@@ -5,12 +5,17 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
-from APsystemsEZ1 import APsystemsEZ1M, ReturnAlarmInfo, ReturnOutputData
+from APsystemsEZ1 import (
+ APsystemsEZ1M,
+ InverterReturnedError,
+ ReturnAlarmInfo,
+ ReturnOutputData,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import LOGGER
+from .const import DOMAIN, LOGGER
@dataclass
@@ -43,6 +48,11 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
self.api.min_power = device_info.minPower
async def _async_update_data(self) -> ApSystemsSensorData:
- output_data = await self.api.get_output_data()
- alarm_info = await self.api.get_alarm_info()
+ try:
+ output_data = await self.api.get_output_data()
+ alarm_info = await self.api.get_alarm_info()
+ except InverterReturnedError:
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="inverter_error"
+ ) from None
return ApSystemsSensorData(output_data=output_data, alarm_info=alarm_info)
diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json
index e02f86c2730..b3a10ca49a7 100644
--- a/homeassistant/components/apsystems/strings.json
+++ b/homeassistant/components/apsystems/strings.json
@@ -72,5 +72,10 @@
"name": "Inverter status"
}
}
+ },
+ "exceptions": {
+ "inverter_error": {
+ "message": "Inverter returned an error"
+ }
}
}
diff --git a/homeassistant/components/aqualogic/manifest.json b/homeassistant/components/aqualogic/manifest.json
index 783e4c8c204..cc807e4bb19 100644
--- a/homeassistant/components/aqualogic/manifest.json
+++ b/homeassistant/components/aqualogic/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aqualogic",
"iot_class": "local_push",
"loggers": ["aqualogic"],
+ "quality_scale": "legacy",
"requirements": ["aqualogic==2.6"]
}
diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json
index 1bac2bdfb5f..6fc1092d33c 100644
--- a/homeassistant/components/aquostv/manifest.json
+++ b/homeassistant/components/aquostv/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aquostv",
"iot_class": "local_polling",
"loggers": ["sharp_aquos_rc"],
+ "quality_scale": "legacy",
"requirements": ["sharp_aquos_rc==0.3.2"]
}
diff --git a/homeassistant/components/arest/manifest.json b/homeassistant/components/arest/manifest.json
index 53732d15064..be43b3aafc9 100644
--- a/homeassistant/components/arest/manifest.json
+++ b/homeassistant/components/arest/manifest.json
@@ -3,5 +3,6 @@
"name": "aREST",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/arest",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/arris_tg2492lg/manifest.json b/homeassistant/components/arris_tg2492lg/manifest.json
index c36423d287a..98778de5f2a 100644
--- a/homeassistant/components/arris_tg2492lg/manifest.json
+++ b/homeassistant/components/arris_tg2492lg/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["arris_tg2492lg"],
+ "quality_scale": "legacy",
"requirements": ["arris-tg2492lg==2.2.0"]
}
diff --git a/homeassistant/components/aruba/manifest.json b/homeassistant/components/aruba/manifest.json
index 0d1fabf51b8..c98dda754cd 100644
--- a/homeassistant/components/aruba/manifest.json
+++ b/homeassistant/components/aruba/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aruba",
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
+ "quality_scale": "legacy",
"requirements": ["pexpect==4.6.0"]
}
diff --git a/homeassistant/components/arwn/manifest.json b/homeassistant/components/arwn/manifest.json
index 15eb656e974..8cabb045b64 100644
--- a/homeassistant/components/arwn/manifest.json
+++ b/homeassistant/components/arwn/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/arwn",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py
index d90424d52d3..5bbc81adb86 100644
--- a/homeassistant/components/assist_pipeline/pipeline.py
+++ b/homeassistant/components/assist_pipeline/pipeline.py
@@ -1032,39 +1032,37 @@ class PipelineRun:
agent_id=self.intent_agent,
)
- # Sentence triggers override conversation agent
- if (
- trigger_response_text
- := await conversation.async_handle_sentence_triggers(
- self.hass, user_input
- )
- ):
- # Sentence trigger matched
- trigger_response = intent.IntentResponse(
- self.pipeline.conversation_language
- )
- trigger_response.async_set_speech(trigger_response_text)
- conversation_result = conversation.ConversationResult(
- response=trigger_response,
- conversation_id=user_input.conversation_id,
- )
- # Try local intents first, if preferred.
- # Skip this step if the default agent is already used.
- elif (
- self.pipeline.prefer_local_intents
- and (user_input.agent_id != conversation.HOME_ASSISTANT_AGENT)
- and (
+ conversation_result: conversation.ConversationResult | None = None
+ if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT:
+ # Sentence triggers override conversation agent
+ if (
+ trigger_response_text
+ := await conversation.async_handle_sentence_triggers(
+ self.hass, user_input
+ )
+ ) is not None:
+ # Sentence trigger matched
+ trigger_response = intent.IntentResponse(
+ self.pipeline.conversation_language
+ )
+ trigger_response.async_set_speech(trigger_response_text)
+ conversation_result = conversation.ConversationResult(
+ response=trigger_response,
+ conversation_id=user_input.conversation_id,
+ )
+ # Try local intents first, if preferred.
+ elif self.pipeline.prefer_local_intents and (
intent_response := await conversation.async_handle_intents(
self.hass, user_input
)
- )
- ):
- # Local intent matched
- conversation_result = conversation.ConversationResult(
- response=intent_response,
- conversation_id=user_input.conversation_id,
- )
- else:
+ ):
+ # Local intent matched
+ conversation_result = conversation.ConversationResult(
+ response=intent_response,
+ conversation_id=user_input.conversation_id,
+ )
+
+ if conversation_result is None:
# Fall back to pipeline conversation agent
conversation_result = await conversation.async_converse(
hass=self.hass,
diff --git a/homeassistant/components/aten_pe/manifest.json b/homeassistant/components/aten_pe/manifest.json
index 3b4ade637cb..1e2c74f2636 100644
--- a/homeassistant/components/aten_pe/manifest.json
+++ b/homeassistant/components/aten_pe/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@mtdcr"],
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["atenpdu==0.3.2"]
}
diff --git a/homeassistant/components/atome/manifest.json b/homeassistant/components/atome/manifest.json
index cafe24e2e13..f00dd5ea757 100644
--- a/homeassistant/components/atome/manifest.json
+++ b/homeassistant/components/atome/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/atome",
"iot_class": "cloud_polling",
"loggers": ["pyatome"],
+ "quality_scale": "legacy",
"requirements": ["pyAtome==0.1.1"]
}
diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json
index 4bc7e77d2d8..96ed982e4ec 100644
--- a/homeassistant/components/august/manifest.json
+++ b/homeassistant/components/august/manifest.json
@@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
- "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.0"]
+ "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.1"]
}
diff --git a/homeassistant/components/autarco/config_flow.py b/homeassistant/components/autarco/config_flow.py
index a66f14047a7..294fa685fb8 100644
--- a/homeassistant/components/autarco/config_flow.py
+++ b/homeassistant/components/autarco/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections.abc import Mapping
from typing import Any
from autarco import Autarco, AutarcoAuthenticationError, AutarcoConnectionError
@@ -20,6 +21,12 @@ DATA_SCHEMA = vol.Schema(
}
)
+STEP_REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
class AutarcoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Autarco."""
@@ -55,3 +62,40 @@ class AutarcoConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
data_schema=DATA_SCHEMA,
)
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle re-authentication request from Autarco."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle re-authentication confirmation."""
+ errors = {}
+
+ reauth_entry = self._get_reauth_entry()
+ if user_input is not None:
+ client = Autarco(
+ email=reauth_entry.data[CONF_EMAIL],
+ password=user_input[CONF_PASSWORD],
+ session=async_get_clientsession(self.hass),
+ )
+ try:
+ await client.get_account()
+ except AutarcoAuthenticationError:
+ errors["base"] = "invalid_auth"
+ except AutarcoConnectionError:
+ errors["base"] = "cannot_connect"
+ else:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates=user_input,
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
+ data_schema=STEP_REAUTH_SCHEMA,
+ errors=errors,
+ )
diff --git a/homeassistant/components/autarco/coordinator.py b/homeassistant/components/autarco/coordinator.py
index 5dd19478ae8..dd8786bca25 100644
--- a/homeassistant/components/autarco/coordinator.py
+++ b/homeassistant/components/autarco/coordinator.py
@@ -7,6 +7,7 @@ from typing import NamedTuple
from autarco import (
AccountSite,
Autarco,
+ AutarcoAuthenticationError,
AutarcoConnectionError,
Battery,
Inverter,
@@ -16,6 +17,7 @@ from autarco import (
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 .const import DOMAIN, LOGGER, SCAN_INTERVAL
@@ -60,8 +62,10 @@ class AutarcoDataUpdateCoordinator(DataUpdateCoordinator[AutarcoData]):
inverters = await self.client.get_inverters(self.account_site.public_key)
if site.has_battery:
battery = await self.client.get_battery(self.account_site.public_key)
- except AutarcoConnectionError as error:
- raise UpdateFailed(error) from error
+ except AutarcoAuthenticationError as err:
+ raise ConfigEntryAuthFailed(err) from err
+ except AutarcoConnectionError as err:
+ raise UpdateFailed(err) from err
return AutarcoData(
solar=solar,
inverters=inverters,
diff --git a/homeassistant/components/autarco/quality_scale.yaml b/homeassistant/components/autarco/quality_scale.yaml
new file mode 100644
index 00000000000..d2e1455af7e
--- /dev/null
+++ b/homeassistant/components/autarco/quality_scale.yaml
@@ -0,0 +1,99 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: todo
+ comment: |
+ The entity.py file is not used in this integration.
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration only polls data using a coordinator.
+ Since the integration is read-only and poll-only (only provide sensor
+ data), there is no need to implement parallel updates.
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users home address to get the data.
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users home address to get the data.
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices:
+ status: exempt
+ comment: |
+ This is an service, which doesn't integrate with any devices.
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have any entities that should disabled by default.
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices: todo
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/autarco/strings.json b/homeassistant/components/autarco/strings.json
index 8eda5fe0411..159dbd09781 100644
--- a/homeassistant/components/autarco/strings.json
+++ b/homeassistant/components/autarco/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "Connect to your Autarco account to get information about your solar panels.",
+ "description": "Connect to your Autarco account, to get information about your sites.",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
@@ -11,6 +11,16 @@
"email": "The email address of your Autarco account.",
"password": "The password of your Autarco account."
}
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The password for {email} is no longer valid.",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::autarco::config::step::user::data_description::password%]"
+ }
}
},
"error": {
@@ -18,7 +28,8 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index 4fcd8a1416d..bd8af526d75 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -6,7 +6,6 @@ from abc import ABC, abstractmethod
import asyncio
from collections.abc import Callable, Mapping
from dataclasses import dataclass
-from functools import partial
import logging
from typing import Any, Protocol, cast
@@ -51,12 +50,6 @@ from homeassistant.core import (
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstant,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (
@@ -86,12 +79,7 @@ from homeassistant.helpers.trace import (
trace_get,
trace_path,
)
-from homeassistant.helpers.trigger import (
- TriggerActionType,
- TriggerData,
- TriggerInfo,
- async_initialize_triggers,
-)
+from homeassistant.helpers.trigger import async_initialize_triggers
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.dt import parse_datetime
@@ -137,20 +125,6 @@ class IfAction(Protocol):
"""AND all conditions."""
-# AutomationActionType, AutomationTriggerData,
-# and AutomationTriggerInfo are deprecated as of 2022.9.
-# Can be removed in 2025.1
-_DEPRECATED_AutomationActionType = DeprecatedConstant(
- TriggerActionType, "TriggerActionType", "2025.1"
-)
-_DEPRECATED_AutomationTriggerData = DeprecatedConstant(
- TriggerData, "TriggerData", "2025.1"
-)
-_DEPRECATED_AutomationTriggerInfo = DeprecatedConstant(
- TriggerInfo, "TriggerInfo", "2025.1"
-)
-
-
@bind_hass
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
"""Return true if specified automation entity_id is on.
@@ -477,6 +451,7 @@ class UnavailableAutomationEntity(BaseAutomationEntity):
)
async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
async_delete_issue(
self.hass, DOMAIN, f"{self.entity_id}_validation_{self._validation_status}"
@@ -1219,11 +1194,3 @@ def websocket_config(
"config": automation.raw_config,
},
)
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/avea/manifest.json b/homeassistant/components/avea/manifest.json
index 43c46c96e66..7e6c080481e 100644
--- a/homeassistant/components/avea/manifest.json
+++ b/homeassistant/components/avea/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/avea",
"iot_class": "local_polling",
"loggers": ["avea"],
+ "quality_scale": "legacy",
"requirements": ["avea==1.5.1"]
}
diff --git a/homeassistant/components/avion/manifest.json b/homeassistant/components/avion/manifest.json
index 505dca870a7..8488e949af3 100644
--- a/homeassistant/components/avion/manifest.json
+++ b/homeassistant/components/avion/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/avion",
"iot_class": "assumed_state",
+ "quality_scale": "legacy",
"requirements": ["avion==0.10"]
}
diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py
index 3175e6bc56c..090d9747a64 100644
--- a/homeassistant/components/aws/config_flow.py
+++ b/homeassistant/components/aws/config_flow.py
@@ -14,7 +14,4 @@ class AWSFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import a config entry."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
return self.async_create_entry(title="configuration.yaml", data=import_data)
diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json
index 6238bffce36..12149e4388a 100644
--- a/homeassistant/components/aws/manifest.json
+++ b/homeassistant/components/aws/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/aws",
"iot_class": "cloud_push",
"loggers": ["aiobotocore", "botocore"],
+ "quality_scale": "legacy",
"requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"]
}
diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json
index d2265307d47..7163437361a 100644
--- a/homeassistant/components/axis/manifest.json
+++ b/homeassistant/components/axis/manifest.json
@@ -29,7 +29,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
- "quality_scale": "platinum",
"requirements": ["axis==63"],
"ssdp": [
{
diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py
index 60ac9bff8cd..baed866042e 100644
--- a/homeassistant/components/azure_event_hub/config_flow.py
+++ b/homeassistant/components/azure_event_hub/config_flow.py
@@ -102,8 +102,6 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial user step."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
if user_input is None:
return self.async_show_form(step_id=STEP_USER, data_schema=BASE_SCHEMA)
@@ -160,8 +158,6 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import config from configuration.yaml."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
if CONF_SEND_INTERVAL in import_data:
self._options[CONF_SEND_INTERVAL] = import_data.pop(CONF_SEND_INTERVAL)
if CONF_MAX_DELAY in import_data:
diff --git a/homeassistant/components/azure_event_hub/manifest.json b/homeassistant/components/azure_event_hub/manifest.json
index c6d5835fd1d..45fbf8c4a56 100644
--- a/homeassistant/components/azure_event_hub/manifest.json
+++ b/homeassistant/components/azure_event_hub/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/azure_event_hub",
"iot_class": "cloud_push",
"loggers": ["azure"],
- "requirements": ["azure-eventhub==5.11.1"]
+ "requirements": ["azure-eventhub==5.11.1"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/azure_event_hub/strings.json b/homeassistant/components/azure_event_hub/strings.json
index 3319a29a154..d17c4a385c0 100644
--- a/homeassistant/components/azure_event_hub/strings.json
+++ b/homeassistant/components/azure_event_hub/strings.json
@@ -31,7 +31,6 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"cannot_connect": "Connecting with the credentials from the configuration.yaml failed, please remove from yaml and use the config flow.",
"unknown": "Connecting with the credentials from the configuration.yaml failed with an unknown error, please remove from yaml and use the config flow."
}
diff --git a/homeassistant/components/azure_service_bus/manifest.json b/homeassistant/components/azure_service_bus/manifest.json
index 059f6300aec..31c1edac686 100644
--- a/homeassistant/components/azure_service_bus/manifest.json
+++ b/homeassistant/components/azure_service_bus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/azure_service_bus",
"iot_class": "cloud_push",
"loggers": ["azure"],
+ "quality_scale": "legacy",
"requirements": ["azure-servicebus==7.10.0"]
}
diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py
index 907fda4c7f8..200cb4a3f65 100644
--- a/homeassistant/components/backup/__init__.py
+++ b/homeassistant/components/backup/__init__.py
@@ -32,9 +32,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups."""
- await backup_manager.async_create_backup(on_progress=None)
- if backup_task := backup_manager.backup_task:
- await backup_task
+ await backup_manager.async_create_backup()
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py
index ddc0a1eac3f..4300f75eed0 100644
--- a/homeassistant/components/backup/manager.py
+++ b/homeassistant/components/backup/manager.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import abc
import asyncio
-from collections.abc import Callable
from dataclasses import asdict, dataclass
import hashlib
import io
@@ -35,13 +34,6 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
BUF_SIZE = 2**20 * 4 # 4MB
-@dataclass(slots=True)
-class NewBackup:
- """New backup class."""
-
- slug: str
-
-
@dataclass(slots=True)
class Backup:
"""Backup class."""
@@ -57,15 +49,6 @@ class Backup:
return {**asdict(self), "path": self.path.as_posix()}
-@dataclass(slots=True)
-class BackupProgress:
- """Backup progress class."""
-
- done: bool
- stage: str | None
- success: bool | None
-
-
class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have."""
@@ -82,7 +65,7 @@ class BaseBackupManager(abc.ABC):
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager."""
self.hass = hass
- self.backup_task: asyncio.Task | None = None
+ self.backing_up = False
self.backups: dict[str, Backup] = {}
self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {}
@@ -150,12 +133,7 @@ class BaseBackupManager(abc.ABC):
"""Restore a backup."""
@abc.abstractmethod
- async def async_create_backup(
- self,
- *,
- on_progress: Callable[[BackupProgress], None] | None,
- **kwargs: Any,
- ) -> NewBackup:
+ async def async_create_backup(self, **kwargs: Any) -> Backup:
"""Generate a backup."""
@abc.abstractmethod
@@ -314,36 +292,17 @@ class BackupManager(BaseBackupManager):
await self.hass.async_add_executor_job(_move_and_cleanup)
await self.load_backups()
- async def async_create_backup(
- self,
- *,
- on_progress: Callable[[BackupProgress], None] | None,
- **kwargs: Any,
- ) -> NewBackup:
+ async def async_create_backup(self, **kwargs: Any) -> Backup:
"""Generate a backup."""
- if self.backup_task:
+ if self.backing_up:
raise HomeAssistantError("Backup already in progress")
- backup_name = f"Core {HAVERSION}"
- date_str = dt_util.now().isoformat()
- slug = _generate_slug(date_str, backup_name)
- self.backup_task = self.hass.async_create_task(
- self._async_create_backup(backup_name, date_str, slug, on_progress),
- name="backup_manager_create_backup",
- eager_start=False, # To ensure the task is not started before we return
- )
- return NewBackup(slug=slug)
- async def _async_create_backup(
- self,
- backup_name: str,
- date_str: str,
- slug: str,
- on_progress: Callable[[BackupProgress], None] | None,
- ) -> Backup:
- """Generate a backup."""
- success = False
try:
+ self.backing_up = True
await self.async_pre_backup_actions()
+ backup_name = f"Core {HAVERSION}"
+ date_str = dt_util.now().isoformat()
+ slug = _generate_slug(date_str, backup_name)
backup_data = {
"slug": slug,
@@ -370,12 +329,9 @@ class BackupManager(BaseBackupManager):
if self.loaded_backups:
self.backups[slug] = backup
LOGGER.debug("Generated new backup with slug %s", slug)
- success = True
return backup
finally:
- if on_progress:
- on_progress(BackupProgress(done=True, stage=None, success=success))
- self.backup_task = None
+ self.backing_up = False
await self.async_post_backup_actions()
def _mkdir_and_generate_backup_contents(
diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json
index 1ec9b748cda..0a906bb6dfa 100644
--- a/homeassistant/components/backup/manifest.json
+++ b/homeassistant/components/backup/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal",
- "requirements": ["securetar==2024.2.1"]
+ "requirements": ["securetar==2024.11.0"]
}
diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py
index a7c61b7c66c..3ac8a7ace3e 100644
--- a/homeassistant/components/backup/websocket.py
+++ b/homeassistant/components/backup/websocket.py
@@ -8,7 +8,6 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import DATA_MANAGER, LOGGER
-from .manager import BackupProgress
@callback
@@ -41,7 +40,7 @@ async def handle_info(
msg["id"],
{
"backups": list(backups.values()),
- "backing_up": manager.backup_task is not None,
+ "backing_up": manager.backing_up,
},
)
@@ -114,11 +113,7 @@ async def handle_create(
msg: dict[str, Any],
) -> None:
"""Generate a backup."""
-
- def on_progress(progress: BackupProgress) -> None:
- connection.send_message(websocket_api.event_message(msg["id"], progress))
-
- backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress)
+ backup = await hass.data[DATA_MANAGER].async_create_backup()
connection.send_result(msg["id"], backup)
@@ -132,6 +127,7 @@ async def handle_backup_start(
) -> None:
"""Backup start notification."""
manager = hass.data[DATA_MANAGER]
+ manager.backing_up = True
LOGGER.debug("Backup start notification")
try:
@@ -153,6 +149,7 @@ async def handle_backup_end(
) -> None:
"""Backup end notification."""
manager = hass.data[DATA_MANAGER]
+ manager.backing_up = False
LOGGER.debug("Backup end notification")
try:
diff --git a/homeassistant/components/baidu/manifest.json b/homeassistant/components/baidu/manifest.json
index 8213b7cbe5e..32f14100b81 100644
--- a/homeassistant/components/baidu/manifest.json
+++ b/homeassistant/components/baidu/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/baidu",
"iot_class": "cloud_push",
"loggers": ["aip"],
+ "quality_scale": "legacy",
"requirements": ["baidu-aip==1.6.6"]
}
diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py
new file mode 100644
index 00000000000..cab7eae5e25
--- /dev/null
+++ b/homeassistant/components/bang_olufsen/diagnostics.py
@@ -0,0 +1,40 @@
+"""Support for Bang & Olufsen diagnostics."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
+from homeassistant.core import HomeAssistant
+import homeassistant.helpers.entity_registry as er
+
+from . import BangOlufsenConfigEntry
+from .const import DOMAIN
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: BangOlufsenConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ data: dict = {
+ "config_entry": config_entry.as_dict(),
+ "websocket_connected": config_entry.runtime_data.client.websocket_connected,
+ }
+
+ if TYPE_CHECKING:
+ assert config_entry.unique_id
+
+ # Add media_player entity's state
+ entity_registry = er.async_get(hass)
+ if entity_id := entity_registry.async_get_entity_id(
+ MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id
+ ):
+ 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["media_player"] = state_dict
+
+ return data
diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json
index b4a92d4da25..1565c98e979 100644
--- a/homeassistant/components/bang_olufsen/manifest.json
+++ b/homeassistant/components/bang_olufsen/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["mozart-api==4.1.1.116.0"],
+ "requirements": ["mozart-api==4.1.1.116.3"],
"zeroconf": ["_bangolufsen._tcp.local."]
}
diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py
index 56aa66d32e8..96e7cca0175 100644
--- a/homeassistant/components/bang_olufsen/media_player.py
+++ b/homeassistant/components/bang_olufsen/media_player.py
@@ -86,6 +86,8 @@ from .const import (
from .entity import BangOlufsenEntity
from .util import get_serial_number_from_jid
+PARALLEL_UPDATES = 0
+
SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
@@ -180,7 +182,6 @@ async def async_setup_entry(
class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
"""Representation of a media player."""
- _attr_icon = "mdi:speaker-wireless"
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json
index aef6f953524..6e75d2f26c8 100644
--- a/homeassistant/components/bang_olufsen/strings.json
+++ b/homeassistant/components/bang_olufsen/strings.json
@@ -11,7 +11,7 @@
"invalid_ip": "Invalid IPv4 address"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::single_instance_allowed%]",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"flow_title": "{name}",
diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py
index 913f7cb3241..bc817226b61 100644
--- a/homeassistant/components/bang_olufsen/websocket.py
+++ b/homeassistant/components/bang_olufsen/websocket.py
@@ -15,7 +15,7 @@ from mozart_api.models import (
VolumeState,
WebsocketNotificationTag,
)
-from mozart_api.mozart_client import MozartClient
+from mozart_api.mozart_client import BaseWebSocketResponse, MozartClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -202,12 +202,13 @@ class BangOlufsenWebsocket(BangOlufsenBase):
sw_version=software_status.software_version,
)
- def on_all_notifications_raw(self, notification: dict) -> None:
+ def on_all_notifications_raw(self, notification: BaseWebSocketResponse) -> None:
"""Receive all notifications."""
+ debug_notification = {
+ "device_id": self._device.id,
+ "serial_number": int(self._unique_id),
+ **notification,
+ }
- # Add the device_id and serial_number to the notification
- notification["device_id"] = self._device.id
- notification["serial_number"] = int(self._unique_id)
-
- _LOGGER.debug("%s", notification)
- self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, notification)
+ _LOGGER.debug("%s", debug_notification)
+ self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, debug_notification)
diff --git a/homeassistant/components/bbox/manifest.json b/homeassistant/components/bbox/manifest.json
index 9035bea74bc..67e54ae2359 100644
--- a/homeassistant/components/bbox/manifest.json
+++ b/homeassistant/components/bbox/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bbox",
"iot_class": "local_polling",
"loggers": ["pybbox"],
+ "quality_scale": "legacy",
"requirements": ["pybbox==0.0.5-alpha"]
}
diff --git a/homeassistant/components/beewi_smartclim/manifest.json b/homeassistant/components/beewi_smartclim/manifest.json
index 3555f9181bb..baf41be4345 100644
--- a/homeassistant/components/beewi_smartclim/manifest.json
+++ b/homeassistant/components/beewi_smartclim/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/beewi_smartclim",
"iot_class": "local_polling",
"loggers": ["beewi_smartclim"],
+ "quality_scale": "legacy",
"requirements": ["beewi-smartclim==0.0.10"]
}
diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py
index baf6bf98547..f31c3d102b0 100644
--- a/homeassistant/components/binary_sensor/__init__.py
+++ b/homeassistant/components/binary_sensor/__init__.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from datetime import timedelta
from enum import StrEnum
-from functools import partial
import logging
from typing import Literal, final
@@ -16,12 +15,6 @@ from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -126,94 +119,7 @@ class BinarySensorDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass))
-
-# DEVICE_CLASS* below are deprecated as of 2021.12
-# use the BinarySensorDeviceClass enum instead.
DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass]
-_DEPRECATED_DEVICE_CLASS_BATTERY = DeprecatedConstantEnum(
- BinarySensorDeviceClass.BATTERY, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_BATTERY_CHARGING = DeprecatedConstantEnum(
- BinarySensorDeviceClass.BATTERY_CHARGING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_CO = DeprecatedConstantEnum(
- BinarySensorDeviceClass.CO, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_COLD = DeprecatedConstantEnum(
- BinarySensorDeviceClass.COLD, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_CONNECTIVITY = DeprecatedConstantEnum(
- BinarySensorDeviceClass.CONNECTIVITY, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum(
- BinarySensorDeviceClass.DOOR, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_GARAGE_DOOR = DeprecatedConstantEnum(
- BinarySensorDeviceClass.GARAGE_DOOR, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_GAS = DeprecatedConstantEnum(
- BinarySensorDeviceClass.GAS, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_HEAT = DeprecatedConstantEnum(
- BinarySensorDeviceClass.HEAT, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_LIGHT = DeprecatedConstantEnum(
- BinarySensorDeviceClass.LIGHT, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_LOCK = DeprecatedConstantEnum(
- BinarySensorDeviceClass.LOCK, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_MOISTURE = DeprecatedConstantEnum(
- BinarySensorDeviceClass.MOISTURE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_MOTION = DeprecatedConstantEnum(
- BinarySensorDeviceClass.MOTION, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_MOVING = DeprecatedConstantEnum(
- BinarySensorDeviceClass.MOVING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_OCCUPANCY = DeprecatedConstantEnum(
- BinarySensorDeviceClass.OCCUPANCY, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_OPENING = DeprecatedConstantEnum(
- BinarySensorDeviceClass.OPENING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PLUG = DeprecatedConstantEnum(
- BinarySensorDeviceClass.PLUG, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_POWER = DeprecatedConstantEnum(
- BinarySensorDeviceClass.POWER, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PRESENCE = DeprecatedConstantEnum(
- BinarySensorDeviceClass.PRESENCE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PROBLEM = DeprecatedConstantEnum(
- BinarySensorDeviceClass.PROBLEM, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_RUNNING = DeprecatedConstantEnum(
- BinarySensorDeviceClass.RUNNING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SAFETY = DeprecatedConstantEnum(
- BinarySensorDeviceClass.SAFETY, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SMOKE = DeprecatedConstantEnum(
- BinarySensorDeviceClass.SMOKE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SOUND = DeprecatedConstantEnum(
- BinarySensorDeviceClass.SOUND, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_TAMPER = DeprecatedConstantEnum(
- BinarySensorDeviceClass.TAMPER, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_UPDATE = DeprecatedConstantEnum(
- BinarySensorDeviceClass.UPDATE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_VIBRATION = DeprecatedConstantEnum(
- BinarySensorDeviceClass.VIBRATION, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum(
- BinarySensorDeviceClass.WINDOW, "2025.1"
-)
# mypy: disallow-any-generics
@@ -294,11 +200,3 @@ class BinarySensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
if (is_on := self.is_on) is None:
return None
return STATE_ON if is_on else STATE_OFF
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/bitcoin/manifest.json b/homeassistant/components/bitcoin/manifest.json
index 6f5fd678009..b208e904cab 100644
--- a/homeassistant/components/bitcoin/manifest.json
+++ b/homeassistant/components/bitcoin/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bitcoin",
"iot_class": "cloud_polling",
"loggers": ["blockchain"],
+ "quality_scale": "legacy",
"requirements": ["blockchain==1.4.4"]
}
diff --git a/homeassistant/components/bizkaibus/manifest.json b/homeassistant/components/bizkaibus/manifest.json
index b47df75bbe5..5a333546401 100644
--- a/homeassistant/components/bizkaibus/manifest.json
+++ b/homeassistant/components/bizkaibus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bizkaibus",
"iot_class": "cloud_polling",
"loggers": ["bizkaibus"],
+ "quality_scale": "legacy",
"requirements": ["bizkaibus==0.1.1"]
}
diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json
index d75b69dfaf8..a0f4b0c383c 100644
--- a/homeassistant/components/blackbird/manifest.json
+++ b/homeassistant/components/blackbird/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/blackbird",
"iot_class": "local_polling",
"loggers": ["pyblackbird"],
+ "quality_scale": "legacy",
"requirements": ["pyblackbird==0.6"]
}
diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py
index 62f15bd6e10..e37df26aaa8 100644
--- a/homeassistant/components/blink/config_flow.py
+++ b/homeassistant/components/blink/config_flow.py
@@ -10,7 +10,7 @@ from blinkpy.auth import Auth, LoginError, TokenRefreshFailed
from blinkpy.blinkpy import Blink, BlinkSetupError
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -61,6 +61,8 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN):
session=async_get_clientsession(self.hass),
)
await self.async_set_unique_id(user_input[CONF_USERNAME])
+ if self.source != SOURCE_REAUTH:
+ self._abort_if_unique_id_configured()
try:
await validate_input(self.auth)
diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json
index 70fac896ff2..d3592b6af6e 100644
--- a/homeassistant/components/blinksticklight/manifest.json
+++ b/homeassistant/components/blinksticklight/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/blinksticklight",
"iot_class": "local_polling",
"loggers": ["blinkstick"],
+ "quality_scale": "legacy",
"requirements": ["BlinkStick==1.2.0"]
}
diff --git a/homeassistant/components/blockchain/manifest.json b/homeassistant/components/blockchain/manifest.json
index 2e58dc5aa03..6c9182ee0c4 100644
--- a/homeassistant/components/blockchain/manifest.json
+++ b/homeassistant/components/blockchain/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/blockchain",
"iot_class": "cloud_polling",
"loggers": ["pyblockchain"],
+ "quality_scale": "legacy",
"requirements": ["python-blockchain-api==0.0.2"]
}
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index 97985a74300..38ef78fad3a 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -292,14 +292,6 @@ class BluesoundPlayer(MediaPlayerEntity):
self._last_status_update = dt_util.utcnow()
self._status = status
- group_name = status.group_name
- if group_name != self._group_name:
- _LOGGER.debug("Group name change detected on device: %s", self.id)
- self._group_name = group_name
-
- # rebuild ordered list of entity_ids that are in the group, master is first
- self._group_list = self.rebuild_bluesound_group()
-
self.async_write_ha_state()
except PlayerUnreachableError:
self._attr_available = False
@@ -323,6 +315,8 @@ class BluesoundPlayer(MediaPlayerEntity):
self._sync_status = sync_status
+ self._group_list = self.rebuild_bluesound_group()
+
if sync_status.master is not None:
self._is_master = False
master_id = f"{sync_status.master.ip}:{sync_status.master.port}"
@@ -619,21 +613,32 @@ class BluesoundPlayer(MediaPlayerEntity):
def rebuild_bluesound_group(self) -> list[str]:
"""Rebuild the list of entities in speaker group."""
- if self._group_name is None:
+ if self.sync_status.master is None and self.sync_status.slaves is None:
return []
- device_group = self._group_name.split("+")
+ player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND]
- sorted_entities: list[BluesoundPlayer] = sorted(
- self.hass.data[DATA_BLUESOUND],
- key=lambda entity: entity.is_master,
- reverse=True,
- )
- return [
- entity.sync_status.name
- for entity in sorted_entities
- if entity.bluesound_device_name in device_group
+ leader_sync_status: SyncStatus | None = None
+ if self.sync_status.master is None:
+ leader_sync_status = self.sync_status
+ else:
+ required_id = f"{self.sync_status.master.ip}:{self.sync_status.master.port}"
+ for x in player_entities:
+ if x.sync_status.id == required_id:
+ leader_sync_status = x.sync_status
+ break
+
+ if leader_sync_status is None or leader_sync_status.slaves is None:
+ return []
+
+ follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.slaves]
+ follower_names = [
+ x.sync_status.name
+ for x in player_entities
+ if x.sync_status.id in follower_ids
]
+ follower_names.insert(0, leader_sync_status.name)
+ return follower_names
async def async_unjoin(self) -> None:
"""Unjoin the player from a group."""
diff --git a/homeassistant/components/bluetooth_le_tracker/manifest.json b/homeassistant/components/bluetooth_le_tracker/manifest.json
index 79f885cad18..4abf5f7607e 100644
--- a/homeassistant/components/bluetooth_le_tracker/manifest.json
+++ b/homeassistant/components/bluetooth_le_tracker/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bluetooth_le_tracker",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json
index 0a0356e6669..8fb35b311c9 100644
--- a/homeassistant/components/bluetooth_tracker/manifest.json
+++ b/homeassistant/components/bluetooth_tracker/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker",
"iot_class": "local_polling",
"loggers": ["bluetooth", "bt_proximity"],
+ "quality_scale": "legacy",
"requirements": ["bt-proximity==0.2.1", "PyBluez==0.22"]
}
diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py
index 65bdfca997b..285ac98fc8f 100644
--- a/homeassistant/components/bmw_connected_drive/binary_sensor.py
+++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py
@@ -26,6 +26,8 @@ from .const import UNIT_MAP
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py
index e6bd92b92d7..85747278cb1 100644
--- a/homeassistant/components/bmw_connected_drive/button.py
+++ b/homeassistant/components/bmw_connected_drive/button.py
@@ -22,6 +22,8 @@ from .entity import BMWBaseEntity
if TYPE_CHECKING:
from .coordinator import BMWDataUpdateCoordinator
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py
index 409bfdca6f1..8831895c71e 100644
--- a/homeassistant/components/bmw_connected_drive/config_flow.py
+++ b/homeassistant/components/bmw_connected_drive/config_flow.py
@@ -27,9 +27,18 @@ from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_US
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
+from homeassistant.util.ssl import get_default_context
from . import DOMAIN
-from .const import CONF_ALLOWED_REGIONS, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN
+from .const import (
+ CONF_ALLOWED_REGIONS,
+ CONF_CAPTCHA_REGIONS,
+ CONF_CAPTCHA_TOKEN,
+ CONF_CAPTCHA_URL,
+ CONF_GCID,
+ CONF_READ_ONLY,
+ CONF_REFRESH_TOKEN,
+)
DATA_SCHEMA = vol.Schema(
{
@@ -41,7 +50,14 @@ DATA_SCHEMA = vol.Schema(
translation_key="regions",
)
),
- }
+ },
+ extra=vol.REMOVE_EXTRA,
+)
+CAPTCHA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_CAPTCHA_TOKEN): str,
+ },
+ extra=vol.REMOVE_EXTRA,
)
@@ -54,6 +70,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
data[CONF_USERNAME],
data[CONF_PASSWORD],
get_region_from_name(data[CONF_REGION]),
+ hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN),
+ verify=get_default_context(),
)
try:
@@ -79,15 +97,17 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
+ data: dict[str, Any] = {}
+
_existing_entry_data: Mapping[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] = {}
+ errors: dict[str, str] = self.data.pop("errors", {})
- if user_input is not None:
+ if user_input is not None and not errors:
unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}"
await self.async_set_unique_id(unique_id)
@@ -96,22 +116,35 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
else:
self._abort_if_unique_id_configured()
+ # Store user input for later use
+ self.data.update(user_input)
+
+ # North America and Rest of World require captcha token
+ if (
+ self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS
+ and CONF_CAPTCHA_TOKEN not in self.data
+ ):
+ return await self.async_step_captcha()
+
info = None
try:
- info = await validate_input(self.hass, user_input)
- entry_data = {
- **user_input,
- CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
- CONF_GCID: info.get(CONF_GCID),
- }
+ info = await validate_input(self.hass, self.data)
except MissingCaptcha:
errors["base"] = "missing_captcha"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
+ finally:
+ self.data.pop(CONF_CAPTCHA_TOKEN, None)
if info:
+ entry_data = {
+ **self.data,
+ CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
+ CONF_GCID: info.get(CONF_GCID),
+ }
+
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=entry_data
@@ -128,7 +161,7 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
schema = self.add_suggested_values_to_schema(
DATA_SCHEMA,
- self._existing_entry_data,
+ self._existing_entry_data or self.data,
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
@@ -147,6 +180,22 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
self._existing_entry_data = self._get_reconfigure_entry().data
return await self.async_step_user()
+ async def async_step_captcha(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Show captcha form."""
+ if user_input and user_input.get(CONF_CAPTCHA_TOKEN):
+ self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip()
+ return await self.async_step_user(self.data)
+
+ return self.async_show_form(
+ step_id="captcha",
+ data_schema=CAPTCHA_SCHEMA,
+ description_placeholders={
+ "captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION])
+ },
+ )
+
@staticmethod
@callback
def async_get_options_flow(
diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py
index 98d4acbfc91..750289e9d0a 100644
--- a/homeassistant/components/bmw_connected_drive/const.py
+++ b/homeassistant/components/bmw_connected_drive/const.py
@@ -8,10 +8,15 @@ ATTR_DIRECTION = "direction"
ATTR_VIN = "vin"
CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"]
+CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"]
CONF_READ_ONLY = "read_only"
CONF_ACCOUNT = "account"
CONF_REFRESH_TOKEN = "refresh_token"
CONF_GCID = "gcid"
+CONF_CAPTCHA_TOKEN = "captcha_token"
+CONF_CAPTCHA_URL = (
+ "https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html"
+)
DATA_HASS_CONFIG = "hass_config"
diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py
index d38b7ffacc2..4f560d16f9c 100644
--- a/homeassistant/components/bmw_connected_drive/coordinator.py
+++ b/homeassistant/components/bmw_connected_drive/coordinator.py
@@ -84,11 +84,6 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
if self.account.refresh_token != old_refresh_token:
self._update_config_entry_refresh_token(self.account.refresh_token)
- _LOGGER.debug(
- "bimmer_connected: refresh token %s > %s",
- old_refresh_token,
- self.account.refresh_token,
- )
def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None:
"""Update or delete the refresh_token in the Config Entry."""
diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py
index 977fd531e2c..b65c2c1b088 100644
--- a/homeassistant/components/bmw_connected_drive/device_tracker.py
+++ b/homeassistant/components/bmw_connected_drive/device_tracker.py
@@ -16,6 +16,8 @@ from .const import ATTR_DIRECTION
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bmw_connected_drive/diagnostics.py b/homeassistant/components/bmw_connected_drive/diagnostics.py
index ff3c6f29559..3950ea3dec2 100644
--- a/homeassistant/components/bmw_connected_drive/diagnostics.py
+++ b/homeassistant/components/bmw_connected_drive/diagnostics.py
@@ -16,6 +16,8 @@ from homeassistant.helpers.device_registry import DeviceEntry
from . import BMWConfigEntry
from .const import CONF_REFRESH_TOKEN
+PARALLEL_UPDATES = 1
+
if TYPE_CHECKING:
from bimmer_connected.vehicle import MyBMWVehicle
diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py
index 3dfc0b1c4d4..b715a1e38cc 100644
--- a/homeassistant/components/bmw_connected_drive/lock.py
+++ b/homeassistant/components/bmw_connected_drive/lock.py
@@ -18,7 +18,10 @@ from . import BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 1
+
DOOR_LOCK_STATE = "door_lock_state"
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json
index 584eb1eebb5..81928a59a52 100644
--- a/homeassistant/components/bmw_connected_drive/manifest.json
+++ b/homeassistant/components/bmw_connected_drive/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
- "quality_scale": "platinum",
- "requirements": ["bimmer-connected[china]==0.16.4"]
+ "requirements": ["bimmer-connected[china]==0.17.2"]
}
diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py
index 56523351e66..662a73a20cd 100644
--- a/homeassistant/components/bmw_connected_drive/notify.py
+++ b/homeassistant/components/bmw_connected_drive/notify.py
@@ -22,6 +22,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN, BMWConfigEntry
+PARALLEL_UPDATES = 1
+
ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"]
POI_SCHEMA = vol.Schema(
diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py
index 54519ff9e6b..cce71b3b2fd 100644
--- a/homeassistant/components/bmw_connected_drive/number.py
+++ b/homeassistant/components/bmw_connected_drive/number.py
@@ -22,6 +22,8 @@ from . import BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py
index 323768ad9eb..7bc91b098ae 100644
--- a/homeassistant/components/bmw_connected_drive/select.py
+++ b/homeassistant/components/bmw_connected_drive/select.py
@@ -19,6 +19,8 @@ from . import BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py
index e24e2dd75f6..555655511e8 100644
--- a/homeassistant/components/bmw_connected_drive/sensor.py
+++ b/homeassistant/components/bmw_connected_drive/sensor.py
@@ -34,6 +34,8 @@ from . import BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json
index 0e7a4a32ef4..8078971acd1 100644
--- a/homeassistant/components/bmw_connected_drive/strings.json
+++ b/homeassistant/components/bmw_connected_drive/strings.json
@@ -7,6 +7,16 @@
"password": "[%key:common::config_flow::data::password%]",
"region": "ConnectedDrive Region"
}
+ },
+ "captcha": {
+ "title": "Are you a robot?",
+ "description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.",
+ "data": {
+ "captcha_token": "Captcha token"
+ },
+ "data_description": {
+ "captcha_token": "One-time token retrieved from the captcha challenge."
+ }
}
},
"error": {
diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py
index e8a02efdcfc..f0214bc1262 100644
--- a/homeassistant/components/bmw_connected_drive/switch.py
+++ b/homeassistant/components/bmw_connected_drive/switch.py
@@ -18,6 +18,8 @@ from . import BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
from .entity import BMWBaseEntity
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json
index 08e4fb007b7..1d4c110f4fd 100644
--- a/homeassistant/components/bond/manifest.json
+++ b/homeassistant/components/bond/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bond",
"iot_class": "local_push",
"loggers": ["bond_async"],
- "quality_scale": "platinum",
"requirements": ["bond-async==0.2.1"],
"zeroconf": ["_bond._tcp.local."]
}
diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py
index d44b7eb9423..911c08a835d 100644
--- a/homeassistant/components/bring/const.py
+++ b/homeassistant/components/bring/const.py
@@ -9,4 +9,3 @@ ATTR_ITEM_NAME: Final = "item"
ATTR_NOTIFICATION_TYPE: Final = "message"
SERVICE_PUSH_NOTIFICATION = "send_message"
-UNIT_ITEMS = "items"
diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py
index 746ed397e1b..eddee46f3bc 100644
--- a/homeassistant/components/bring/sensor.py
+++ b/homeassistant/components/bring/sensor.py
@@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BringConfigEntry
-from .const import UNIT_ITEMS
from .coordinator import BringData, BringDataUpdateCoordinator
from .entity import BringBaseEntity
from .util import list_language, sum_attributes
@@ -48,19 +47,16 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
key=BringSensor.URGENT,
translation_key=BringSensor.URGENT,
value_fn=lambda lst, _: sum_attributes(lst, "urgent"),
- native_unit_of_measurement=UNIT_ITEMS,
),
BringSensorEntityDescription(
key=BringSensor.CONVENIENT,
translation_key=BringSensor.CONVENIENT,
value_fn=lambda lst, _: sum_attributes(lst, "convenient"),
- native_unit_of_measurement=UNIT_ITEMS,
),
BringSensorEntityDescription(
key=BringSensor.DISCOUNTED,
translation_key=BringSensor.DISCOUNTED,
value_fn=lambda lst, _: sum_attributes(lst, "discounted"),
- native_unit_of_measurement=UNIT_ITEMS,
),
BringSensorEntityDescription(
key=BringSensor.LIST_LANGUAGE,
diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json
index 9a93881b5d2..c8c12090118 100644
--- a/homeassistant/components/bring/strings.json
+++ b/homeassistant/components/bring/strings.json
@@ -1,4 +1,7 @@
{
+ "common": {
+ "shopping_list_items": "items"
+ },
"config": {
"step": {
"user": {
@@ -29,13 +32,16 @@
"entity": {
"sensor": {
"urgent": {
- "name": "Urgent"
+ "name": "Urgent",
+ "unit_of_measurement": "[%key:component::bring::common::shopping_list_items%]"
},
"convenient": {
- "name": "On occasion"
+ "name": "On occasion",
+ "unit_of_measurement": "[%key:component::bring::common::shopping_list_items%]"
},
"discounted": {
- "name": "Discount only"
+ "name": "Discount only",
+ "unit_of_measurement": "[%key:component::bring::common::shopping_list_items%]"
},
"list_language": {
"name": "Region & language",
diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json
index 4e773a6cff2..fa70f3a5dc5 100644
--- a/homeassistant/components/brother/manifest.json
+++ b/homeassistant/components/brother/manifest.json
@@ -8,7 +8,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
- "quality_scale": "platinum",
"requirements": ["brother==4.3.1"],
"zeroconf": [
{
diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py
index e86eb59d6bc..d49ebdf07ca 100644
--- a/homeassistant/components/brother/sensor.py
+++ b/homeassistant/components/brother/sensor.py
@@ -30,8 +30,6 @@ from .const import DOMAIN
ATTR_COUNTER = "counter"
ATTR_REMAINING_PAGES = "remaining_pages"
-UNIT_PAGES = "p"
-
_LOGGER = logging.getLogger(__name__)
@@ -52,7 +50,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="page_counter",
translation_key="page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.page_counter,
@@ -60,7 +57,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="bw_counter",
translation_key="bw_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.bw_counter,
@@ -68,7 +64,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="color_counter",
translation_key="color_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.color_counter,
@@ -76,7 +71,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="duplex_unit_pages_counter",
translation_key="duplex_unit_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.duplex_unit_pages_counter,
@@ -92,7 +86,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="drum_remaining_pages",
translation_key="drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.drum_remaining_pages,
@@ -100,7 +93,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="drum_counter",
translation_key="drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.drum_counter,
@@ -116,7 +108,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="black_drum_remaining_pages",
translation_key="black_drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.black_drum_remaining_pages,
@@ -124,7 +115,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="black_drum_counter",
translation_key="black_drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.black_drum_counter,
@@ -140,7 +130,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="cyan_drum_remaining_pages",
translation_key="cyan_drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.cyan_drum_remaining_pages,
@@ -148,7 +137,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="cyan_drum_counter",
translation_key="cyan_drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.cyan_drum_counter,
@@ -164,7 +152,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="magenta_drum_remaining_pages",
translation_key="magenta_drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.magenta_drum_remaining_pages,
@@ -172,7 +159,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="magenta_drum_counter",
translation_key="magenta_drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.magenta_drum_counter,
@@ -188,7 +174,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="yellow_drum_remaining_pages",
translation_key="yellow_drum_remaining_pages",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.yellow_drum_remaining_pages,
@@ -196,7 +181,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription(
key="yellow_drum_counter",
translation_key="yellow_drum_page_counter",
- native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.yellow_drum_counter,
diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json
index 3b5b38ce9a0..b502ed7e3b9 100644
--- a/homeassistant/components/brother/strings.json
+++ b/homeassistant/components/brother/strings.json
@@ -46,61 +46,75 @@
"name": "Status"
},
"page_counter": {
- "name": "Page counter"
+ "name": "Page counter",
+ "unit_of_measurement": "pages"
},
"bw_pages": {
- "name": "B/W pages"
+ "name": "B/W pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"color_pages": {
- "name": "Color pages"
+ "name": "Color pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"duplex_unit_page_counter": {
- "name": "Duplex unit page counter"
+ "name": "Duplex unit page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"drum_remaining_life": {
"name": "Drum remaining lifetime"
},
"drum_remaining_pages": {
- "name": "Drum remaining pages"
+ "name": "Drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"drum_page_counter": {
- "name": "Drum page counter"
+ "name": "Drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"black_drum_remaining_life": {
"name": "Black drum remaining lifetime"
},
"black_drum_remaining_pages": {
- "name": "Black drum remaining pages"
+ "name": "Black drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"black_drum_page_counter": {
- "name": "Black drum page counter"
+ "name": "Black drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"cyan_drum_remaining_life": {
"name": "Cyan drum remaining lifetime"
},
"cyan_drum_remaining_pages": {
- "name": "Cyan drum remaining pages"
+ "name": "Cyan drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"cyan_drum_page_counter": {
- "name": "Cyan drum page counter"
+ "name": "Cyan drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"magenta_drum_remaining_life": {
"name": "Magenta drum remaining lifetime"
},
"magenta_drum_remaining_pages": {
- "name": "Magenta drum remaining pages"
+ "name": "Magenta drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"magenta_drum_page_counter": {
- "name": "Magenta drum page counter"
+ "name": "Magenta drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"yellow_drum_remaining_life": {
"name": "Yellow drum remaining lifetime"
},
"yellow_drum_remaining_pages": {
- "name": "Yellow drum remaining pages"
+ "name": "Yellow drum remaining pages",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"yellow_drum_page_counter": {
- "name": "Yellow drum page counter"
+ "name": "Yellow drum page counter",
+ "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]"
},
"belt_unit_remaining_life": {
"name": "Belt unit remaining lifetime"
diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py
index 4d3c6ee2073..623bfbfef56 100644
--- a/homeassistant/components/bsblan/__init__.py
+++ b/homeassistant/components/bsblan/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_PASSKEY
from .coordinator import BSBLanUpdateCoordinator
-PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
+PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
type BSBLanConfigEntry = ConfigEntry[BSBLanData]
diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py
index fcbe88f2fac..6d992da395a 100644
--- a/homeassistant/components/bsblan/climate.py
+++ b/homeassistant/components/bsblan/climate.py
@@ -15,7 +15,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import format_mac
@@ -75,26 +75,19 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
super().__init__(data.coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
- self._attr_min_temp = float(data.static.min_temp.value)
- self._attr_max_temp = float(data.static.max_temp.value)
- if data.static.min_temp.unit in ("°C", "°C"):
- self._attr_temperature_unit = UnitOfTemperature.CELSIUS
- else:
- self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
+ self._attr_min_temp = data.static.min_temp.value
+ self._attr_max_temp = data.static.max_temp.value
+ self._attr_temperature_unit = data.coordinator.client.get_temperature_unit
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
- if self.coordinator.data.state.current_temperature.value == "---":
- # device returns no current temperature
- return None
-
- return float(self.coordinator.data.state.current_temperature.value)
+ return self.coordinator.data.state.current_temperature.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
- return float(self.coordinator.data.state.target_temperature.value)
+ return self.coordinator.data.state.target_temperature.value
@property
def hvac_mode(self) -> HVACMode | None:
diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py
index 1a4299fe72f..be9030d95b0 100644
--- a/homeassistant/components/bsblan/coordinator.py
+++ b/homeassistant/components/bsblan/coordinator.py
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import timedelta
from random import randint
-from bsblan import BSBLAN, BSBLANConnectionError, Sensor, State
+from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
@@ -20,6 +20,7 @@ class BSBLanCoordinatorData:
state: State
sensor: Sensor
+ dhw: HotWaterState
class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
@@ -59,6 +60,7 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
state = await self.client.state()
sensor = await self.client.sensor()
+ dhw = await self.client.hot_water_state()
except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
raise UpdateFailed(
@@ -66,4 +68,4 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
) from err
self.update_interval = self._get_update_interval()
- return BSBLanCoordinatorData(state=state, sensor=sensor)
+ return BSBLanCoordinatorData(state=state, sensor=sensor, dhw=dhw)
diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py
index eab03d7a50c..c13b4ad7650 100644
--- a/homeassistant/components/bsblan/sensor.py
+++ b/homeassistant/components/bsblan/sensor.py
@@ -72,11 +72,9 @@ class BSBLanSensor(BSBLanEntity, SensorEntity):
super().__init__(data.coordinator, data)
self.entity_description = description
self._attr_unique_id = f"{data.device.MAC}-{description.key}"
+ self._attr_temperature_unit = data.coordinator.client.get_temperature_unit
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
- value = self.entity_description.value_fn(self.coordinator.data)
- if value == "---":
- return None
- return value
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json
index 4fb374fee75..a73a89ca1cc 100644
--- a/homeassistant/components/bsblan/strings.json
+++ b/homeassistant/components/bsblan/strings.json
@@ -31,6 +31,12 @@
},
"set_data_error": {
"message": "An error occurred while sending the data to the BSBLAN device"
+ },
+ "set_temperature_error": {
+ "message": "An error occurred while setting the temperature"
+ },
+ "set_operation_mode_error": {
+ "message": "An error occurred while setting the operation mode"
}
},
"entity": {
diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py
new file mode 100644
index 00000000000..318408a9124
--- /dev/null
+++ b/homeassistant/components/bsblan/water_heater.py
@@ -0,0 +1,107 @@
+"""BSBLAN platform to control a compatible Water Heater Device."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from bsblan import BSBLANError
+
+from homeassistant.components.water_heater import (
+ STATE_ECO,
+ STATE_OFF,
+ WaterHeaterEntity,
+ WaterHeaterEntityFeature,
+)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_ON
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import BSBLanConfigEntry, BSBLanData
+from .const import DOMAIN
+from .entity import BSBLanEntity
+
+PARALLEL_UPDATES = 1
+
+# Mapping between BSBLan and HA operation modes
+OPERATION_MODES = {
+ "Eco": STATE_ECO, # Energy saving mode
+ "Off": STATE_OFF, # Protection mode
+ "On": STATE_ON, # Continuous comfort mode
+}
+
+OPERATION_MODES_REVERSE = {v: k for k, v in OPERATION_MODES.items()}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: BSBLanConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up BSBLAN water heater based on a config entry."""
+ data = entry.runtime_data
+ async_add_entities([BSBLANWaterHeater(data)])
+
+
+class BSBLANWaterHeater(BSBLanEntity, WaterHeaterEntity):
+ """Defines a BSBLAN water heater entity."""
+
+ _attr_name = None
+ _attr_supported_features = (
+ WaterHeaterEntityFeature.TARGET_TEMPERATURE
+ | WaterHeaterEntityFeature.OPERATION_MODE
+ )
+
+ def __init__(self, data: BSBLanData) -> None:
+ """Initialize BSBLAN water heater."""
+ super().__init__(data.coordinator, data)
+ self._attr_unique_id = format_mac(data.device.MAC)
+ self._attr_operation_list = list(OPERATION_MODES_REVERSE.keys())
+
+ # Set temperature limits based on device capabilities
+ self._attr_temperature_unit = data.coordinator.client.get_temperature_unit
+ self._attr_min_temp = data.coordinator.data.dhw.reduced_setpoint.value
+ self._attr_max_temp = data.coordinator.data.dhw.nominal_setpoint_max.value
+
+ @property
+ def current_operation(self) -> str | None:
+ """Return current operation."""
+ current_mode = self.coordinator.data.dhw.operating_mode.desc
+ return OPERATION_MODES.get(current_mode)
+
+ @property
+ def current_temperature(self) -> float | None:
+ """Return the current temperature."""
+ return self.coordinator.data.dhw.dhw_actual_value_top_temperature.value
+
+ @property
+ def target_temperature(self) -> float | None:
+ """Return the temperature we try to reach."""
+ return self.coordinator.data.dhw.nominal_setpoint.value
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ try:
+ await self.coordinator.client.set_hot_water(nominal_setpoint=temperature)
+ except BSBLANError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_temperature_error",
+ ) from err
+
+ await self.coordinator.async_request_refresh()
+
+ async def async_set_operation_mode(self, operation_mode: str) -> None:
+ """Set new operation mode."""
+ bsblan_mode = OPERATION_MODES_REVERSE.get(operation_mode)
+ try:
+ await self.coordinator.client.set_hot_water(operating_mode=bsblan_mode)
+ except BSBLANError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_operation_mode_error",
+ ) from err
+
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/bt_home_hub_5/manifest.json b/homeassistant/components/bt_home_hub_5/manifest.json
index c2d708d9a02..e260d443dc7 100644
--- a/homeassistant/components/bt_home_hub_5/manifest.json
+++ b/homeassistant/components/bt_home_hub_5/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bt_home_hub_5",
"iot_class": "local_polling",
"loggers": ["bthomehub5_devicelist"],
+ "quality_scale": "legacy",
"requirements": ["bthomehub5-devicelist==0.1.1"]
}
diff --git a/homeassistant/components/bt_smarthub/manifest.json b/homeassistant/components/bt_smarthub/manifest.json
index 8f2dc631e80..31dd99a493f 100644
--- a/homeassistant/components/bt_smarthub/manifest.json
+++ b/homeassistant/components/bt_smarthub/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/bt_smarthub",
"iot_class": "local_polling",
"loggers": ["btsmarthub_devicelist"],
+ "quality_scale": "legacy",
"requirements": ["btsmarthub-devicelist==0.2.3"]
}
diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py
index afce293402e..712f765237e 100644
--- a/homeassistant/components/buienradar/sensor.py
+++ b/homeassistant/components/buienradar/sensor.py
@@ -742,6 +742,7 @@ class BrSensor(SensorEntity):
) -> None:
"""Initialize the sensor."""
self.entity_description = description
+ self._data: BrData | None = None
self._measured = None
self._attr_unique_id = (
f"{coordinates[CONF_LATITUDE]:2.6f}{coordinates[CONF_LONGITUDE]:2.6f}"
@@ -756,17 +757,29 @@ class BrSensor(SensorEntity):
if description.key.startswith(PRECIPITATION_FORECAST):
self._timeframe = None
+ async def async_added_to_hass(self) -> None:
+ """Handle entity being added to hass."""
+ if self._data is None:
+ return
+ self._update()
+
@callback
def data_updated(self, data: BrData):
- """Update data."""
- if self._load_data(data.data) and self.hass:
+ """Handle data update."""
+ self._data = data
+ if not self.hass:
+ return
+ self._update()
+
+ def _update(self):
+ """Update sensor data."""
+ _LOGGER.debug("Updating sensor %s", self.entity_id)
+ if self._load_data(self._data.data):
self.async_write_ha_state()
@callback
def _load_data(self, data): # noqa: C901
"""Load the sensor with relevant data."""
- # Find sensor
-
# Check if we have a new measurement,
# otherwise we do not have to update the sensor
if self._measured == data.get(MEASURED):
diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json
index 76e6c42b666..c0127c20d05 100644
--- a/homeassistant/components/calendar/strings.json
+++ b/homeassistant/components/calendar/strings.json
@@ -82,11 +82,11 @@
},
"end_date_time": {
"name": "End time",
- "description": "Returns active events before this time (exclusive). Cannot be used with 'duration'."
+ "description": "Returns active events before this time (exclusive). Cannot be used with Duration."
},
"duration": {
"name": "Duration",
- "description": "Returns active events from start_date_time until the specified duration."
+ "description": "Returns active events from Start time for the specified duration."
}
}
}
diff --git a/homeassistant/components/cambridge_audio/__init__.py b/homeassistant/components/cambridge_audio/__init__.py
index a584f0db6c1..8b910bb81bb 100644
--- a/homeassistant/components/cambridge_audio/__init__.py
+++ b/homeassistant/components/cambridge_audio/__init__.py
@@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS
@@ -27,7 +28,7 @@ async def async_setup_entry(
) -> bool:
"""Set up Cambridge Audio integration from a config entry."""
- client = StreamMagicClient(entry.data[CONF_HOST])
+ client = StreamMagicClient(entry.data[CONF_HOST], async_get_clientsession(hass))
async def _connection_update_callback(
_client: StreamMagicClient, _callback_type: CallbackType
diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py
index 201e531608d..ca587ee9a48 100644
--- a/homeassistant/components/cambridge_audio/config_flow.py
+++ b/homeassistant/components/cambridge_audio/config_flow.py
@@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS
@@ -30,7 +31,7 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info.properties["serial"])
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
- client = StreamMagicClient(host)
+ client = StreamMagicClient(host, async_get_clientsession(self.hass))
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await client.connect()
@@ -69,7 +70,9 @@ class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input:
- client = StreamMagicClient(user_input[CONF_HOST])
+ client = StreamMagicClient(
+ user_input[CONF_HOST], async_get_clientsession(self.hass)
+ )
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await client.connect()
diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json
index c359ca14a21..7b7e341e3c6 100644
--- a/homeassistant/components/cambridge_audio/manifest.json
+++ b/homeassistant/components/cambridge_audio/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
- "requirements": ["aiostreammagic==2.8.5"],
+ "requirements": ["aiostreammagic==2.10.0"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}
diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py
index 5e340cdd21e..805cf8ec7f6 100644
--- a/homeassistant/components/cambridge_audio/media_player.py
+++ b/homeassistant/components/cambridge_audio/media_player.py
@@ -57,6 +57,8 @@ TRANSPORT_FEATURES: dict[TransportControl, MediaPlayerEntityFeature] = {
TransportControl.STOP: MediaPlayerEntityFeature.STOP,
}
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py
index c99abc853e5..b1bc0f9e4df 100644
--- a/homeassistant/components/cambridge_audio/select.py
+++ b/homeassistant/components/cambridge_audio/select.py
@@ -12,7 +12,9 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .entity import CambridgeAudioEntity
+from .entity import CambridgeAudioEntity, command
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -116,6 +118,7 @@ class CambridgeAudioSelect(CambridgeAudioEntity, SelectEntity):
"""Return the state of the select."""
return self.entity_description.value_fn(self.client)
+ @command
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.set_value_fn(self.client, option)
diff --git a/homeassistant/components/cambridge_audio/switch.py b/homeassistant/components/cambridge_audio/switch.py
index 3209b275d46..72aa0d3cbea 100644
--- a/homeassistant/components/cambridge_audio/switch.py
+++ b/homeassistant/components/cambridge_audio/switch.py
@@ -12,7 +12,9 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .entity import CambridgeAudioEntity
+from .entity import CambridgeAudioEntity, command
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -73,10 +75,12 @@ class CambridgeAudioSwitch(CambridgeAudioEntity, SwitchEntity):
"""Return the state of the switch."""
return self.entity_description.value_fn(self.client)
+ @command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_value_fn(self.client, True)
+ @command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_value_fn(self.client, False)
diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py
index 64a4480d9d3..4d718433fca 100644
--- a/homeassistant/components/camera/__init__.py
+++ b/homeassistant/components/camera/__init__.py
@@ -55,19 +55,19 @@ from homeassistant.helpers.deprecation import (
DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
+ deprecated_function,
dir_with_deprecated_constants,
)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.network import get_url
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.loader import bind_hass
-from .const import ( # noqa: F401
- _DEPRECATED_STREAM_TYPE_HLS,
- _DEPRECATED_STREAM_TYPE_WEB_RTC,
+from .const import (
CAMERA_IMAGE_TIMEOUT,
CAMERA_STREAM_SOURCE_TIMEOUT,
CONF_DURATION,
@@ -133,16 +133,6 @@ class CameraEntityFeature(IntFlag):
STREAM = 2
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Pleease use the CameraEntityFeature enum instead.
-_DEPRECATED_SUPPORT_ON_OFF: Final = DeprecatedConstantEnum(
- CameraEntityFeature.ON_OFF, "2025.1"
-)
-_DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum(
- CameraEntityFeature.STREAM, "2025.1"
-)
-
-
DEFAULT_CONTENT_TYPE: Final = "image/jpeg"
ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}"
@@ -466,6 +456,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# Entity Properties
_attr_brand: str | None = None
_attr_frame_interval: float = MIN_STREAM_INTERVAL
+ # Deprecated in 2024.12. Remove in 2025.6
_attr_frontend_stream_type: StreamType | None
_attr_is_on: bool = True
_attr_is_recording: bool = False
@@ -497,6 +488,16 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
)
+ self._deprecate_attr_frontend_stream_type_logged = False
+ if type(self).frontend_stream_type != Camera.frontend_stream_type:
+ report_usage(
+ (
+ f"is overwriting the 'frontend_stream_type' property in the {type(self).__name__} class,"
+ " which is deprecated and will be removed in Home Assistant 2025.6, "
+ ),
+ core_integration_behavior=ReportBehavior.ERROR,
+ exclude_integrations={DOMAIN},
+ )
@cached_property
def entity_picture(self) -> str:
@@ -566,11 +567,29 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
frontend which camera attributes and player to use. The default type
is to use HLS, and components can override to change the type.
"""
+ # Deprecated in 2024.12. Remove in 2025.6
+ # Use the camera_capabilities instead
if hasattr(self, "_attr_frontend_stream_type"):
+ if not self._deprecate_attr_frontend_stream_type_logged:
+ report_usage(
+ (
+ f"is setting the '_attr_frontend_stream_type' attribute in the {type(self).__name__} class,"
+ " which is deprecated and will be removed in Home Assistant 2025.6, "
+ ),
+ core_integration_behavior=ReportBehavior.ERROR,
+ exclude_integrations={DOMAIN},
+ )
+
+ self._deprecate_attr_frontend_stream_type_logged = True
return self._attr_frontend_stream_type
if CameraEntityFeature.STREAM not in self.supported_features_compat:
return None
- if self._webrtc_provider or self._legacy_webrtc_provider:
+ if (
+ self._webrtc_provider
+ or self._legacy_webrtc_provider
+ or self._supports_native_sync_webrtc
+ or self._supports_native_async_webrtc
+ ):
return StreamType.WEB_RTC
return StreamType.HLS
@@ -628,14 +647,17 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Async means that it could take some time to process the offer and responses/message
will be sent with the send_message callback.
- This method is used by cameras with CameraEntityFeature.STREAM and StreamType.WEB_RTC.
+ This method is used by cameras with CameraEntityFeature.STREAM.
An integration overriding this method must also implement async_on_webrtc_candidate.
Integrations can override with a native WebRTC implementation.
"""
if self._supports_native_sync_webrtc:
try:
- answer = await self.async_handle_web_rtc_offer(offer_sdp)
+ answer = await deprecated_function(
+ "async_handle_async_webrtc_offer",
+ breaks_in_ha_version="2025.6",
+ )(self.async_handle_web_rtc_offer)(offer_sdp)
except ValueError as ex:
_LOGGER.error("Error handling WebRTC offer: %s", ex)
send_message(
diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py
index 7e4633d410a..65862e66dab 100644
--- a/homeassistant/components/camera/const.py
+++ b/homeassistant/components/camera/const.py
@@ -3,15 +3,8 @@
from __future__ import annotations
from enum import StrEnum
-from functools import partial
from typing import TYPE_CHECKING, Final
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
@@ -58,17 +51,3 @@ class StreamType(StrEnum):
HLS = "hls"
WEB_RTC = "web_rtc"
-
-
-# These constants are deprecated as of Home Assistant 2022.5
-# Please use the StreamType enum instead.
-_DEPRECATED_STREAM_TYPE_HLS = DeprecatedConstantEnum(StreamType.HLS, "2025.1")
-_DEPRECATED_STREAM_TYPE_WEB_RTC = DeprecatedConstantEnum(StreamType.WEB_RTC, "2025.1")
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py
index 222c95ff998..701457afc3e 100644
--- a/homeassistant/components/camera/media_source.py
+++ b/homeassistant/components/camera/media_source.py
@@ -95,14 +95,16 @@ class CameraMediaSource(MediaSource):
can_stream_hls = "stream" in self.hass.config.components
async def _filter_browsable_camera(camera: Camera) -> BrowseMediaSource | None:
- stream_type = camera.frontend_stream_type
- if stream_type is None:
+ stream_types = camera.camera_capabilities.frontend_stream_types
+ if not stream_types:
return _media_source_for_camera(self.hass, camera, camera.content_type)
if not can_stream_hls:
return None
content_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER]
- if stream_type != StreamType.HLS and not (await camera.stream_source()):
+ if StreamType.HLS not in stream_types and not (
+ await camera.stream_source()
+ ):
return None
return _media_source_for_camera(self.hass, camera, content_type)
diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py
index f020df61092..3630acf1cfe 100644
--- a/homeassistant/components/camera/webrtc.py
+++ b/homeassistant/components/camera/webrtc.py
@@ -10,6 +10,7 @@ from functools import cache, partial, wraps
import logging
from typing import TYPE_CHECKING, Any, Protocol
+from mashumaro import MissingField
import voluptuous as vol
from webrtc_models import (
RTCConfiguration,
@@ -22,6 +23,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
+from homeassistant.helpers.deprecation import deprecated_function
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ulid import ulid
@@ -89,7 +91,7 @@ class WebRTCCandidate(WebRTCMessage):
"""Return a dict representation of the message."""
return {
"type": self._get_type(),
- "candidate": self.candidate.candidate,
+ "candidate": self.candidate.to_dict(),
}
@@ -328,12 +330,20 @@ async def ws_get_client_config(
)
+def _parse_webrtc_candidate_init(value: Any) -> RTCIceCandidateInit:
+ """Validate and parse a WebRTCCandidateInit dict."""
+ try:
+ return RTCIceCandidateInit.from_dict(value)
+ except (MissingField, ValueError) as ex:
+ raise vol.Invalid(str(ex)) from ex
+
+
@websocket_api.websocket_command(
{
vol.Required("type"): "camera/webrtc/candidate",
vol.Required("entity_id"): cv.entity_id,
vol.Required("session_id"): str,
- vol.Required("candidate"): str,
+ vol.Required("candidate"): _parse_webrtc_candidate_init,
}
)
@websocket_api.async_response
@@ -342,9 +352,7 @@ async def ws_candidate(
connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None:
"""Handle WebRTC candidate websocket command."""
- await camera.async_on_webrtc_candidate(
- msg["session_id"], RTCIceCandidateInit(msg["candidate"])
- )
+ await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"])
connection.send_message(websocket_api.result_message(msg["id"]))
@@ -438,6 +446,7 @@ class _CameraRtspToWebRTCProvider(CameraWebRTCLegacyProvider):
return await self._fn(stream_source, offer_sdp, camera.entity_id)
+@deprecated_function("async_register_webrtc_provider", breaks_in_ha_version="2025.6")
def async_register_rtsp_to_web_rtc_provider(
hass: HomeAssistant,
domain: str,
diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py
index 2dd3a678b5d..17e660e96ac 100644
--- a/homeassistant/components/canary/config_flow.py
+++ b/homeassistant/components/canary/config_flow.py
@@ -62,9 +62,6 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
- if self._async_current_entries():
- return self.async_abort(reason="single_instance_allowed")
-
errors = {}
default_username = ""
diff --git a/homeassistant/components/canary/manifest.json b/homeassistant/components/canary/manifest.json
index 4d5adf4a32b..9383bc91556 100644
--- a/homeassistant/components/canary/manifest.json
+++ b/homeassistant/components/canary/manifest.json
@@ -7,5 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/canary",
"iot_class": "cloud_polling",
"loggers": ["canary"],
- "requirements": ["py-canary==0.5.4"]
+ "requirements": ["py-canary==0.5.4"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/canary/strings.json b/homeassistant/components/canary/strings.json
index 9555756deff..699e8b25e11 100644
--- a/homeassistant/components/canary/strings.json
+++ b/homeassistant/components/canary/strings.json
@@ -14,7 +14,6 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json
index 12f2edeee9a..9c49813bd83 100644
--- a/homeassistant/components/cast/strings.json
+++ b/homeassistant/components/cast/strings.json
@@ -53,7 +53,7 @@
},
"view_path": {
"name": "View path",
- "description": "The path of the dashboard view to show."
+ "description": "The URL path of the dashboard view to show."
}
}
}
diff --git a/homeassistant/components/channels/manifest.json b/homeassistant/components/channels/manifest.json
index 0455ca2e8ad..9476e006eda 100644
--- a/homeassistant/components/channels/manifest.json
+++ b/homeassistant/components/channels/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/channels",
"iot_class": "local_polling",
"loggers": ["pychannels"],
+ "quality_scale": "legacy",
"requirements": ["pychannels==1.2.3"]
}
diff --git a/homeassistant/components/cisco_ios/manifest.json b/homeassistant/components/cisco_ios/manifest.json
index dd0d4213973..ba0678c167f 100644
--- a/homeassistant/components/cisco_ios/manifest.json
+++ b/homeassistant/components/cisco_ios/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/cisco_ios",
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
+ "quality_scale": "legacy",
"requirements": ["pexpect==4.6.0"]
}
diff --git a/homeassistant/components/cisco_mobility_express/manifest.json b/homeassistant/components/cisco_mobility_express/manifest.json
index 02786e80cd8..f9ee1c92ed1 100644
--- a/homeassistant/components/cisco_mobility_express/manifest.json
+++ b/homeassistant/components/cisco_mobility_express/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/cisco_mobility_express",
"iot_class": "local_polling",
"loggers": ["ciscomobilityexpress"],
+ "quality_scale": "legacy",
"requirements": ["ciscomobilityexpress==0.3.9"]
}
diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json
index 3da31a0b453..85cfeb7eddf 100644
--- a/homeassistant/components/cisco_webex_teams/manifest.json
+++ b/homeassistant/components/cisco_webex_teams/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/cisco_webex_teams",
"iot_class": "cloud_push",
"loggers": ["webexpythonsdk"],
+ "quality_scale": "legacy",
"requirements": ["webexpythonsdk==2.0.1"]
}
diff --git a/homeassistant/components/citybikes/manifest.json b/homeassistant/components/citybikes/manifest.json
index e163b85ec08..8dac7def832 100644
--- a/homeassistant/components/citybikes/manifest.json
+++ b/homeassistant/components/citybikes/manifest.json
@@ -3,5 +3,6 @@
"name": "CityBikes",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/citybikes",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/clementine/manifest.json b/homeassistant/components/clementine/manifest.json
index 88e7f35f49a..42fe81d0e9b 100644
--- a/homeassistant/components/clementine/manifest.json
+++ b/homeassistant/components/clementine/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/clementine",
"iot_class": "local_polling",
"loggers": ["clementineremote"],
+ "quality_scale": "legacy",
"requirements": ["python-clementine-remote==1.0.1"]
}
diff --git a/homeassistant/components/clickatell/manifest.json b/homeassistant/components/clickatell/manifest.json
index 31456b25c64..3c5ee8b0053 100644
--- a/homeassistant/components/clickatell/manifest.json
+++ b/homeassistant/components/clickatell/manifest.json
@@ -3,5 +3,6 @@
"name": "Clickatell",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/clickatell",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/clicksend/manifest.json b/homeassistant/components/clicksend/manifest.json
index 41bd10108f4..8a43428026b 100644
--- a/homeassistant/components/clicksend/manifest.json
+++ b/homeassistant/components/clicksend/manifest.json
@@ -3,5 +3,6 @@
"name": "ClickSend SMS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/clicksend",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/clicksend_tts/manifest.json b/homeassistant/components/clicksend_tts/manifest.json
index ffa35fd070f..eb884e41203 100644
--- a/homeassistant/components/clicksend_tts/manifest.json
+++ b/homeassistant/components/clicksend_tts/manifest.json
@@ -3,5 +3,6 @@
"name": "ClickSend TTS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/clicksend_tts",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py
index 94db8008aa1..045003dcd0f 100644
--- a/homeassistant/components/climate/__init__.py
+++ b/homeassistant/components/climate/__init__.py
@@ -26,11 +26,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
-from homeassistant.helpers.deprecation import (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
@@ -41,20 +36,6 @@ from homeassistant.util.hass_dict import HassKey
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ( # noqa: F401
- _DEPRECATED_HVAC_MODE_AUTO,
- _DEPRECATED_HVAC_MODE_COOL,
- _DEPRECATED_HVAC_MODE_DRY,
- _DEPRECATED_HVAC_MODE_FAN_ONLY,
- _DEPRECATED_HVAC_MODE_HEAT,
- _DEPRECATED_HVAC_MODE_HEAT_COOL,
- _DEPRECATED_HVAC_MODE_OFF,
- _DEPRECATED_SUPPORT_AUX_HEAT,
- _DEPRECATED_SUPPORT_FAN_MODE,
- _DEPRECATED_SUPPORT_PRESET_MODE,
- _DEPRECATED_SUPPORT_SWING_MODE,
- _DEPRECATED_SUPPORT_TARGET_HUMIDITY,
- _DEPRECATED_SUPPORT_TARGET_TEMPERATURE,
- _DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE,
ATTR_AUX_HEAT,
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
@@ -70,6 +51,8 @@ from .const import ( # noqa: F401
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
+ ATTR_SWING_HORIZONTAL_MODE,
+ ATTR_SWING_HORIZONTAL_MODES,
ATTR_SWING_MODE,
ATTR_SWING_MODES,
ATTR_TARGET_TEMP_HIGH,
@@ -101,6 +84,7 @@ from .const import ( # noqa: F401
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
SWING_BOTH,
@@ -219,6 +203,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_handle_set_swing_mode_service",
[ClimateEntityFeature.SWING_MODE],
)
+ component.async_register_entity_service(
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
+ {vol.Required(ATTR_SWING_HORIZONTAL_MODE): cv.string},
+ "async_handle_set_swing_horizontal_mode_service",
+ [ClimateEntityFeature.SWING_HORIZONTAL_MODE],
+ )
return True
@@ -256,6 +246,8 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"fan_modes",
"swing_mode",
"swing_modes",
+ "swing_horizontal_mode",
+ "swing_horizontal_modes",
"supported_features",
"min_temp",
"max_temp",
@@ -300,6 +292,8 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_supported_features: ClimateEntityFeature = ClimateEntityFeature(0)
_attr_swing_mode: str | None
_attr_swing_modes: list[str] | None
+ _attr_swing_horizontal_mode: str | None
+ _attr_swing_horizontal_modes: list[str] | None
_attr_target_humidity: float | None = None
_attr_target_temperature_high: float | None
_attr_target_temperature_low: float | None
@@ -314,14 +308,14 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# once migrated and set the feature flags TURN_ON/TURN_OFF as needed.
_enable_turn_on_off_backwards_compatibility: bool = True
- def __getattribute__(self, __name: str) -> Any:
+ def __getattribute__(self, name: str, /) -> Any:
"""Get attribute.
Modify return of `supported_features` to
include `_mod_supported_features` if attribute is set.
"""
- if __name != "supported_features":
- return super().__getattribute__(__name)
+ if name != "supported_features":
+ return super().__getattribute__(name)
# Convert the supported features to ClimateEntityFeature.
# Remove this compatibility shim in 2025.1 or later.
@@ -513,6 +507,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if ClimateEntityFeature.SWING_MODE in supported_features:
data[ATTR_SWING_MODES] = self.swing_modes
+ if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features:
+ data[ATTR_SWING_HORIZONTAL_MODES] = self.swing_horizontal_modes
+
return data
@final
@@ -564,6 +561,9 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if ClimateEntityFeature.SWING_MODE in supported_features:
data[ATTR_SWING_MODE] = self.swing_mode
+ if ClimateEntityFeature.SWING_HORIZONTAL_MODE in supported_features:
+ data[ATTR_SWING_HORIZONTAL_MODE] = self.swing_horizontal_mode
+
if ClimateEntityFeature.AUX_HEAT in supported_features:
data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF
if (
@@ -691,11 +691,27 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""
return self._attr_swing_modes
+ @cached_property
+ def swing_horizontal_mode(self) -> str | None:
+ """Return the horizontal swing setting.
+
+ Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE.
+ """
+ return self._attr_swing_horizontal_mode
+
+ @cached_property
+ def swing_horizontal_modes(self) -> list[str] | None:
+ """Return the list of available horizontal swing modes.
+
+ Requires ClimateEntityFeature.SWING_HORIZONTAL_MODE.
+ """
+ return self._attr_swing_horizontal_modes
+
@final
@callback
def _valid_mode_or_raise(
self,
- mode_type: Literal["preset", "swing", "fan", "hvac"],
+ mode_type: Literal["preset", "horizontal_swing", "swing", "fan", "hvac"],
mode: str | HVACMode,
modes: list[str] | list[HVACMode] | None,
) -> None:
@@ -793,6 +809,26 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Set new target swing operation."""
await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode)
+ @final
+ async def async_handle_set_swing_horizontal_mode_service(
+ self, swing_horizontal_mode: str
+ ) -> None:
+ """Validate and set new horizontal swing mode."""
+ self._valid_mode_or_raise(
+ "horizontal_swing", swing_horizontal_mode, self.swing_horizontal_modes
+ )
+ await self.async_set_swing_horizontal_mode(swing_horizontal_mode)
+
+ def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new target horizontal swing operation."""
+ raise NotImplementedError
+
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new target horizontal swing operation."""
+ await self.hass.async_add_executor_job(
+ self.set_swing_horizontal_mode, swing_horizontal_mode
+ )
+
@final
async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None:
"""Validate and set new preset mode."""
@@ -1027,13 +1063,3 @@ async def async_service_temperature_set(
kwargs[value] = temp
await entity.async_set_temperature(**kwargs)
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = ft.partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py
index a84a2f3c628..111401a2251 100644
--- a/homeassistant/components/climate/const.py
+++ b/homeassistant/components/climate/const.py
@@ -1,14 +1,6 @@
"""Provides the constants needed for component."""
from enum import IntFlag, StrEnum
-from functools import partial
-
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
class HVACMode(StrEnum):
@@ -37,15 +29,6 @@ class HVACMode(StrEnum):
FAN_ONLY = "fan_only"
-# These HVAC_MODE_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the HVACMode enum instead.
-_DEPRECATED_HVAC_MODE_OFF = DeprecatedConstantEnum(HVACMode.OFF, "2025.1")
-_DEPRECATED_HVAC_MODE_HEAT = DeprecatedConstantEnum(HVACMode.HEAT, "2025.1")
-_DEPRECATED_HVAC_MODE_COOL = DeprecatedConstantEnum(HVACMode.COOL, "2025.1")
-_DEPRECATED_HVAC_MODE_HEAT_COOL = DeprecatedConstantEnum(HVACMode.HEAT_COOL, "2025.1")
-_DEPRECATED_HVAC_MODE_AUTO = DeprecatedConstantEnum(HVACMode.AUTO, "2025.1")
-_DEPRECATED_HVAC_MODE_DRY = DeprecatedConstantEnum(HVACMode.DRY, "2025.1")
-_DEPRECATED_HVAC_MODE_FAN_ONLY = DeprecatedConstantEnum(HVACMode.FAN_ONLY, "2025.1")
HVAC_MODES = [cls.value for cls in HVACMode]
# No preset is active
@@ -92,6 +75,10 @@ SWING_BOTH = "both"
SWING_VERTICAL = "vertical"
SWING_HORIZONTAL = "horizontal"
+# Possible horizontal swing state
+SWING_HORIZONTAL_ON = "on"
+SWING_HORIZONTAL_OFF = "off"
+
class HVACAction(StrEnum):
"""HVAC action for climate devices."""
@@ -106,14 +93,6 @@ class HVACAction(StrEnum):
PREHEATING = "preheating"
-# These CURRENT_HVAC_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the HVACAction enum instead.
-_DEPRECATED_CURRENT_HVAC_OFF = DeprecatedConstantEnum(HVACAction.OFF, "2025.1")
-_DEPRECATED_CURRENT_HVAC_HEAT = DeprecatedConstantEnum(HVACAction.HEATING, "2025.1")
-_DEPRECATED_CURRENT_HVAC_COOL = DeprecatedConstantEnum(HVACAction.COOLING, "2025.1")
-_DEPRECATED_CURRENT_HVAC_DRY = DeprecatedConstantEnum(HVACAction.DRYING, "2025.1")
-_DEPRECATED_CURRENT_HVAC_IDLE = DeprecatedConstantEnum(HVACAction.IDLE, "2025.1")
-_DEPRECATED_CURRENT_HVAC_FAN = DeprecatedConstantEnum(HVACAction.FAN, "2025.1")
CURRENT_HVAC_ACTIONS = [cls.value for cls in HVACAction]
@@ -134,6 +113,8 @@ ATTR_HVAC_MODES = "hvac_modes"
ATTR_HVAC_MODE = "hvac_mode"
ATTR_SWING_MODES = "swing_modes"
ATTR_SWING_MODE = "swing_mode"
+ATTR_SWING_HORIZONTAL_MODE = "swing_horizontal_mode"
+ATTR_SWING_HORIZONTAL_MODES = "swing_horizontal_modes"
ATTR_TARGET_TEMP_HIGH = "target_temp_high"
ATTR_TARGET_TEMP_LOW = "target_temp_low"
ATTR_TARGET_TEMP_STEP = "target_temp_step"
@@ -153,6 +134,7 @@ SERVICE_SET_PRESET_MODE = "set_preset_mode"
SERVICE_SET_HUMIDITY = "set_humidity"
SERVICE_SET_HVAC_MODE = "set_hvac_mode"
SERVICE_SET_SWING_MODE = "set_swing_mode"
+SERVICE_SET_SWING_HORIZONTAL_MODE = "set_swing_horizontal_mode"
SERVICE_SET_TEMPERATURE = "set_temperature"
@@ -168,35 +150,4 @@ class ClimateEntityFeature(IntFlag):
AUX_HEAT = 64
TURN_OFF = 128
TURN_ON = 256
-
-
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the ClimateEntityFeature enum instead.
-_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum(
- ClimateEntityFeature.TARGET_TEMPERATURE, "2025.1"
-)
-_DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE = DeprecatedConstantEnum(
- ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, "2025.1"
-)
-_DEPRECATED_SUPPORT_TARGET_HUMIDITY = DeprecatedConstantEnum(
- ClimateEntityFeature.TARGET_HUMIDITY, "2025.1"
-)
-_DEPRECATED_SUPPORT_FAN_MODE = DeprecatedConstantEnum(
- ClimateEntityFeature.FAN_MODE, "2025.1"
-)
-_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum(
- ClimateEntityFeature.PRESET_MODE, "2025.1"
-)
-_DEPRECATED_SUPPORT_SWING_MODE = DeprecatedConstantEnum(
- ClimateEntityFeature.SWING_MODE, "2025.1"
-)
-_DEPRECATED_SUPPORT_AUX_HEAT = DeprecatedConstantEnum(
- ClimateEntityFeature.AUX_HEAT, "2025.1"
-)
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
+ SWING_HORIZONTAL_MODE = 512
diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json
index c9a8d12d01b..8f4ffa6b19f 100644
--- a/homeassistant/components/climate/icons.json
+++ b/homeassistant/components/climate/icons.json
@@ -51,6 +51,13 @@
"on": "mdi:arrow-oscillating",
"vertical": "mdi:arrow-up-down"
}
+ },
+ "swing_horizontal_mode": {
+ "default": "mdi:circle-medium",
+ "state": {
+ "off": "mdi:arrow-oscillating-off",
+ "on": "mdi:arrow-expand-horizontal"
+ }
}
}
}
@@ -65,6 +72,9 @@
"set_swing_mode": {
"service": "mdi:arrow-oscillating"
},
+ "set_swing_horizontal_mode": {
+ "service": "mdi:arrow-expand-horizontal"
+ },
"set_temperature": {
"service": "mdi:thermometer"
},
diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py
index 99357777fba..d38e243cb62 100644
--- a/homeassistant/components/climate/reproduce_state.py
+++ b/homeassistant/components/climate/reproduce_state.py
@@ -14,6 +14,7 @@ from .const import (
ATTR_HUMIDITY,
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -23,6 +24,7 @@ from .const import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
)
@@ -76,6 +78,14 @@ async def _async_reproduce_states(
):
await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE])
+ if (
+ ATTR_SWING_HORIZONTAL_MODE in state.attributes
+ and state.attributes[ATTR_SWING_HORIZONTAL_MODE] is not None
+ ):
+ await call_service(
+ SERVICE_SET_SWING_HORIZONTAL_MODE, [ATTR_SWING_HORIZONTAL_MODE]
+ )
+
if (
ATTR_FAN_MODE in state.attributes
and state.attributes[ATTR_FAN_MODE] is not None
diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml
index 12a8e6f001f..68421bf2386 100644
--- a/homeassistant/components/climate/services.yaml
+++ b/homeassistant/components/climate/services.yaml
@@ -131,7 +131,20 @@ set_swing_mode:
fields:
swing_mode:
required: true
- example: "horizontal"
+ example: "on"
+ selector:
+ text:
+
+set_swing_horizontal_mode:
+ target:
+ entity:
+ domain: climate
+ supported_features:
+ - climate.ClimateEntityFeature.SWING_HORIZONTAL_MODE
+ fields:
+ swing_horizontal_mode:
+ required: true
+ example: "on"
selector:
text:
diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py
index 0c4cdd4ac6a..2b7e2c5d8b1 100644
--- a/homeassistant/components/climate/significant_change.py
+++ b/homeassistant/components/climate/significant_change.py
@@ -19,6 +19,7 @@ from . import (
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -34,6 +35,7 @@ SIGNIFICANT_ATTRIBUTES: set[str] = {
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TEMPERATURE,
@@ -70,6 +72,7 @@ def async_check_significant_change(
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
ATTR_SWING_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
]:
return True
diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json
index 26a06821d84..6d8b2c5449d 100644
--- a/homeassistant/components/climate/strings.json
+++ b/homeassistant/components/climate/strings.json
@@ -123,6 +123,16 @@
"swing_modes": {
"name": "Swing modes"
},
+ "swing_horizontal_mode": {
+ "name": "Horizontal swing mode",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]"
+ }
+ },
+ "swing_horizontal_modes": {
+ "name": "Horizontal swing modes"
+ },
"target_temp_high": {
"name": "Upper target temperature"
},
@@ -161,19 +171,19 @@
},
"set_temperature": {
"name": "Set target temperature",
- "description": "Sets target temperature.",
+ "description": "Sets the temperature setpoint.",
"fields": {
"temperature": {
- "name": "Temperature",
- "description": "Target temperature."
+ "name": "Target temperature",
+ "description": "The temperature setpoint."
},
"target_temp_high": {
- "name": "Target temperature high",
- "description": "High target temperature."
+ "name": "Upper target temperature",
+ "description": "The max temperature setpoint."
},
"target_temp_low": {
- "name": "Target temperature low",
- "description": "Low target temperature."
+ "name": "Lower target temperature",
+ "description": "The min temperature setpoint."
},
"hvac_mode": {
"name": "HVAC mode",
@@ -221,6 +231,16 @@
}
}
},
+ "set_swing_horizontal_mode": {
+ "name": "Set horizontal swing mode",
+ "description": "Sets horizontal swing operation mode.",
+ "fields": {
+ "swing_horizontal_mode": {
+ "name": "Horizontal swing mode",
+ "description": "Horizontal swing operation mode."
+ }
+ }
+ },
"turn_on": {
"name": "[%key:common::action::turn_on%]",
"description": "Turns climate device on."
@@ -264,6 +284,9 @@
"not_valid_swing_mode": {
"message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}."
},
+ "not_valid_horizontal_swing_mode": {
+ "message": "Horizontal swing mode {mode} is not valid. Valid horizontal swing modes are: {modes}."
+ },
"not_valid_fan_mode": {
"message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}."
},
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index 4201cb1b2d4..60b105b401e 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -8,6 +8,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["hass_nabucasa"],
- "requirements": ["hass-nabucasa==0.84.0"],
+ "requirements": ["hass-nabucasa==0.85.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json
index 9f7e0dbadcd..1da91f67813 100644
--- a/homeassistant/components/cloud/strings.json
+++ b/homeassistant/components/cloud/strings.json
@@ -68,12 +68,12 @@
},
"services": {
"remote_connect": {
- "name": "Remote connect",
- "description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud."
+ "name": "Enable remote access",
+ "description": "Makes the instance UI accessible from outside of the local network by enabling your Home Assistant Cloud connection."
},
"remote_disconnect": {
- "name": "Remote disconnect",
- "description": "Disconnects the Home Assistant UI from the Home Assistant Cloud. You will no longer be able to access your Home Assistant instance from outside your local network."
+ "name": "Disable remote access",
+ "description": "Disconnects the instance UI from Home Assistant Cloud. This disables access to it from outside your local network."
}
}
}
diff --git a/homeassistant/components/cmus/manifest.json b/homeassistant/components/cmus/manifest.json
index f7591599022..9678dc52a68 100644
--- a/homeassistant/components/cmus/manifest.json
+++ b/homeassistant/components/cmus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/cmus",
"iot_class": "local_polling",
"loggers": ["pbr", "pycmus"],
+ "quality_scale": "legacy",
"requirements": ["pycmus==0.1.1"]
}
diff --git a/homeassistant/components/comed_hourly_pricing/manifest.json b/homeassistant/components/comed_hourly_pricing/manifest.json
index 791a824af8f..a3a29903ac7 100644
--- a/homeassistant/components/comed_hourly_pricing/manifest.json
+++ b/homeassistant/components/comed_hourly_pricing/manifest.json
@@ -3,5 +3,6 @@
"name": "ComEd Hourly Pricing",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/comed_hourly_pricing",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json
index d25d5c1d7d5..d7417ad4aad 100644
--- a/homeassistant/components/comelit/manifest.json
+++ b/homeassistant/components/comelit/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
- "quality_scale": "silver",
"requirements": ["aiocomelit==0.9.1"]
}
diff --git a/homeassistant/components/comfoconnect/manifest.json b/homeassistant/components/comfoconnect/manifest.json
index ae9a092f5d9..4157cb6c311 100644
--- a/homeassistant/components/comfoconnect/manifest.json
+++ b/homeassistant/components/comfoconnect/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/comfoconnect",
"iot_class": "local_push",
"loggers": ["pycomfoconnect"],
+ "quality_scale": "legacy",
"requirements": ["pycomfoconnect==0.5.1"]
}
diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py
index 7c31af165f9..e4c1370d5f7 100644
--- a/homeassistant/components/command_line/sensor.py
+++ b/homeassistant/components/command_line/sensor.py
@@ -187,13 +187,11 @@ class CommandSensor(ManualTriggerSensorEntity):
SensorDeviceClass.TIMESTAMP,
}:
self._attr_native_value = value
- self._process_manual_data(value)
- return
-
- if value is not None:
+ elif value is not None:
self._attr_native_value = async_parse_date_datetime(
value, self.entity_id, self.device_class
)
+
self._process_manual_data(value)
self.async_write_ha_state()
diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json
index 775bde3c859..5b3cc5ac2ac 100644
--- a/homeassistant/components/compensation/manifest.json
+++ b/homeassistant/components/compensation/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@Petro31"],
"documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated",
+ "quality_scale": "legacy",
"requirements": ["numpy==2.1.3"]
}
diff --git a/homeassistant/components/concord232/manifest.json b/homeassistant/components/concord232/manifest.json
index e0aea5d64d9..ebd1d68064b 100644
--- a/homeassistant/components/concord232/manifest.json
+++ b/homeassistant/components/concord232/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/concord232",
"iot_class": "local_polling",
"loggers": ["concord232", "stevedore"],
+ "quality_scale": "legacy",
"requirements": ["concord232==0.15.1"]
}
diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py
index c6d394a1366..59c09232b93 100644
--- a/homeassistant/components/conversation/default_agent.py
+++ b/homeassistant/components/conversation/default_agent.py
@@ -3,8 +3,10 @@
from __future__ import annotations
import asyncio
+from collections import OrderedDict
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
+from enum import Enum, auto
import functools
import logging
from pathlib import Path
@@ -12,8 +14,14 @@ import re
import time
from typing import IO, Any, cast
-from hassil.expression import Expression, ListReference, Sequence
-from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList
+from hassil.expression import Expression, ListReference, Sequence, TextChunk
+from hassil.intents import (
+ Intents,
+ SlotList,
+ TextSlotList,
+ TextSlotValue,
+ WildcardSlotList,
+)
from hassil.recognize import (
MISSING_ENTITY,
RecognizeResult,
@@ -21,6 +29,7 @@ from hassil.recognize import (
recognize_best,
)
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
+from hassil.trie import Trie
from hassil.util import merge_dict
from home_assistant_intents import ErrorKey, get_intents, get_languages
import yaml
@@ -61,7 +70,7 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
REGEX_TYPE = type(re.compile(""))
TRIGGER_CALLBACK_TYPE = Callable[
- [str, RecognizeResult, str | None], Awaitable[str | None]
+ [ConversationInput, RecognizeResult], Awaitable[str | None]
]
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
@@ -102,6 +111,77 @@ class SentenceTriggerResult:
matched_triggers: dict[int, RecognizeResult]
+class IntentMatchingStage(Enum):
+ """Stages of intent matching."""
+
+ EXPOSED_ENTITIES_ONLY = auto()
+ """Match against exposed entities only."""
+
+ UNEXPOSED_ENTITIES = auto()
+ """Match against unexposed entities in Home Assistant."""
+
+ FUZZY = auto()
+ """Capture names that are not known to Home Assistant."""
+
+
+@dataclass(frozen=True)
+class IntentCacheKey:
+ """Key for IntentCache."""
+
+ text: str
+ """User input text."""
+
+ language: str
+ """Language of text."""
+
+ device_id: str | None
+ """Device id from user input."""
+
+
+@dataclass(frozen=True)
+class IntentCacheValue:
+ """Value for IntentCache."""
+
+ result: RecognizeResult | None
+ """Result of intent recognition."""
+
+ stage: IntentMatchingStage
+ """Stage where result was found."""
+
+
+class IntentCache:
+ """LRU cache for intent recognition results."""
+
+ def __init__(self, capacity: int) -> None:
+ """Initialize cache."""
+ self.cache: OrderedDict[IntentCacheKey, IntentCacheValue] = OrderedDict()
+ self.capacity = capacity
+
+ def get(self, key: IntentCacheKey) -> IntentCacheValue | None:
+ """Get value for cache or None."""
+ if key not in self.cache:
+ return None
+
+ # Move the key to the end to show it was recently used
+ self.cache.move_to_end(key)
+ return self.cache[key]
+
+ def put(self, key: IntentCacheKey, value: IntentCacheValue) -> None:
+ """Put a value in the cache, evicting the least recently used item if necessary."""
+ if key in self.cache:
+ # Update value and mark as recently used
+ self.cache.move_to_end(key)
+ elif len(self.cache) >= self.capacity:
+ # Evict the oldest item
+ self.cache.popitem(last=False)
+
+ self.cache[key] = value
+
+ def clear(self) -> None:
+ """Clear the cache."""
+ self.cache.clear()
+
+
def _get_language_variations(language: str) -> Iterable[str]:
"""Generate language codes with and without region."""
yield language
@@ -161,12 +241,19 @@ class DefaultAgent(ConversationEntity):
self._config_intents: dict[str, Any] = config_intents
self._slot_lists: dict[str, SlotList] | None = None
+ # Used to filter slot lists before intent matching
+ self._exposed_names_trie: Trie | None = None
+ self._unexposed_names_trie: Trie | None = None
+
# Sentences that will trigger a callback (skipping intent recognition)
self._trigger_sentences: list[TriggerData] = []
self._trigger_intents: Intents | None = None
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
self._load_intents_lock = asyncio.Lock()
+ # LRU cache to avoid unnecessary intent matching
+ self._intent_cache = IntentCache(capacity=128)
+
@property
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
@@ -228,6 +315,16 @@ class DefaultAgent(ConversationEntity):
slot_lists = self._make_slot_lists()
intent_context = self._make_intent_context(user_input)
+ if self._exposed_names_trie is not None:
+ # Filter by input string
+ text_lower = user_input.text.strip().lower()
+ slot_lists["name"] = TextSlotList(
+ name="name",
+ values=[
+ result[2] for result in self._exposed_names_trie.find(text_lower)
+ ],
+ )
+
start = time.monotonic()
result = await self.hass.async_add_executor_job(
@@ -417,22 +514,232 @@ class DefaultAgent(ConversationEntity):
strict_intents_only: bool,
) -> RecognizeResult | None:
"""Search intents for a match to user input."""
- strict_result = self._recognize_strict(
- user_input, lang_intents, slot_lists, intent_context, language
- )
+ skip_exposed_match = False
- if strict_result is not None:
- # Successful strict match
- return strict_result
+ # Try cache first
+ cache_key = IntentCacheKey(
+ text=user_input.text, language=language, device_id=user_input.device_id
+ )
+ 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 == IntentMatchingStage.EXPOSED_ENTITIES_ONLY
+ ):
+ _LOGGER.debug("Got cached result for exposed entities")
+ return cache_value.result
+
+ # Continue with matching, but we know we won't succeed for exposed
+ # entities only.
+ skip_exposed_match = True
+
+ if not skip_exposed_match:
+ start_time = time.monotonic()
+ strict_result = self._recognize_strict(
+ user_input, lang_intents, slot_lists, intent_context, language
+ )
+ _LOGGER.debug(
+ "Checked exposed entities in %s second(s)",
+ time.monotonic() - start_time,
+ )
+
+ # Update cache
+ self._intent_cache.put(
+ cache_key,
+ IntentCacheValue(
+ result=strict_result,
+ stage=IntentMatchingStage.EXPOSED_ENTITIES_ONLY,
+ ),
+ )
+
+ if strict_result is not None:
+ # Successful strict match with exposed entities
+ return strict_result
if strict_intents_only:
+ # Don't try matching against all entities or doing a fuzzy match
return None
# Try again with all entities (including unexposed)
+ skip_unexposed_entities_match = False
+ if cache_value is not None:
+ if (cache_value.result is not None) and (
+ cache_value.stage == IntentMatchingStage.UNEXPOSED_ENTITIES
+ ):
+ _LOGGER.debug("Got cached result for all entities")
+ return cache_value.result
+
+ # Continue with matching, but we know we won't succeed for all
+ # entities.
+ skip_unexposed_entities_match = True
+
+ if not skip_unexposed_entities_match:
+ unexposed_entities_slot_lists = {
+ **slot_lists,
+ "name": self._get_unexposed_entity_names(user_input.text),
+ }
+
+ start_time = time.monotonic()
+ strict_result = self._recognize_strict(
+ user_input,
+ lang_intents,
+ unexposed_entities_slot_lists,
+ intent_context,
+ language,
+ )
+
+ _LOGGER.debug(
+ "Checked all entities in %s second(s)", time.monotonic() - start_time
+ )
+
+ # Update cache
+ self._intent_cache.put(
+ cache_key,
+ IntentCacheValue(
+ result=strict_result, stage=IntentMatchingStage.UNEXPOSED_ENTITIES
+ ),
+ )
+
+ if strict_result is not None:
+ # Not a successful match, but useful for an error message.
+ # This should fail the intent handling phase (async_match_targets).
+ return strict_result
+
+ # Try again with missing entities enabled
+ skip_fuzzy_match = False
+ if cache_value is not None:
+ if (cache_value.result is not None) and (
+ cache_value.stage == IntentMatchingStage.FUZZY
+ ):
+ _LOGGER.debug("Got cached result for fuzzy match")
+ return cache_value.result
+
+ # We know we won't succeed for fuzzy matching.
+ skip_fuzzy_match = True
+
+ maybe_result: RecognizeResult | None = None
+ if not skip_fuzzy_match:
+ start_time = time.monotonic()
+ best_num_matched_entities = 0
+ best_num_unmatched_entities = 0
+ best_num_unmatched_ranges = 0
+ for result in recognize_all(
+ user_input.text,
+ lang_intents.intents,
+ slot_lists=slot_lists,
+ intent_context=intent_context,
+ allow_unmatched_entities=True,
+ ):
+ if result.text_chunks_matched < 1:
+ # Skip results that don't match any literal text
+ continue
+
+ # Don't count missing entities that couldn't be filled from context
+ num_matched_entities = 0
+ for matched_entity in result.entities_list:
+ if matched_entity.name not in result.unmatched_entities:
+ num_matched_entities += 1
+
+ num_unmatched_entities = 0
+ num_unmatched_ranges = 0
+ for unmatched_entity in result.unmatched_entities_list:
+ if isinstance(unmatched_entity, UnmatchedTextEntity):
+ if unmatched_entity.text != MISSING_ENTITY:
+ num_unmatched_entities += 1
+ elif isinstance(unmatched_entity, UnmatchedRangeEntity):
+ num_unmatched_ranges += 1
+ num_unmatched_entities += 1
+ else:
+ num_unmatched_entities += 1
+
+ if (
+ (maybe_result is None) # first result
+ or (num_matched_entities > best_num_matched_entities)
+ or (
+ # Fewer unmatched entities
+ (num_matched_entities == best_num_matched_entities)
+ and (num_unmatched_entities < best_num_unmatched_entities)
+ )
+ or (
+ # Prefer unmatched ranges
+ (num_matched_entities == best_num_matched_entities)
+ and (num_unmatched_entities == best_num_unmatched_entities)
+ and (num_unmatched_ranges > best_num_unmatched_ranges)
+ )
+ or (
+ # More literal text matched
+ (num_matched_entities == best_num_matched_entities)
+ and (num_unmatched_entities == best_num_unmatched_entities)
+ and (num_unmatched_ranges == best_num_unmatched_ranges)
+ and (
+ result.text_chunks_matched
+ > maybe_result.text_chunks_matched
+ )
+ )
+ or (
+ # Prefer match failures with entities
+ (result.text_chunks_matched == maybe_result.text_chunks_matched)
+ and (num_unmatched_entities == best_num_unmatched_entities)
+ and (num_unmatched_ranges == best_num_unmatched_ranges)
+ and (
+ ("name" in result.entities)
+ or ("name" in result.unmatched_entities)
+ )
+ )
+ ):
+ maybe_result = result
+ best_num_matched_entities = num_matched_entities
+ best_num_unmatched_entities = num_unmatched_entities
+ best_num_unmatched_ranges = num_unmatched_ranges
+
+ # Update cache
+ self._intent_cache.put(
+ cache_key,
+ IntentCacheValue(result=maybe_result, stage=IntentMatchingStage.FUZZY),
+ )
+
+ _LOGGER.debug(
+ "Did fuzzy match in %s second(s)", time.monotonic() - start_time
+ )
+
+ return maybe_result
+
+ def _get_unexposed_entity_names(self, text: str) -> TextSlotList:
+ """Get filtered slot list with unexposed entity names in Home Assistant."""
+ if self._unexposed_names_trie is None:
+ # Build trie
+ self._unexposed_names_trie = Trie()
+ for name_tuple in self._get_entity_name_tuples(exposed=False):
+ self._unexposed_names_trie.insert(
+ name_tuple[0].lower(),
+ TextSlotValue.from_tuple(name_tuple),
+ )
+
+ # Build filtered slot list
+ text_lower = text.strip().lower()
+ return TextSlotList(
+ name="name",
+ values=[
+ result[2] for result in self._unexposed_names_trie.find(text_lower)
+ ],
+ )
+
+ def _get_entity_name_tuples(
+ self, exposed: bool
+ ) -> Iterable[tuple[str, str, dict[str, Any]]]:
+ """Yield (input name, output name, context) tuples for entities."""
entity_registry = er.async_get(self.hass)
- all_entity_names: list[tuple[str, str, dict[str, Any]]] = []
for state in self.hass.states.async_all():
+ entity_exposed = async_should_expose(self.hass, DOMAIN, state.entity_id)
+ if exposed and (not entity_exposed):
+ # Required exposed, entity is not
+ continue
+
+ if (not exposed) and entity_exposed:
+ # Required not exposed, entity is
+ continue
+
+ # Checked against "requires_context" and "excludes_context" in hassil
context = {"domain": state.domain}
if state.attributes:
# Include some attributes
@@ -441,114 +748,18 @@ class DefaultAgent(ConversationEntity):
continue
context[attr] = state.attributes[attr]
- if entity := entity_registry.async_get(state.entity_id):
- # Skip config/hidden entities
- if (entity.entity_category is not None) or (
- entity.hidden_by is not None
- ):
- continue
+ if (
+ entity := entity_registry.async_get(state.entity_id)
+ ) and entity.aliases:
+ for alias in entity.aliases:
+ alias = alias.strip()
+ if not alias:
+ continue
- if entity.aliases:
- # Also add aliases
- for alias in entity.aliases:
- if not alias.strip():
- continue
-
- all_entity_names.append((alias, alias, context))
+ yield (alias, alias, context)
# Default name
- all_entity_names.append((state.name, state.name, context))
-
- slot_lists = {
- **slot_lists,
- "name": TextSlotList.from_tuples(all_entity_names, allow_template=False),
- }
-
- strict_result = self._recognize_strict(
- user_input,
- lang_intents,
- slot_lists,
- intent_context,
- language,
- )
-
- if strict_result is not None:
- # Not a successful match, but useful for an error message.
- # This should fail the intent handling phase (async_match_targets).
- return strict_result
-
- # Try again with missing entities enabled
- maybe_result: RecognizeResult | None = None
- best_num_matched_entities = 0
- best_num_unmatched_entities = 0
- best_num_unmatched_ranges = 0
- for result in recognize_all(
- user_input.text,
- lang_intents.intents,
- slot_lists=slot_lists,
- intent_context=intent_context,
- allow_unmatched_entities=True,
- ):
- if result.text_chunks_matched < 1:
- # Skip results that don't match any literal text
- continue
-
- # Don't count missing entities that couldn't be filled from context
- num_matched_entities = 0
- for matched_entity in result.entities_list:
- if matched_entity.name not in result.unmatched_entities:
- num_matched_entities += 1
-
- num_unmatched_entities = 0
- num_unmatched_ranges = 0
- for unmatched_entity in result.unmatched_entities_list:
- if isinstance(unmatched_entity, UnmatchedTextEntity):
- if unmatched_entity.text != MISSING_ENTITY:
- num_unmatched_entities += 1
- elif isinstance(unmatched_entity, UnmatchedRangeEntity):
- num_unmatched_ranges += 1
- num_unmatched_entities += 1
- else:
- num_unmatched_entities += 1
-
- if (
- (maybe_result is None) # first result
- or (num_matched_entities > best_num_matched_entities)
- or (
- # Fewer unmatched entities
- (num_matched_entities == best_num_matched_entities)
- and (num_unmatched_entities < best_num_unmatched_entities)
- )
- or (
- # Prefer unmatched ranges
- (num_matched_entities == best_num_matched_entities)
- and (num_unmatched_entities == best_num_unmatched_entities)
- and (num_unmatched_ranges > best_num_unmatched_ranges)
- )
- or (
- # More literal text matched
- (num_matched_entities == best_num_matched_entities)
- and (num_unmatched_entities == best_num_unmatched_entities)
- and (num_unmatched_ranges == best_num_unmatched_ranges)
- and (result.text_chunks_matched > maybe_result.text_chunks_matched)
- )
- or (
- # Prefer match failures with entities
- (result.text_chunks_matched == maybe_result.text_chunks_matched)
- and (num_unmatched_entities == best_num_unmatched_entities)
- and (num_unmatched_ranges == best_num_unmatched_ranges)
- and (
- ("name" in result.entities)
- or ("name" in result.unmatched_entities)
- )
- )
- ):
- maybe_result = result
- best_num_matched_entities = num_matched_entities
- best_num_unmatched_entities = num_unmatched_entities
- best_num_unmatched_ranges = num_unmatched_ranges
-
- return maybe_result
+ yield (state.name, state.name, context)
def _recognize_strict(
self,
@@ -653,6 +864,9 @@ class DefaultAgent(ConversationEntity):
self._lang_intents.pop(language, None)
_LOGGER.debug("Cleared intents for language: %s", language)
+ # Intents have changed, so we must clear the cache
+ self._intent_cache.clear()
+
async def async_prepare(self, language: str | None = None) -> None:
"""Load intents for a language."""
if language is None:
@@ -837,10 +1051,15 @@ class DefaultAgent(ConversationEntity):
if self._unsub_clear_slot_list is None:
return
self._slot_lists = None
+ self._exposed_names_trie = None
+ self._unexposed_names_trie = None
for unsub in self._unsub_clear_slot_list:
unsub()
self._unsub_clear_slot_list = None
+ # Slot lists have changed, so we must clear the cache
+ self._intent_cache.clear()
+
@core.callback
def _make_slot_lists(self) -> dict[str, SlotList]:
"""Create slot lists with areas and entity names/aliases."""
@@ -849,8 +1068,6 @@ class DefaultAgent(ConversationEntity):
start = time.monotonic()
- entity_registry = er.async_get(self.hass)
-
# Gather entity names, keeping track of exposed names.
# We try intent recognition with only exposed names first, then all names.
#
@@ -858,35 +1075,7 @@ class DefaultAgent(ConversationEntity):
# have the same name. The intent matcher doesn't gather all matching
# values for a list, just the first. So we will need to match by name no
# matter what.
- exposed_entity_names = []
- for state in self.hass.states.async_all():
- is_exposed = async_should_expose(self.hass, DOMAIN, state.entity_id)
-
- # Checked against "requires_context" and "excludes_context" in hassil
- context = {"domain": state.domain}
- if state.attributes:
- # Include some attributes
- for attr in DEFAULT_EXPOSED_ATTRIBUTES:
- if attr not in state.attributes:
- continue
- context[attr] = state.attributes[attr]
-
- if (
- entity := entity_registry.async_get(state.entity_id)
- ) and entity.aliases:
- for alias in entity.aliases:
- if not alias.strip():
- continue
-
- name_tuple = (alias, alias, context)
- if is_exposed:
- exposed_entity_names.append(name_tuple)
-
- # Default name
- name_tuple = (state.name, state.name, context)
- if is_exposed:
- exposed_entity_names.append(name_tuple)
-
+ exposed_entity_names = list(self._get_entity_name_tuples(exposed=True))
_LOGGER.debug("Exposed entities: %s", exposed_entity_names)
# Expose all areas.
@@ -919,11 +1108,17 @@ class DefaultAgent(ConversationEntity):
floor_names.append((alias, floor.name))
+ # Build trie
+ self._exposed_names_trie = Trie()
+ name_list = TextSlotList.from_tuples(exposed_entity_names, allow_template=False)
+ for name_value in name_list.values:
+ assert isinstance(name_value.text_in, TextChunk)
+ name_text = name_value.text_in.text.strip().lower()
+ self._exposed_names_trie.insert(name_text, name_value)
+
self._slot_lists = {
"area": TextSlotList.from_tuples(area_names, allow_template=False),
- "name": TextSlotList.from_tuples(
- exposed_entity_names, allow_template=False
- ),
+ "name": name_list,
"floor": TextSlotList.from_tuples(floor_names, allow_template=False),
}
@@ -1091,9 +1286,7 @@ class DefaultAgent(ConversationEntity):
# Gather callback responses in parallel
trigger_callbacks = [
- self._trigger_sentences[trigger_id].callback(
- user_input.text, trigger_result, user_input.device_id
- )
+ self._trigger_sentences[trigger_id].callback(user_input, trigger_result)
for trigger_id, trigger_result in result.matched_triggers.items()
]
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index 6c2d70b6a11..2d2f2f58a3a 100644
--- a/homeassistant/components/conversation/manifest.json
+++ b/homeassistant/components/conversation/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["hassil==2.0.2", "home-assistant-intents==2024.11.13"]
+ "requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.2"]
}
diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py
index 724e520e6df..10218e76751 100644
--- a/homeassistant/components/conversation/models.py
+++ b/homeassistant/components/conversation/models.py
@@ -40,6 +40,17 @@ class ConversationInput:
agent_id: str | None = None
"""Agent to use for processing."""
+ def as_dict(self) -> dict[str, Any]:
+ """Return input as a dict."""
+ return {
+ "text": self.text,
+ "context": self.context.as_dict(),
+ "conversation_id": self.conversation_id,
+ "device_id": self.device_id,
+ "language": self.language,
+ "agent_id": self.agent_id,
+ }
+
@dataclass(slots=True)
class ConversationResult:
diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py
index a4f64ffbad9..24eb54c5694 100644
--- a/homeassistant/components/conversation/trigger.py
+++ b/homeassistant/components/conversation/trigger.py
@@ -16,6 +16,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import UNDEFINED, ConfigType
from .const import DATA_DEFAULT_ENTITY, DOMAIN
+from .models import ConversationInput
def has_no_punctuation(value: list[str]) -> list[str]:
@@ -62,7 +63,7 @@ async def async_attach_trigger(
job = HassJob(action)
async def call_action(
- sentence: str, result: RecognizeResult, device_id: str | None
+ user_input: ConversationInput, result: RecognizeResult
) -> str | None:
"""Call action with right context."""
@@ -83,12 +84,13 @@ async def async_attach_trigger(
trigger_input: dict[str, Any] = { # Satisfy type checker
**trigger_data,
"platform": DOMAIN,
- "sentence": sentence,
+ "sentence": user_input.text,
"details": details,
"slots": { # direct access to values
entity_name: entity["value"] for entity_name, entity in details.items()
},
- "device_id": device_id,
+ "device_id": user_input.device_id,
+ "user_input": user_input.as_dict(),
}
# Wait for the automation to complete
diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py
index ea11761a753..001bff51991 100644
--- a/homeassistant/components/cover/__init__.py
+++ b/homeassistant/components/cover/__init__.py
@@ -89,36 +89,8 @@ class CoverDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass))
-
-# DEVICE_CLASS* below are deprecated as of 2021.12
-# use the CoverDeviceClass enum instead.
DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass]
-_DEPRECATED_DEVICE_CLASS_AWNING = DeprecatedConstantEnum(
- CoverDeviceClass.AWNING, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_BLIND = DeprecatedConstantEnum(
- CoverDeviceClass.BLIND, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_CURTAIN = DeprecatedConstantEnum(
- CoverDeviceClass.CURTAIN, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_DAMPER = DeprecatedConstantEnum(
- CoverDeviceClass.DAMPER, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum(CoverDeviceClass.DOOR, "2025.1")
-_DEPRECATED_DEVICE_CLASS_GARAGE = DeprecatedConstantEnum(
- CoverDeviceClass.GARAGE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_GATE = DeprecatedConstantEnum(CoverDeviceClass.GATE, "2025.1")
-_DEPRECATED_DEVICE_CLASS_SHADE = DeprecatedConstantEnum(
- CoverDeviceClass.SHADE, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SHUTTER = DeprecatedConstantEnum(
- CoverDeviceClass.SHUTTER, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum(
- CoverDeviceClass.WINDOW, "2025.1"
-)
+
# mypy: disallow-any-generics
@@ -136,27 +108,6 @@ class CoverEntityFeature(IntFlag):
SET_TILT_POSITION = 128
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the CoverEntityFeature enum instead.
-_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(CoverEntityFeature.OPEN, "2025.1")
-_DEPRECATED_SUPPORT_CLOSE = DeprecatedConstantEnum(CoverEntityFeature.CLOSE, "2025.1")
-_DEPRECATED_SUPPORT_SET_POSITION = DeprecatedConstantEnum(
- CoverEntityFeature.SET_POSITION, "2025.1"
-)
-_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(CoverEntityFeature.STOP, "2025.1")
-_DEPRECATED_SUPPORT_OPEN_TILT = DeprecatedConstantEnum(
- CoverEntityFeature.OPEN_TILT, "2025.1"
-)
-_DEPRECATED_SUPPORT_CLOSE_TILT = DeprecatedConstantEnum(
- CoverEntityFeature.CLOSE_TILT, "2025.1"
-)
-_DEPRECATED_SUPPORT_STOP_TILT = DeprecatedConstantEnum(
- CoverEntityFeature.STOP_TILT, "2025.1"
-)
-_DEPRECATED_SUPPORT_SET_TILT_POSITION = DeprecatedConstantEnum(
- CoverEntityFeature.SET_TILT_POSITION, "2025.1"
-)
-
ATTR_CURRENT_POSITION = "current_position"
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
ATTR_POSITION = "position"
diff --git a/homeassistant/components/cppm_tracker/manifest.json b/homeassistant/components/cppm_tracker/manifest.json
index d8c387cdbf4..ca2fdf71a45 100644
--- a/homeassistant/components/cppm_tracker/manifest.json
+++ b/homeassistant/components/cppm_tracker/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/cppm_tracker",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["clearpasspy==1.0.2"]
}
diff --git a/homeassistant/components/cpuspeed/config_flow.py b/homeassistant/components/cpuspeed/config_flow.py
index ac35cc0fc4f..21dc577b5bf 100644
--- a/homeassistant/components/cpuspeed/config_flow.py
+++ b/homeassistant/components/cpuspeed/config_flow.py
@@ -23,7 +23,6 @@ class CPUSpeedFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
await self.async_set_unique_id(DOMAIN)
- self._abort_if_unique_id_configured()
if user_input is None:
return self.async_show_form(step_id="user")
diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json
index ff3a41d9c09..0c7f549a1b9 100644
--- a/homeassistant/components/cpuspeed/manifest.json
+++ b/homeassistant/components/cpuspeed/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/cpuspeed",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["py-cpuinfo==9.0.0"]
+ "requirements": ["py-cpuinfo==9.0.0"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/cpuspeed/strings.json b/homeassistant/components/cpuspeed/strings.json
index e82c6a0db12..6f4b3133b1b 100644
--- a/homeassistant/components/cpuspeed/strings.json
+++ b/homeassistant/components/cpuspeed/strings.json
@@ -8,7 +8,6 @@
}
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::single_instance_allowed%]",
"not_compatible": "Unable to get CPU information, this integration is not compatible with your system"
}
}
diff --git a/homeassistant/components/cups/manifest.json b/homeassistant/components/cups/manifest.json
index 3e5b46770fb..c4aa596f01e 100644
--- a/homeassistant/components/cups/manifest.json
+++ b/homeassistant/components/cups/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/cups",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pycups==1.9.73"]
}
diff --git a/homeassistant/components/currencylayer/manifest.json b/homeassistant/components/currencylayer/manifest.json
index d66331c4ab0..82d9d4050d4 100644
--- a/homeassistant/components/currencylayer/manifest.json
+++ b/homeassistant/components/currencylayer/manifest.json
@@ -3,5 +3,6 @@
"name": "currencylayer",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/currencylayer",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/danfoss_air/manifest.json b/homeassistant/components/danfoss_air/manifest.json
index 9eea3221bbe..57cb1aa7218 100644
--- a/homeassistant/components/danfoss_air/manifest.json
+++ b/homeassistant/components/danfoss_air/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/danfoss_air",
"iot_class": "local_polling",
"loggers": ["pydanfossair"],
+ "quality_scale": "legacy",
"requirements": ["pydanfossair==0.1.0"]
}
diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json
index 4ae24a80c6c..ca9681effca 100644
--- a/homeassistant/components/datadog/manifest.json
+++ b/homeassistant/components/datadog/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/datadog",
"iot_class": "local_push",
"loggers": ["datadog"],
+ "quality_scale": "legacy",
"requirements": ["datadog==0.15.0"]
}
diff --git a/homeassistant/components/ddwrt/manifest.json b/homeassistant/components/ddwrt/manifest.json
index 98ea17b0659..9a2b2470131 100644
--- a/homeassistant/components/ddwrt/manifest.json
+++ b/homeassistant/components/ddwrt/manifest.json
@@ -3,5 +3,6 @@
"name": "DD-WRT",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ddwrt",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json
index 04aaa6bc324..93ae8e392c8 100644
--- a/homeassistant/components/deconz/manifest.json
+++ b/homeassistant/components/deconz/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pydeconz"],
- "quality_scale": "platinum",
"requirements": ["pydeconz==118"],
"ssdp": [
{
diff --git a/homeassistant/components/decora/manifest.json b/homeassistant/components/decora/manifest.json
index bef42f8b4ab..64dc01d09a1 100644
--- a/homeassistant/components/decora/manifest.json
+++ b/homeassistant/components/decora/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/decora",
"iot_class": "local_polling",
"loggers": ["bluepy", "decora"],
+ "quality_scale": "legacy",
"requirements": ["bluepy==1.3.0", "decora==0.6"]
}
diff --git a/homeassistant/components/decora_wifi/manifest.json b/homeassistant/components/decora_wifi/manifest.json
index 0bead527e78..25892dc3e64 100644
--- a/homeassistant/components/decora_wifi/manifest.json
+++ b/homeassistant/components/decora_wifi/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/decora_wifi",
"iot_class": "cloud_polling",
"loggers": ["decora_wifi"],
+ "quality_scale": "legacy",
"requirements": ["decora-wifi==1.4"]
}
diff --git a/homeassistant/components/delijn/manifest.json b/homeassistant/components/delijn/manifest.json
index d25dab4234e..b87242d6e94 100644
--- a/homeassistant/components/delijn/manifest.json
+++ b/homeassistant/components/delijn/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/delijn",
"iot_class": "cloud_polling",
"loggers": ["pydelijn"],
+ "quality_scale": "legacy",
"requirements": ["pydelijn==1.1.0"]
}
diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py
index ff0ed5746ca..5424591f021 100644
--- a/homeassistant/components/demo/climate.py
+++ b/homeassistant/components/demo/climate.py
@@ -43,6 +43,7 @@ async def async_setup_entry(
target_humidity=None,
current_humidity=None,
swing_mode=None,
+ swing_horizontal_mode=None,
hvac_mode=HVACMode.HEAT,
hvac_action=HVACAction.HEATING,
target_temp_high=None,
@@ -60,6 +61,7 @@ async def async_setup_entry(
target_humidity=67.4,
current_humidity=54.2,
swing_mode="off",
+ swing_horizontal_mode="auto",
hvac_mode=HVACMode.COOL,
hvac_action=HVACAction.COOLING,
target_temp_high=None,
@@ -78,6 +80,7 @@ async def async_setup_entry(
target_humidity=None,
current_humidity=None,
swing_mode="auto",
+ swing_horizontal_mode=None,
hvac_mode=HVACMode.HEAT_COOL,
hvac_action=None,
target_temp_high=24,
@@ -109,6 +112,7 @@ class DemoClimate(ClimateEntity):
target_humidity: float | None,
current_humidity: float | None,
swing_mode: str | None,
+ swing_horizontal_mode: str | None,
hvac_mode: HVACMode,
hvac_action: HVACAction | None,
target_temp_high: float | None,
@@ -129,6 +133,8 @@ class DemoClimate(ClimateEntity):
self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
if swing_mode is not None:
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
+ if swing_horizontal_mode is not None:
+ self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
if HVACMode.HEAT_COOL in hvac_modes or HVACMode.AUTO in hvac_modes:
self._attr_supported_features |= (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
@@ -147,9 +153,11 @@ class DemoClimate(ClimateEntity):
self._hvac_action = hvac_action
self._hvac_mode = hvac_mode
self._current_swing_mode = swing_mode
+ self._current_swing_horizontal_mode = swing_horizontal_mode
self._fan_modes = ["on_low", "on_high", "auto_low", "auto_high", "off"]
self._hvac_modes = hvac_modes
self._swing_modes = ["auto", "1", "2", "3", "off"]
+ self._swing_horizontal_modes = ["auto", "rangefull", "off"]
self._target_temperature_high = target_temp_high
self._target_temperature_low = target_temp_low
self._attr_device_info = DeviceInfo(
@@ -242,6 +250,16 @@ class DemoClimate(ClimateEntity):
"""List of available swing modes."""
return self._swing_modes
+ @property
+ def swing_horizontal_mode(self) -> str | None:
+ """Return the swing setting."""
+ return self._current_swing_horizontal_mode
+
+ @property
+ def swing_horizontal_modes(self) -> list[str]:
+ """List of available swing modes."""
+ return self._swing_horizontal_modes
+
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperatures."""
if kwargs.get(ATTR_TEMPERATURE) is not None:
@@ -266,6 +284,11 @@ class DemoClimate(ClimateEntity):
self._current_swing_mode = swing_mode
self.async_write_ha_state()
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new swing mode."""
+ self._current_swing_horizontal_mode = swing_horizontal_mode
+ self.async_write_ha_state()
+
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new fan mode."""
self._current_fan_mode = fan_mode
diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json
index 17425a6d119..eafcbb9161a 100644
--- a/homeassistant/components/demo/icons.json
+++ b/homeassistant/components/demo/icons.json
@@ -19,6 +19,13 @@
"auto": "mdi:arrow-oscillating",
"off": "mdi:arrow-oscillating-off"
}
+ },
+ "swing_horizontal_mode": {
+ "state": {
+ "rangefull": "mdi:pan-horizontal",
+ "auto": "mdi:compare-horizontal",
+ "off": "mdi:arrow-oscillating-off"
+ }
}
}
}
diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json
index aa5554e9fcc..da72b33d3ca 100644
--- a/homeassistant/components/demo/strings.json
+++ b/homeassistant/components/demo/strings.json
@@ -42,6 +42,13 @@
"auto": "Auto",
"off": "[%key:common::state::off%]"
}
+ },
+ "swing_horizontal_mode": {
+ "state": {
+ "rangefull": "Full range",
+ "auto": "Auto",
+ "off": "[%key:common::state::off%]"
+ }
}
}
}
diff --git a/homeassistant/components/denon/manifest.json b/homeassistant/components/denon/manifest.json
index d94e8a264e3..9e840b43fcf 100644
--- a/homeassistant/components/denon/manifest.json
+++ b/homeassistant/components/denon/manifest.json
@@ -3,5 +3,6 @@
"name": "Denon Network Receivers",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/denon",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json
index eff70b94a18..328ab504bd1 100644
--- a/homeassistant/components/denonavr/manifest.json
+++ b/homeassistant/components/denonavr/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/denonavr",
"iot_class": "local_push",
"loggers": ["denonavr"],
- "requirements": ["denonavr==1.0.0"],
+ "requirements": ["denonavr==1.0.1"],
"ssdp": [
{
"manufacturer": "Denon",
diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json
index 4b66c893d57..bfdf861a019 100644
--- a/homeassistant/components/derivative/strings.json
+++ b/homeassistant/components/derivative/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Derivative sensor",
+ "title": "Create Derivative sensor",
"description": "Create a sensor that estimates the derivative of a sensor.",
"data": {
"name": "[%key:common::config_flow::data::name%]",
diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py
index 28991483cda..313373e3181 100644
--- a/homeassistant/components/device_tracker/__init__.py
+++ b/homeassistant/components/device_tracker/__init__.py
@@ -2,15 +2,8 @@
from __future__ import annotations
-from functools import partial
-
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.deprecation import (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -23,10 +16,6 @@ from .config_entry import ( # noqa: F401
async_unload_entry,
)
from .const import ( # noqa: F401
- _DEPRECATED_SOURCE_TYPE_BLUETOOTH,
- _DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE,
- _DEPRECATED_SOURCE_TYPE_GPS,
- _DEPRECATED_SOURCE_TYPE_ROUTER,
ATTR_ATTRIBUTES,
ATTR_BATTERY,
ATTR_DEV_ID,
@@ -72,13 +61,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the device tracker."""
async_setup_legacy_integration(hass, config)
return True
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py
index 964b7faab9b..c9e4d4e910a 100644
--- a/homeassistant/components/device_tracker/const.py
+++ b/homeassistant/components/device_tracker/const.py
@@ -4,16 +4,9 @@ from __future__ import annotations
from datetime import timedelta
from enum import StrEnum
-from functools import partial
import logging
from typing import Final
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.util.signal_type import SignalType
LOGGER: Final = logging.getLogger(__package__)
@@ -34,19 +27,6 @@ class SourceType(StrEnum):
BLUETOOTH_LE = "bluetooth_le"
-# SOURCE_TYPE_* below are deprecated as of 2022.9
-# use the SourceType enum instead.
-_DEPRECATED_SOURCE_TYPE_GPS: Final = DeprecatedConstantEnum(SourceType.GPS, "2025.1")
-_DEPRECATED_SOURCE_TYPE_ROUTER: Final = DeprecatedConstantEnum(
- SourceType.ROUTER, "2025.1"
-)
-_DEPRECATED_SOURCE_TYPE_BLUETOOTH: Final = DeprecatedConstantEnum(
- SourceType.BLUETOOTH, "2025.1"
-)
-_DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE: Final = DeprecatedConstantEnum(
- SourceType.BLUETOOTH_LE, "2025.1"
-)
-
CONF_SCAN_INTERVAL: Final = "interval_seconds"
SCAN_INTERVAL: Final = timedelta(seconds=12)
@@ -72,10 +52,3 @@ ATTR_IP: Final = "ip"
CONNECTED_DEVICE_REGISTERED = SignalType[dict[str, str | None]](
"device_tracker_connected_device_registered"
)
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json
index d6e36d92300..294333a5d80 100644
--- a/homeassistant/components/device_tracker/strings.json
+++ b/homeassistant/components/device_tracker/strings.json
@@ -48,7 +48,7 @@
"services": {
"see": {
"name": "See",
- "description": "Records a seen tracked device.",
+ "description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
"fields": {
"mac": {
"name": "MAC address",
diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json
index eb85e827551..a9715fffa84 100644
--- a/homeassistant/components/devolo_home_control/manifest.json
+++ b/homeassistant/components/devolo_home_control/manifest.json
@@ -8,7 +8,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["devolo_home_control_api"],
- "quality_scale": "gold",
"requirements": ["devolo-home-control-api==0.18.3"],
"zeroconf": ["_dvl-deviceapi._tcp.local."]
}
diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py
index 70a94531431..7f6784f2404 100644
--- a/homeassistant/components/devolo_home_network/__init__.py
+++ b/homeassistant/components/devolo_home_network/__init__.py
@@ -83,7 +83,6 @@ async def async_setup_entry(
)
except DeviceNotFound as err:
raise ConfigEntryNotReady(
- f"Unable to connect to {entry.data[CONF_IP_ADDRESS]}",
translation_domain=DOMAIN,
translation_key="connection_failed",
translation_placeholders={"ip_address": entry.data[CONF_IP_ADDRESS]},
@@ -98,7 +97,11 @@ async def async_setup_entry(
try:
return await device.device.async_check_firmware_available()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def async_update_connected_plc_devices() -> LogicalNetwork:
"""Fetch data from API endpoint."""
@@ -107,7 +110,11 @@ async def async_setup_entry(
try:
return await device.plcnet.async_get_network_overview()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def async_update_guest_wifi_status() -> WifiGuestAccessGet:
"""Fetch data from API endpoint."""
@@ -116,10 +123,14 @@ async def async_setup_entry(
try:
return await device.device.async_get_wifi_guest_access()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
except DevicePasswordProtected as err:
raise ConfigEntryAuthFailed(
- err, translation_domain=DOMAIN, translation_key="password_wrong"
+ translation_domain=DOMAIN, translation_key="password_wrong"
) from err
async def async_update_led_status() -> bool:
@@ -129,7 +140,11 @@ async def async_setup_entry(
try:
return await device.device.async_get_led_setting()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def async_update_last_restart() -> int:
"""Fetch data from API endpoint."""
@@ -138,10 +153,14 @@ async def async_setup_entry(
try:
return await device.device.async_uptime()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
except DevicePasswordProtected as err:
raise ConfigEntryAuthFailed(
- err, translation_domain=DOMAIN, translation_key="password_wrong"
+ translation_domain=DOMAIN, translation_key="password_wrong"
) from err
async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]:
@@ -151,7 +170,11 @@ async def async_setup_entry(
try:
return await device.device.async_get_wifi_connected_station()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]:
"""Fetch data from API endpoint."""
@@ -160,7 +183,11 @@ async def async_setup_entry(
try:
return await device.device.async_get_wifi_neighbor_access_points()
except DeviceUnavailable as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(err)},
+ ) from err
async def disconnect(event: Event) -> None:
"""Disconnect from device."""
diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json
index 27fd08898c0..d10e14f9081 100644
--- a/homeassistant/components/devolo_home_network/manifest.json
+++ b/homeassistant/components/devolo_home_network/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["devolo_plc_api"],
- "quality_scale": "platinum",
"requirements": ["devolo-plc-api==1.4.1"],
"zeroconf": [
{
diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json
index 0799bb14172..4b683b5d2fa 100644
--- a/homeassistant/components/devolo_home_network/strings.json
+++ b/homeassistant/components/devolo_home_network/strings.json
@@ -6,11 +6,17 @@
"description": "[%key:common::config_flow::description::confirm_setup%]",
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
+ },
+ "data_description": {
+ "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard."
}
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "Password you protected the device with."
}
},
"zeroconf_confirm": {
@@ -94,6 +100,9 @@
},
"password_wrong": {
"message": "The used password is wrong"
+ },
+ "update_failed": {
+ "message": "Error while updating the data: {error}"
}
}
}
diff --git a/homeassistant/components/digital_ocean/manifest.json b/homeassistant/components/digital_ocean/manifest.json
index 7fee8ca5b2b..819a557491a 100644
--- a/homeassistant/components/digital_ocean/manifest.json
+++ b/homeassistant/components/digital_ocean/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/digital_ocean",
"iot_class": "local_polling",
"loggers": ["digitalocean"],
+ "quality_scale": "legacy",
"requirements": ["python-digitalocean==1.13.2"]
}
diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json
index 957bbff0acc..bee2c297635 100644
--- a/homeassistant/components/directv/manifest.json
+++ b/homeassistant/components/directv/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/directv",
"iot_class": "local_polling",
"loggers": ["directv"],
- "quality_scale": "silver",
"requirements": ["directv==0.4.0"],
"ssdp": [
{
diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json
index fceb214aded..f724b4bc6fd 100644
--- a/homeassistant/components/discogs/manifest.json
+++ b/homeassistant/components/discogs/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/discogs",
"iot_class": "cloud_polling",
"loggers": ["discogs_client"],
+ "quality_scale": "legacy",
"requirements": ["discogs-client==2.3.0"]
}
diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py
index 72aa6c19a21..81c33adc052 100644
--- a/homeassistant/components/discovergy/__init__.py
+++ b/homeassistant/components/discovergy/__init__.py
@@ -60,11 +60,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def async_reload_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -> None:
"""Handle an options update."""
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py
index 05ed90bf354..f24fdd1e43d 100644
--- a/homeassistant/components/discovergy/config_flow.py
+++ b/homeassistant/components/discovergy/config_flow.py
@@ -11,12 +11,7 @@ from pydiscovergy.authentication import BasicAuth
import pydiscovergy.error as discovergyError
import voluptuous as vol
-from homeassistant.config_entries import (
- SOURCE_REAUTH,
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
-)
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
@@ -57,35 +52,14 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- _existing_entry: ConfigEntry
-
- async def async_step_user(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle the initial step."""
- if user_input is None:
- return self.async_show_form(
- step_id="user",
- data_schema=CONFIG_SCHEMA,
- )
-
- return await self._validate_and_save(user_input)
-
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle the initial step."""
- self._existing_entry = self._get_reauth_entry()
- return await self.async_step_reauth_confirm()
+ return await self.async_step_user()
- async def async_step_reauth_confirm(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle the reauth step."""
- return await self._validate_and_save(user_input, step_id="reauth_confirm")
-
- async def _validate_and_save(
- self, user_input: Mapping[str, Any] | None = None, step_id: str = "user"
+ async def async_step_user(
+ self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""Validate user input and create config entry."""
errors = {}
@@ -106,17 +80,17 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected error occurred while getting meters")
errors["base"] = "unknown"
else:
+ await self.async_set_unique_id(user_input[CONF_EMAIL].lower())
+
if self.source == SOURCE_REAUTH:
+ self._abort_if_unique_id_mismatch(reason="account_mismatch")
return self.async_update_reload_and_abort(
- entry=self._existing_entry,
- data={
- CONF_EMAIL: user_input[CONF_EMAIL],
+ entry=self._get_reauth_entry(),
+ data_updates={
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
- # set unique id to title which is the account email
- await self.async_set_unique_id(user_input[CONF_EMAIL].lower())
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -124,10 +98,10 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
- step_id=step_id,
+ step_id="user",
data_schema=self.add_suggested_values_to_schema(
CONFIG_SCHEMA,
- self._existing_entry.data
+ self._get_reauth_entry().data
if self.source == SOURCE_REAUTH
else user_input,
),
diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml
new file mode 100644
index 00000000000..3caeaa6bbe0
--- /dev/null
+++ b/homeassistant/components/discovergy/quality_scale.yaml
@@ -0,0 +1,96 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow:
+ status: todo
+ comment: |
+ The data_descriptions are missing.
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ The integration does not provide any additional options.
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a cloud service.
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a cloud service.
+ 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:
+ status: exempt
+ comment: |
+ The integration connects to a single device per configuration entry.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations:
+ status: exempt
+ comment: |
+ The integration does not provide any additional icons.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connect to a single device per configuration entry.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json
index 9a91fa92dc4..b626a11ea1e 100644
--- a/homeassistant/components/discovergy/strings.json
+++ b/homeassistant/components/discovergy/strings.json
@@ -6,12 +6,6 @@
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
- },
- "reauth_confirm": {
- "data": {
- "email": "[%key:common::config_flow::data::email%]",
- "password": "[%key:common::config_flow::data::password%]"
- }
}
},
"error": {
@@ -21,6 +15,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "account_mismatch": "The inexogy account authenticated with, does not match the account needed re-authentication.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
diff --git a/homeassistant/components/dlib_face_detect/manifest.json b/homeassistant/components/dlib_face_detect/manifest.json
index e395a84f206..e8476583081 100644
--- a/homeassistant/components/dlib_face_detect/manifest.json
+++ b/homeassistant/components/dlib_face_detect/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/dlib_face_detect",
"iot_class": "local_push",
"loggers": ["face_recognition"],
+ "quality_scale": "legacy",
"requirements": ["face-recognition==1.2.3"]
}
diff --git a/homeassistant/components/dlib_face_identify/manifest.json b/homeassistant/components/dlib_face_identify/manifest.json
index 60c0ef3c766..2a764e4a3e8 100644
--- a/homeassistant/components/dlib_face_identify/manifest.json
+++ b/homeassistant/components/dlib_face_identify/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/dlib_face_identify",
"iot_class": "local_push",
"loggers": ["face_recognition"],
+ "quality_scale": "legacy",
"requirements": ["face-recognition==1.2.3"]
}
diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json
index 091e083ceda..1913bb9d5d7 100644
--- a/homeassistant/components/dlna_dms/manifest.json
+++ b/homeassistant/components/dlna_dms/manifest.json
@@ -7,7 +7,6 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling",
- "quality_scale": "platinum",
"requirements": ["async-upnp-client==0.41.0"],
"ssdp": [
{
diff --git a/homeassistant/components/dominos/manifest.json b/homeassistant/components/dominos/manifest.json
index 442f433db7c..5618c6f0d87 100644
--- a/homeassistant/components/dominos/manifest.json
+++ b/homeassistant/components/dominos/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/dominos",
"iot_class": "cloud_polling",
"loggers": ["pizzapi"],
+ "quality_scale": "legacy",
"requirements": ["pizzapi==0.0.6"]
}
diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json
index 7c85ca63467..ae307bb4962 100644
--- a/homeassistant/components/doods/manifest.json
+++ b/homeassistant/components/doods/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/doods",
"iot_class": "local_polling",
"loggers": ["pydoods"],
+ "quality_scale": "legacy",
"requirements": ["pydoods==1.0.2", "Pillow==11.0.0"]
}
diff --git a/homeassistant/components/dovado/manifest.json b/homeassistant/components/dovado/manifest.json
index 9a0fc46ad16..78b1e0c6719 100644
--- a/homeassistant/components/dovado/manifest.json
+++ b/homeassistant/components/dovado/manifest.json
@@ -5,5 +5,6 @@
"disabled": "This integration is disabled because it uses non-open source code to operate.",
"documentation": "https://www.home-assistant.io/integrations/dovado",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["dovado==0.4.1"]
}
diff --git a/homeassistant/components/dsmr_reader/manifest.json b/homeassistant/components/dsmr_reader/manifest.json
index 7adb664fbd8..9c0e6da2c46 100644
--- a/homeassistant/components/dsmr_reader/manifest.json
+++ b/homeassistant/components/dsmr_reader/manifest.json
@@ -6,6 +6,5 @@
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/dsmr_reader",
"iot_class": "local_push",
- "mqtt": ["dsmr/#"],
- "quality_scale": "gold"
+ "mqtt": ["dsmr/#"]
}
diff --git a/homeassistant/components/dte_energy_bridge/manifest.json b/homeassistant/components/dte_energy_bridge/manifest.json
index f5b57d82869..8285469a745 100644
--- a/homeassistant/components/dte_energy_bridge/manifest.json
+++ b/homeassistant/components/dte_energy_bridge/manifest.json
@@ -3,5 +3,6 @@
"name": "DTE Energy Bridge",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/dte_energy_bridge",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/dublin_bus_transport/manifest.json b/homeassistant/components/dublin_bus_transport/manifest.json
index 1866da8ed8d..3df22b0da00 100644
--- a/homeassistant/components/dublin_bus_transport/manifest.json
+++ b/homeassistant/components/dublin_bus_transport/manifest.json
@@ -3,5 +3,6 @@
"name": "Dublin Bus",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/dublin_bus_transport",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/duckdns/manifest.json b/homeassistant/components/duckdns/manifest.json
index b14da053450..b48ed0b2394 100644
--- a/homeassistant/components/duckdns/manifest.json
+++ b/homeassistant/components/duckdns/manifest.json
@@ -3,5 +3,6 @@
"name": "Duck DNS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/duckdns",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json
index 2a427e36e84..7a79902eae3 100644
--- a/homeassistant/components/duotecno/manifest.json
+++ b/homeassistant/components/duotecno/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/duotecno",
"iot_class": "local_push",
"loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"],
- "quality_scale": "silver",
"requirements": ["pyDuotecno==2024.10.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json
index 4badf76f2e9..b4efd0744fb 100644
--- a/homeassistant/components/dweet/manifest.json
+++ b/homeassistant/components/dweet/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/dweet",
"iot_class": "cloud_polling",
"loggers": ["dweepy"],
+ "quality_scale": "legacy",
"requirements": ["dweepy==0.3.0"]
}
diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py
index 59b8e464bb0..7388c43cb89 100644
--- a/homeassistant/components/dynalite/__init__.py
+++ b/homeassistant/components/dynalite/__init__.py
@@ -4,21 +4,17 @@ from __future__ import annotations
import voluptuous as vol
-from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
-# Loading the config flow file will register the flow
from .bridge import DynaliteBridge
from .const import (
ATTR_AREA,
ATTR_CHANNEL,
ATTR_HOST,
- CONF_BRIDGES,
DOMAIN,
LOGGER,
PLATFORMS,
@@ -27,41 +23,14 @@ from .const import (
)
from .convert_config import convert_config
from .panel import async_register_dynalite_frontend
-from .schema import BRIDGE_SCHEMA
-CONFIG_SCHEMA = vol.Schema(
- vol.All(
- cv.deprecated(DOMAIN),
- {
- DOMAIN: vol.Schema(
- {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])}
- ),
- },
- ),
- extra=vol.ALLOW_EXTRA,
-)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Dynalite platform."""
- conf = config.get(DOMAIN, {})
- LOGGER.debug("Setting up dynalite component config = %s", conf)
hass.data[DOMAIN] = {}
- bridges = conf.get(CONF_BRIDGES, [])
-
- for bridge_conf in bridges:
- host = bridge_conf[CONF_HOST]
- LOGGER.debug("Starting config entry flow host=%s conf=%s", host, bridge_conf)
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data=bridge_conf,
- )
- )
-
async def dynalite_service(service_call: ServiceCall) -> None:
data = service_call.data
host = data.get(ATTR_HOST, "")
diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py
index 928f7043a49..4b111c25cc9 100644
--- a/homeassistant/components/dynalite/config_flow.py
+++ b/homeassistant/components/dynalite/config_flow.py
@@ -8,9 +8,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .bridge import DynaliteBridge
from .const import DEFAULT_PORT, DOMAIN, LOGGER
@@ -26,38 +24,6 @@ class DynaliteFlowHandler(ConfigFlow, domain=DOMAIN):
"""Initialize the Dynalite flow."""
self.host = None
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import a new bridge as a config entry."""
- LOGGER.debug("Starting async_step_import (deprecated) - %s", import_data)
- # Raise an issue that this is deprecated and has been imported
- async_create_issue(
- self.hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2023.12.0",
- is_fixable=False,
- is_persistent=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Dynalite",
- },
- )
-
- host = import_data[CONF_HOST]
- # Check if host already exists
- for entry in self._async_current_entries():
- if entry.data[CONF_HOST] == host:
- self.hass.config_entries.async_update_entry(
- entry, data=dict(import_data)
- )
- return self.async_abort(reason="already_configured")
-
- # New entry
- return await self._try_create(import_data)
-
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py
index c1cb1a0fb1b..4712b14bea3 100644
--- a/homeassistant/components/dynalite/const.py
+++ b/homeassistant/components/dynalite/const.py
@@ -16,7 +16,6 @@ ACTIVE_OFF = "off"
ACTIVE_ON = "on"
CONF_AREA = "area"
CONF_AUTO_DISCOVER = "autodiscover"
-CONF_BRIDGES = "bridges"
CONF_CHANNEL = "channel"
CONF_CHANNEL_COVER = "channel_cover"
CONF_CLOSE_PRESET = "close"
diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json
index 4d45dc2d399..25432196169 100644
--- a/homeassistant/components/easyenergy/manifest.json
+++ b/homeassistant/components/easyenergy/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["easyenergy==2.1.2"]
}
diff --git a/homeassistant/components/ebox/manifest.json b/homeassistant/components/ebox/manifest.json
index 952f9dc133d..d87c85b6612 100644
--- a/homeassistant/components/ebox/manifest.json
+++ b/homeassistant/components/ebox/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ebox",
"iot_class": "cloud_polling",
"loggers": ["pyebox"],
+ "quality_scale": "legacy",
"requirements": ["pyebox==1.1.4"]
}
diff --git a/homeassistant/components/ebusd/manifest.json b/homeassistant/components/ebusd/manifest.json
index 3ce18d6e8d3..b82e8f1b910 100644
--- a/homeassistant/components/ebusd/manifest.json
+++ b/homeassistant/components/ebusd/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ebusd",
"iot_class": "local_polling",
"loggers": ["ebusdpy"],
+ "quality_scale": "legacy",
"requirements": ["ebusdpy==0.0.17"]
}
diff --git a/homeassistant/components/ecoal_boiler/manifest.json b/homeassistant/components/ecoal_boiler/manifest.json
index 75dc95ae121..4d8202f8fde 100644
--- a/homeassistant/components/ecoal_boiler/manifest.json
+++ b/homeassistant/components/ecoal_boiler/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ecoal_boiler",
"iot_class": "local_polling",
"loggers": ["ecoaliface"],
+ "quality_scale": "legacy",
"requirements": ["ecoaliface==0.4.0"]
}
diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py
index ec67845cf9f..3a70ab2af5b 100644
--- a/homeassistant/components/ecovacs/controller.py
+++ b/homeassistant/components/ecovacs/controller.py
@@ -13,7 +13,6 @@ from deebot_client.authentication import Authenticator, create_rest_config
from deebot_client.const import UNDEFINED, UndefinedType
from deebot_client.device import Device
from deebot_client.exceptions import DeebotError, InvalidAuthenticationError
-from deebot_client.models import DeviceInfo
from deebot_client.mqtt_client import MqttClient, create_mqtt_config
from deebot_client.util import md5
from deebot_client.util.continents import get_continent
@@ -81,25 +80,32 @@ class EcovacsController:
try:
devices = await self._api_client.get_devices()
credentials = await self._authenticator.authenticate()
- for device_config in devices:
- if isinstance(device_config, DeviceInfo):
- # MQTT device
- device = Device(device_config, self._authenticator)
- mqtt = await self._get_mqtt_client()
- await device.initialize(mqtt)
- self._devices.append(device)
- else:
- # Legacy device
- bot = VacBot(
- credentials.user_id,
- EcoVacsAPI.REALM,
- self._device_id[0:8],
- credentials.token,
- device_config,
- self._continent,
- monitor=True,
- )
- self._legacy_devices.append(bot)
+ for device_info in devices.mqtt:
+ device = Device(device_info, self._authenticator)
+ mqtt = await self._get_mqtt_client()
+ await device.initialize(mqtt)
+ self._devices.append(device)
+ for device_config in devices.xmpp:
+ bot = VacBot(
+ credentials.user_id,
+ EcoVacsAPI.REALM,
+ self._device_id[0:8],
+ credentials.token,
+ device_config,
+ self._continent,
+ monitor=True,
+ )
+ self._legacy_devices.append(bot)
+ for device_config in devices.not_supported:
+ _LOGGER.warning(
+ (
+ 'Device "%s" not supported. Please add support for it to '
+ "https://github.com/DeebotUniverse/client.py: %s"
+ ),
+ device_config["deviceName"],
+ device_config,
+ )
+
except InvalidAuthenticationError as ex:
raise ConfigEntryError("Invalid credentials") from ex
except DeebotError as ex:
diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json
index 0ab9f9a4612..4a43489ff24 100644
--- a/homeassistant/components/ecovacs/manifest.json
+++ b/homeassistant/components/ecovacs/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
- "requirements": ["py-sucks==0.9.10", "deebot-client==8.4.1"]
+ "requirements": ["py-sucks==0.9.10", "deebot-client==9.0.0"]
}
diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py
index 28c4efbd0c6..7c190d27775 100644
--- a/homeassistant/components/ecovacs/sensor.py
+++ b/homeassistant/components/ecovacs/sensor.py
@@ -26,11 +26,11 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- AREA_SQUARE_METERS,
ATTR_BATTERY_LEVEL,
CONF_DESCRIPTION,
PERCENTAGE,
EntityCategory,
+ UnitOfArea,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
@@ -67,7 +67,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
capability_fn=lambda caps: caps.stats.clean,
value_fn=lambda e: e.area,
translation_key="stats_area",
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
),
EcovacsSensorEntityDescription[StatsEvent](
key="stats_time",
@@ -84,7 +84,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
value_fn=lambda e: e.area,
key="total_stats_area",
translation_key="total_stats_area",
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
EcovacsSensorEntityDescription[TotalStatsEvent](
diff --git a/homeassistant/components/ecowitt/strings.json b/homeassistant/components/ecowitt/strings.json
index 95fcc3c3bb0..aaacb5e03dd 100644
--- a/homeassistant/components/ecowitt/strings.json
+++ b/homeassistant/components/ecowitt/strings.json
@@ -6,7 +6,7 @@
}
},
"create_entry": {
- "default": "To finish setting up the integration, use the Ecowitt App (on your phone) or access the Ecowitt WebUI in a browser at the station IP address.\n\nPick your station -> Menu Others -> DIY Upload Servers. Hit next and select 'Customized'\n\n- Server IP: `{server}`\n- Path: `{path}`\n- Port: `{port}`\n\nSelect **Save**."
+ "default": "To finish setting up the integration, you need to tell the Ecowitt station to send data to Home Assistant at the following address:\n\n- Server IP / Host Name: `{server}`\n- Path: `{path}`\n- Port: `{port}`\n\nYou can access the Ecowitt configuration in one of two ways:\n\n1. Use the Ecowitt App (on your phone):\n - Select the Menu Icon (☰) on the upper left, then **My Devices** → **Pick your station**\n - Select the Ellipsis Icon (⋯) → **Others**\n - Select **DIY Upload Servers** → **Customized**\n - Make sure to choose 'Protocol Type Same As: Ecowitt'\n - Enter the Server IP / Host Name, Path, and Port (printed above). _Note: The path has to match! Remove the first forward slash from the path, as the app will prepend one._\n - Save\n1. Navigate to the Ecowitt web UI in a browser at the station IP address:\n - Select **Weather Services** then scroll down to 'Customized'\n - Make sure to select 'Customized: 🔘 Enable' and 'Protocol Type Same As: 🔘 Ecowitt'\n - Enter the Server IP / Host Name, Path, and Port (printed above).\n - Save"
}
}
}
diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json
index b15a88d099f..18e67f55667 100644
--- a/homeassistant/components/eddystone_temperature/manifest.json
+++ b/homeassistant/components/eddystone_temperature/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/eddystone_temperature",
"iot_class": "local_polling",
"loggers": ["beacontools"],
+ "quality_scale": "legacy",
"requirements": ["beacontools[scan]==2.1.0"]
}
diff --git a/homeassistant/components/edimax/manifest.json b/homeassistant/components/edimax/manifest.json
index f104ec40e64..a226ef3bbe8 100644
--- a/homeassistant/components/edimax/manifest.json
+++ b/homeassistant/components/edimax/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/edimax",
"iot_class": "local_polling",
"loggers": ["pyedimax"],
+ "quality_scale": "legacy",
"requirements": ["pyedimax==0.2.1"]
}
diff --git a/homeassistant/components/egardia/manifest.json b/homeassistant/components/egardia/manifest.json
index 99f39c99cbc..08eb82df0e7 100644
--- a/homeassistant/components/egardia/manifest.json
+++ b/homeassistant/components/egardia/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/egardia",
"iot_class": "local_polling",
"loggers": ["pythonegardia"],
+ "quality_scale": "legacy",
"requirements": ["pythonegardia==1.0.52"]
}
diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json
index a4f7482c920..59de546824f 100644
--- a/homeassistant/components/eight_sleep/manifest.json
+++ b/homeassistant/components/eight_sleep/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/eight_sleep",
"integration_type": "system",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": []
}
diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json
index c68902560b9..734ad5ec930 100644
--- a/homeassistant/components/elgato/manifest.json
+++ b/homeassistant/components/elgato/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/elgato",
"integration_type": "device",
"iot_class": "local_polling",
- "quality_scale": "platinum",
"requirements": ["elgato==5.1.2"],
"zeroconf": ["_elg._tcp.local."]
}
diff --git a/homeassistant/components/elgato/quality_scale.yaml b/homeassistant/components/elgato/quality_scale.yaml
new file mode 100644
index 00000000000..2910bdb4473
--- /dev/null
+++ b/homeassistant/components/elgato/quality_scale.yaml
@@ -0,0 +1,85 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow:
+ status: todo
+ comment: |
+ The data_description for port is missing.
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: todo
+ comment: |
+ The integration doesn't update the device info based on DHCP discovery
+ of known existing devices.
+ discovery: done
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices:
+ status: todo
+ comment: |
+ Device are documented, but some are missing. For example, the their pro
+ strip is supported as well.
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/eliqonline/manifest.json b/homeassistant/components/eliqonline/manifest.json
index 78fd62fbd33..70f2cd8a675 100644
--- a/homeassistant/components/eliqonline/manifest.json
+++ b/homeassistant/components/eliqonline/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/eliqonline",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["eliqonline==1.2.2"]
}
diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json
index 6318231c281..bf02d727280 100644
--- a/homeassistant/components/elkm1/strings.json
+++ b/homeassistant/components/elkm1/strings.json
@@ -68,7 +68,7 @@
}
},
"alarm_arm_home_instant": {
- "name": "Alarm are home instant",
+ "name": "Alarm arm home instant",
"description": "Arms the ElkM1 in home instant mode.",
"fields": {
"code": {
diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json
index c57b707906b..efa97a9f6b9 100644
--- a/homeassistant/components/elmax/manifest.json
+++ b/homeassistant/components/elmax/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/elmax",
"iot_class": "cloud_polling",
"loggers": ["elmax_api"],
- "requirements": ["elmax-api==0.0.5"],
+ "requirements": ["elmax-api==0.0.6.1"],
"zeroconf": [
{
"type": "_elmax-ssl._tcp.local."
diff --git a/homeassistant/components/elv/manifest.json b/homeassistant/components/elv/manifest.json
index 9b71595e58f..5757aeb5e52 100644
--- a/homeassistant/components/elv/manifest.json
+++ b/homeassistant/components/elv/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/elv",
"iot_class": "local_polling",
"loggers": ["pypca"],
+ "quality_scale": "legacy",
"requirements": ["pypca==0.0.7"]
}
diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json
index 3f57f62eb0b..856cdaf189f 100644
--- a/homeassistant/components/emby/manifest.json
+++ b/homeassistant/components/emby/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/emby",
"iot_class": "local_push",
"loggers": ["pyemby"],
+ "quality_scale": "legacy",
"requirements": ["pyEmby==1.10"]
}
diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py
index c696a569135..9273c24c7dc 100644
--- a/homeassistant/components/emoncms/sensor.py
+++ b/homeassistant/components/emoncms/sensor.py
@@ -10,16 +10,31 @@ from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
+ SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ CONCENTRATION_PARTS_PER_MILLION,
CONF_API_KEY,
CONF_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_URL,
CONF_VALUE_TEMPLATE,
+ PERCENTAGE,
+ UnitOfApparentPower,
+ UnitOfElectricCurrent,
+ UnitOfElectricPotential,
+ UnitOfEnergy,
+ UnitOfFrequency,
UnitOfPower,
+ UnitOfPressure,
+ UnitOfSoundPressure,
+ UnitOfSpeed,
+ UnitOfTemperature,
+ UnitOfVolume,
+ UnitOfVolumeFlowRate,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
@@ -41,6 +56,146 @@ from .const import (
)
from .coordinator import EmoncmsCoordinator
+SENSORS: dict[str | None, SensorEntityDescription] = {
+ "kWh": SensorEntityDescription(
+ key="energy|kWh",
+ translation_key="energy",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ "Wh": SensorEntityDescription(
+ key="energy|Wh",
+ translation_key="energy",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ "kW": SensorEntityDescription(
+ key="power|kW",
+ translation_key="power",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.KILO_WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "W": SensorEntityDescription(
+ key="power|W",
+ translation_key="power",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "V": SensorEntityDescription(
+ key="voltage",
+ translation_key="voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "A": SensorEntityDescription(
+ key="current",
+ translation_key="current",
+ device_class=SensorDeviceClass.CURRENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "VA": SensorEntityDescription(
+ key="apparent_power",
+ translation_key="apparent_power",
+ device_class=SensorDeviceClass.APPARENT_POWER,
+ native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "°C": SensorEntityDescription(
+ key="temperature|celsius",
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "°F": SensorEntityDescription(
+ key="temperature|fahrenheit",
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "K": SensorEntityDescription(
+ key="temperature|kelvin",
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.KELVIN,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "Hz": SensorEntityDescription(
+ key="frequency",
+ translation_key="frequency",
+ device_class=SensorDeviceClass.FREQUENCY,
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "hPa": SensorEntityDescription(
+ key="pressure",
+ translation_key="pressure",
+ device_class=SensorDeviceClass.PRESSURE,
+ native_unit_of_measurement=UnitOfPressure.HPA,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "dB": SensorEntityDescription(
+ key="decibel",
+ translation_key="decibel",
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ native_unit_of_measurement=UnitOfSoundPressure.DECIBEL,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "m³": SensorEntityDescription(
+ key="volume|cubic_meter",
+ translation_key="volume",
+ device_class=SensorDeviceClass.VOLUME_STORAGE,
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "m³/h": SensorEntityDescription(
+ key="flow|cubic_meters_per_hour",
+ translation_key="flow",
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "l/m": SensorEntityDescription(
+ key="flow|liters_per_minute",
+ translation_key="flow",
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "m/s": SensorEntityDescription(
+ key="speed|meters_per_second",
+ translation_key="speed",
+ device_class=SensorDeviceClass.SPEED,
+ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "µg/m³": SensorEntityDescription(
+ key="concentration|microgram_per_cubic_meter",
+ translation_key="concentration",
+ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "ppm": SensorEntityDescription(
+ key="concentration|microgram_parts_per_million",
+ translation_key="concentration",
+ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "%": SensorEntityDescription(
+ key="percent",
+ translation_key="percent",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+}
+
ATTR_FEEDID = "FeedId"
ATTR_FEEDNAME = "FeedName"
ATTR_LASTUPDATETIME = "LastUpdated"
@@ -173,6 +328,8 @@ async def async_setup_entry(
class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
"""Implementation of an Emoncms sensor."""
+ _attr_has_entity_name = True
+
def __init__(
self,
coordinator: EmoncmsCoordinator,
@@ -187,33 +344,15 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
elem = {}
if self.coordinator.data:
elem = self.coordinator.data[self.idx]
- self._attr_name = f"{name} {elem[FEED_NAME]}"
- self._attr_native_unit_of_measurement = unit_of_measurement
+ self._attr_translation_placeholders = {
+ "emoncms_details": f"{elem[FEED_TAG]} {elem[FEED_NAME]}",
+ }
self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}"
- if unit_of_measurement in ("kWh", "Wh"):
- self._attr_device_class = SensorDeviceClass.ENERGY
- self._attr_state_class = SensorStateClass.TOTAL_INCREASING
- elif unit_of_measurement == "W":
- self._attr_device_class = SensorDeviceClass.POWER
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "V":
- self._attr_device_class = SensorDeviceClass.VOLTAGE
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "A":
- self._attr_device_class = SensorDeviceClass.CURRENT
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "VA":
- self._attr_device_class = SensorDeviceClass.APPARENT_POWER
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement in ("°C", "°F", "K"):
- self._attr_device_class = SensorDeviceClass.TEMPERATURE
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "Hz":
- self._attr_device_class = SensorDeviceClass.FREQUENCY
- self._attr_state_class = SensorStateClass.MEASUREMENT
- elif unit_of_measurement == "hPa":
- self._attr_device_class = SensorDeviceClass.PRESSURE
- self._attr_state_class = SensorStateClass.MEASUREMENT
+ description = SENSORS.get(unit_of_measurement)
+ if description is not None:
+ self.entity_description = description
+ else:
+ self._attr_native_unit_of_measurement = unit_of_measurement
self._update_attributes(elem)
def _update_attributes(self, elem: dict[str, Any]) -> None:
diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json
index 0d841f2efb4..5769e825944 100644
--- a/homeassistant/components/emoncms/strings.json
+++ b/homeassistant/components/emoncms/strings.json
@@ -24,6 +24,52 @@
"already_configured": "This server is already configured"
}
},
+ "entity": {
+ "sensor": {
+ "energy": {
+ "name": "Energy {emoncms_details}"
+ },
+ "power": {
+ "name": "Power {emoncms_details}"
+ },
+ "percent": {
+ "name": "Percentage {emoncms_details}"
+ },
+ "voltage": {
+ "name": "Voltage {emoncms_details}"
+ },
+ "current": {
+ "name": "Current {emoncms_details}"
+ },
+ "apparent_power": {
+ "name": "Apparent power {emoncms_details}"
+ },
+ "temperature": {
+ "name": "Temperature {emoncms_details}"
+ },
+ "frequency": {
+ "name": "Frequency {emoncms_details}"
+ },
+ "pressure": {
+ "name": "Pressure {emoncms_details}"
+ },
+ "decibel": {
+ "name": "Decibel {emoncms_details}"
+ },
+ "volume": {
+ "name": "Volume {emoncms_details}"
+ },
+ "flow": {
+ "name": "Flow rate {emoncms_details}"
+ },
+ "speed": {
+ "name": "Speed {emoncms_details}"
+ },
+ "concentration": {
+ "name": "Concentration {emoncms_details}"
+ }
+ }
+ },
"options": {
"error": {
"api_error": "[%key:component::emoncms::config::error::api_error%]"
diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json
index faa91e64017..e73f76f7528 100644
--- a/homeassistant/components/emoncms_history/manifest.json
+++ b/homeassistant/components/emoncms_history/manifest.json
@@ -3,5 +3,6 @@
"name": "Emoncms History",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/emoncms_history",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json
index 807a0419967..bb867e88d85 100644
--- a/homeassistant/components/energyzero/manifest.json
+++ b/homeassistant/components/energyzero/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/energyzero",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["energyzero==2.1.1"]
}
diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json
index aa06a1ff79f..bdc90e6c634 100644
--- a/homeassistant/components/enphase_envoy/manifest.json
+++ b/homeassistant/components/enphase_envoy/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
- "requirements": ["pyenphase==1.22.0"],
+ "requirements": ["pyenphase==1.23.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
diff --git a/homeassistant/components/entur_public_transport/manifest.json b/homeassistant/components/entur_public_transport/manifest.json
index f75099c2c27..5e25eb4b4a7 100644
--- a/homeassistant/components/entur_public_transport/manifest.json
+++ b/homeassistant/components/entur_public_transport/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/entur_public_transport",
"iot_class": "cloud_polling",
"loggers": ["enturclient"],
+ "quality_scale": "legacy",
"requirements": ["enturclient==0.2.4"]
}
diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json
index 0cf9f165aa2..42587aa7c2f 100644
--- a/homeassistant/components/envisalink/manifest.json
+++ b/homeassistant/components/envisalink/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/envisalink",
"iot_class": "local_push",
"loggers": ["pyenvisalink"],
+ "quality_scale": "legacy",
"requirements": ["pyenvisalink==4.7"]
}
diff --git a/homeassistant/components/ephember/manifest.json b/homeassistant/components/ephember/manifest.json
index dd7938ccbd2..547ab2918f5 100644
--- a/homeassistant/components/ephember/manifest.json
+++ b/homeassistant/components/ephember/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ephember",
"iot_class": "local_polling",
"loggers": ["pyephember"],
+ "quality_scale": "legacy",
"requirements": ["pyephember==0.3.1"]
}
diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json
index b30f806bf63..ed80ad9aabf 100644
--- a/homeassistant/components/eq3btsmart/manifest.json
+++ b/homeassistant/components/eq3btsmart/manifest.json
@@ -22,6 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
- "quality_scale": "silver",
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"]
}
diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py
index dc513a03e02..f60668b0a06 100644
--- a/homeassistant/components/esphome/assist_satellite.py
+++ b/homeassistant/components/esphome/assist_satellite.py
@@ -95,11 +95,7 @@ async def async_setup_entry(
if entry_data.device_info.voice_assistant_feature_flags_compat(
entry_data.api_version
):
- async_add_entities(
- [
- EsphomeAssistSatellite(entry, entry_data),
- ]
- )
+ async_add_entities([EsphomeAssistSatellite(entry, entry_data)])
class EsphomeAssistSatellite(
@@ -198,6 +194,9 @@ class EsphomeAssistSatellite(
self._satellite_config.max_active_wake_words = config.max_active_wake_words
_LOGGER.debug("Received satellite configuration: %s", self._satellite_config)
+ # Inform listeners that config has been updated
+ self.entry_data.async_assist_satellite_config_updated(self._satellite_config)
+
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
@@ -254,6 +253,13 @@ class EsphomeAssistSatellite(
# Will use media player for TTS/announcements
self._update_tts_format()
+ # Update wake word select when config is updated
+ self.async_on_remove(
+ self.entry_data.async_register_assist_satellite_set_wake_word_callback(
+ self.async_set_wake_word
+ )
+ )
+
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
@@ -478,6 +484,17 @@ class EsphomeAssistSatellite(
"""Handle announcement finished message (also sent for TTS)."""
self.tts_response_finished()
+ @callback
+ def async_set_wake_word(self, wake_word_id: str) -> None:
+ """Set active wake word and update config on satellite."""
+ self._satellite_config.active_wake_words = [wake_word_id]
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self.async_set_configuration(self._satellite_config),
+ "esphome_voice_assistant_set_config",
+ )
+ _LOGGER.debug("Setting active wake word: %s", wake_word_id)
+
def _update_tts_format(self) -> None:
"""Update the TTS format from the first media player."""
for supported_format in chain(*self.entry_data.media_player_formats.values()):
diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py
index f1b5218eec7..fc41ee99a00 100644
--- a/homeassistant/components/esphome/entry_data.py
+++ b/homeassistant/components/esphome/entry_data.py
@@ -48,6 +48,7 @@ from aioesphomeapi import (
from aioesphomeapi.model import ButtonInfo
from bleak_esphome.backend.device import ESPHomeBluetoothDevice
+from homeassistant.components.assist_satellite import AssistSatelliteConfiguration
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
@@ -152,6 +153,12 @@ class RuntimeEntryData:
media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field(
default_factory=lambda: defaultdict(list)
)
+ assist_satellite_config_update_callbacks: list[
+ Callable[[AssistSatelliteConfiguration], None]
+ ] = field(default_factory=list)
+ assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field(
+ default_factory=list
+ )
@property
def name(self) -> str:
@@ -504,3 +511,35 @@ class RuntimeEntryData:
# We use this to determine if a deep sleep device should
# be marked as unavailable or not.
self.expected_disconnect = True
+
+ @callback
+ def async_register_assist_satellite_config_updated_callback(
+ self,
+ callback_: Callable[[AssistSatelliteConfiguration], None],
+ ) -> CALLBACK_TYPE:
+ """Register to receive callbacks when the Assist satellite's configuration is updated."""
+ self.assist_satellite_config_update_callbacks.append(callback_)
+ return lambda: self.assist_satellite_config_update_callbacks.remove(callback_)
+
+ @callback
+ def async_assist_satellite_config_updated(
+ self, config: AssistSatelliteConfiguration
+ ) -> None:
+ """Notify listeners that the Assist satellite configuration has been updated."""
+ for callback_ in self.assist_satellite_config_update_callbacks.copy():
+ callback_(config)
+
+ @callback
+ def async_register_assist_satellite_set_wake_word_callback(
+ self,
+ callback_: Callable[[str], None],
+ ) -> CALLBACK_TYPE:
+ """Register to receive callbacks when the Assist satellite's wake word is set."""
+ self.assist_satellite_set_wake_word_callbacks.append(callback_)
+ return lambda: self.assist_satellite_set_wake_word_callbacks.remove(callback_)
+
+ @callback
+ def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None:
+ """Notify listeners that the Assist satellite wake word has been set."""
+ for callback_ in self.assist_satellite_set_wake_word_callbacks.copy():
+ callback_(wake_word_id)
diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py
index 2dacae52f75..9484d1e7593 100644
--- a/homeassistant/components/esphome/ffmpeg_proxy.py
+++ b/homeassistant/components/esphome/ffmpeg_proxy.py
@@ -212,6 +212,10 @@ class FFmpegConvertResponse(web.StreamResponse):
assert proc.stdout is not None
assert proc.stderr is not None
+ stderr_task = self.hass.async_create_background_task(
+ self._dump_ffmpeg_stderr(proc), "ESPHome media proxy dump stderr"
+ )
+
try:
# Pull audio chunks from ffmpeg and pass them to the HTTP client
while (
@@ -230,18 +234,14 @@ class FFmpegConvertResponse(web.StreamResponse):
raise # don't log error
except:
_LOGGER.exception("Unexpected error during ffmpeg conversion")
-
- # Process did not exit successfully
- stderr_text = ""
- while line := await proc.stderr.readline():
- stderr_text += line.decode()
- _LOGGER.error("FFmpeg output: %s", stderr_text)
-
raise
finally:
# Allow conversion info to be removed
self.convert_info.is_finished = True
+ # stop dumping ffmpeg stderr task
+ stderr_task.cancel()
+
# Terminate hangs, so kill is used
if proc.returncode is None:
proc.kill()
@@ -250,6 +250,16 @@ class FFmpegConvertResponse(web.StreamResponse):
if request.transport and not request.transport.is_closing():
await writer.write_eof()
+ async def _dump_ffmpeg_stderr(
+ self,
+ proc: asyncio.subprocess.Process,
+ ) -> None:
+ assert proc.stdout is not None
+ assert proc.stderr is not None
+
+ while self.hass.is_running and (chunk := await proc.stderr.readline()):
+ _LOGGER.debug("ffmpeg[%s] output: %s", proc.pid, chunk.decode().rstrip())
+
class FFmpegProxyView(HomeAssistantView):
"""FFmpeg web view to convert audio and stream back to client."""
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index b9b6a98dcd1..77a3164d94c 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -15,9 +15,8 @@
"iot_class": "local_push",
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
- "quality_scale": "platinum",
"requirements": [
- "aioesphomeapi==27.0.1",
+ "aioesphomeapi==27.0.3",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==1.1.0"
],
diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py
index 3930b71d106..8a30814aa2c 100644
--- a/homeassistant/components/esphome/media_player.py
+++ b/homeassistant/components/esphome/media_player.py
@@ -20,6 +20,7 @@ from aioesphomeapi import (
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ANNOUNCE,
+ ATTR_MEDIA_EXTRA,
BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEntity,
@@ -50,6 +51,8 @@ _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumM
}
)
+ATTR_BYPASS_PROXY = "bypass_proxy"
+
class EsphomeMediaPlayer(
EsphomeEntity[MediaPlayerInfo, MediaPlayerEntityState], MediaPlayerEntity
@@ -108,13 +111,15 @@ class EsphomeMediaPlayer(
media_id = async_process_play_media_url(self.hass, media_id)
announcement = kwargs.get(ATTR_MEDIA_ANNOUNCE)
+ bypass_proxy = kwargs.get(ATTR_MEDIA_EXTRA, {}).get(ATTR_BYPASS_PROXY)
supported_formats: list[MediaPlayerSupportedFormat] | None = (
self._entry_data.media_player_formats.get(self._static_info.unique_id)
)
if (
- supported_formats
+ not bypass_proxy
+ and supported_formats
and _is_url(media_id)
and (
proxy_url := self._get_proxy_url(
diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py
index 623946503eb..71a21186d3d 100644
--- a/homeassistant/components/esphome/select.py
+++ b/homeassistant/components/esphome/select.py
@@ -8,8 +8,11 @@ from homeassistant.components.assist_pipeline.select import (
AssistPipelineSelect,
VadSensitivitySelect,
)
-from homeassistant.components.select import SelectEntity
+from homeassistant.components.assist_satellite import AssistSatelliteConfiguration
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import restore_state
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
@@ -47,6 +50,7 @@ async def async_setup_entry(
[
EsphomeAssistPipelineSelect(hass, entry_data),
EsphomeVadSensitivitySelect(hass, entry_data),
+ EsphomeAssistSatelliteWakeWordSelect(hass, entry_data),
]
)
@@ -89,3 +93,77 @@ class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect):
"""Initialize a VAD sensitivity selector."""
EsphomeAssistEntity.__init__(self, entry_data)
VadSensitivitySelect.__init__(self, hass, self._device_info.mac_address)
+
+
+class EsphomeAssistSatelliteWakeWordSelect(
+ EsphomeAssistEntity, SelectEntity, restore_state.RestoreEntity
+):
+ """Wake word selector for esphome devices."""
+
+ entity_description = SelectEntityDescription(
+ key="wake_word",
+ translation_key="wake_word",
+ entity_category=EntityCategory.CONFIG,
+ )
+ _attr_should_poll = False
+ _attr_current_option: str | None = None
+ _attr_options: list[str] = []
+
+ def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None:
+ """Initialize a wake word selector."""
+ EsphomeAssistEntity.__init__(self, entry_data)
+
+ unique_id_prefix = self._device_info.mac_address
+ self._attr_unique_id = f"{unique_id_prefix}-wake_word"
+
+ # name -> id
+ self._wake_words: dict[str, str] = {}
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return bool(self._attr_options)
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+
+ # Update options when config is updated
+ self.async_on_remove(
+ self._entry_data.async_register_assist_satellite_config_updated_callback(
+ self.async_satellite_config_updated
+ )
+ )
+
+ async def async_select_option(self, option: str) -> None:
+ """Select an option."""
+ if wake_word_id := self._wake_words.get(option):
+ # _attr_current_option will be updated on
+ # async_satellite_config_updated after the device sets the wake
+ # word.
+ self._entry_data.async_assist_satellite_set_wake_word(wake_word_id)
+
+ def async_satellite_config_updated(
+ self, config: AssistSatelliteConfiguration
+ ) -> None:
+ """Update options with available wake words."""
+ if (not config.available_wake_words) or (config.max_active_wake_words < 1):
+ self._attr_current_option = None
+ self._wake_words.clear()
+ self.async_write_ha_state()
+ return
+
+ self._wake_words = {w.wake_word: w.id for w in config.available_wake_words}
+ self._attr_options = sorted(self._wake_words)
+
+ if config.active_wake_words:
+ # Select first active wake word
+ wake_word_id = config.active_wake_words[0]
+ for wake_word in config.available_wake_words:
+ if wake_word.id == wake_word_id:
+ self._attr_current_option = wake_word.wake_word
+ else:
+ # Select first available wake word
+ self._attr_current_option = config.available_wake_words[0].wake_word
+
+ self.async_write_ha_state()
diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json
index 18a54772e30..81b58de8df2 100644
--- a/homeassistant/components/esphome/strings.json
+++ b/homeassistant/components/esphome/strings.json
@@ -84,6 +84,12 @@
"aggressive": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::aggressive%]",
"relaxed": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::relaxed%]"
}
+ },
+ "wake_word": {
+ "name": "Wake word",
+ "state": {
+ "okay_nabu": "Okay Nabu"
+ }
}
},
"climate": {
@@ -119,7 +125,7 @@
},
"service_calls_not_allowed": {
"title": "{name} is not permitted to perform Home Assistant actions",
- "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perfom Home Assistant action, you can enable this functionality in the options flow."
+ "description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow."
}
}
}
diff --git a/homeassistant/components/etherscan/manifest.json b/homeassistant/components/etherscan/manifest.json
index 1b296e4e4be..e5099ffaf9c 100644
--- a/homeassistant/components/etherscan/manifest.json
+++ b/homeassistant/components/etherscan/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/etherscan",
"iot_class": "cloud_polling",
"loggers": ["pyetherscan"],
+ "quality_scale": "legacy",
"requirements": ["python-etherscan-api==0.0.3"]
}
diff --git a/homeassistant/components/eufy/manifest.json b/homeassistant/components/eufy/manifest.json
index ccf15144f9e..6ad1b7de81b 100644
--- a/homeassistant/components/eufy/manifest.json
+++ b/homeassistant/components/eufy/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/eufy",
"iot_class": "local_polling",
"loggers": ["lakeside"],
+ "quality_scale": "legacy",
"requirements": ["lakeside==0.13"]
}
diff --git a/homeassistant/components/everlights/manifest.json b/homeassistant/components/everlights/manifest.json
index 6f856b26087..a2deeab2666 100644
--- a/homeassistant/components/everlights/manifest.json
+++ b/homeassistant/components/everlights/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/everlights",
"iot_class": "local_polling",
"loggers": ["pyeverlights"],
+ "quality_scale": "legacy",
"requirements": ["pyeverlights==0.1.0"]
}
diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json
index e81e71c5b07..da3d197f6aa 100644
--- a/homeassistant/components/evohome/manifest.json
+++ b/homeassistant/components/evohome/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/evohome",
"iot_class": "cloud_polling",
"loggers": ["evohomeasync", "evohomeasync2"],
+ "quality_scale": "legacy",
"requirements": ["evohome-async==0.4.20"]
}
diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json
index 53976bf3002..7c796c74ef7 100644
--- a/homeassistant/components/ezviz/manifest.json
+++ b/homeassistant/components/ezviz/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/ezviz",
"iot_class": "cloud_polling",
"loggers": ["paho_mqtt", "pyezviz"],
- "requirements": ["pyezviz==0.2.1.2"]
+ "requirements": ["pyezviz==0.2.2.3"]
}
diff --git a/homeassistant/components/facebook/manifest.json b/homeassistant/components/facebook/manifest.json
index 5074489852e..5a7eb216ccc 100644
--- a/homeassistant/components/facebook/manifest.json
+++ b/homeassistant/components/facebook/manifest.json
@@ -3,5 +3,6 @@
"name": "Facebook Messenger",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/facebook",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/fail2ban/manifest.json b/homeassistant/components/fail2ban/manifest.json
index e348db1c695..1570afda6eb 100644
--- a/homeassistant/components/fail2ban/manifest.json
+++ b/homeassistant/components/fail2ban/manifest.json
@@ -3,5 +3,6 @@
"name": "Fail2Ban",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/fail2ban",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/familyhub/manifest.json b/homeassistant/components/familyhub/manifest.json
index f57030efb27..cf4bf0ba68f 100644
--- a/homeassistant/components/familyhub/manifest.json
+++ b/homeassistant/components/familyhub/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/familyhub",
"iot_class": "local_polling",
"loggers": ["pyfamilyhublocal"],
+ "quality_scale": "legacy",
"requirements": ["python-family-hub-local==0.0.2"]
}
diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py
index b1c2b748520..71fb9c53353 100644
--- a/homeassistant/components/fan/__init__.py
+++ b/homeassistant/components/fan/__init__.py
@@ -23,12 +23,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
@@ -61,21 +55,6 @@ class FanEntityFeature(IntFlag):
TURN_ON = 32
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the FanEntityFeature enum instead.
-_DEPRECATED_SUPPORT_SET_SPEED = DeprecatedConstantEnum(
- FanEntityFeature.SET_SPEED, "2025.1"
-)
-_DEPRECATED_SUPPORT_OSCILLATE = DeprecatedConstantEnum(
- FanEntityFeature.OSCILLATE, "2025.1"
-)
-_DEPRECATED_SUPPORT_DIRECTION = DeprecatedConstantEnum(
- FanEntityFeature.DIRECTION, "2025.1"
-)
-_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum(
- FanEntityFeature.PRESET_MODE, "2025.1"
-)
-
SERVICE_INCREASE_SPEED = "increase_speed"
SERVICE_DECREASE_SPEED = "decrease_speed"
SERVICE_OSCILLATE = "oscillate"
@@ -234,10 +213,10 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
entity_description: FanEntityDescription
_attr_current_direction: str | None = None
_attr_oscillating: bool | None = None
- _attr_percentage: int | None
- _attr_preset_mode: str | None
- _attr_preset_modes: list[str] | None
- _attr_speed_count: int
+ _attr_percentage: int | None = 0
+ _attr_preset_mode: str | None = None
+ _attr_preset_modes: list[str] | None = None
+ _attr_speed_count: int = 100
_attr_supported_features: FanEntityFeature = FanEntityFeature(0)
__mod_supported_features: FanEntityFeature = FanEntityFeature(0)
@@ -245,14 +224,14 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
# once migrated and set the feature flags TURN_ON/TURN_OFF as needed.
_enable_turn_on_off_backwards_compatibility: bool = True
- def __getattribute__(self, __name: str) -> Any:
+ def __getattribute__(self, name: str, /) -> Any:
"""Get attribute.
Modify return of `supported_features` to
include `_mod_supported_features` if attribute is set.
"""
- if __name != "supported_features":
- return super().__getattribute__(__name)
+ if name != "supported_features":
+ return super().__getattribute__(name)
# Convert the supported features to ClimateEntityFeature.
# Remove this compatibility shim in 2025.1 or later.
@@ -463,16 +442,12 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@cached_property
def percentage(self) -> int | None:
"""Return the current speed as a percentage."""
- if hasattr(self, "_attr_percentage"):
- return self._attr_percentage
- return 0
+ return self._attr_percentage
@cached_property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
- if hasattr(self, "_attr_speed_count"):
- return self._attr_speed_count
- return 100
+ return self._attr_speed_count
@property
def percentage_step(self) -> float:
@@ -538,9 +513,7 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Requires FanEntityFeature.SET_SPEED.
"""
- if hasattr(self, "_attr_preset_mode"):
- return self._attr_preset_mode
- return None
+ return self._attr_preset_mode
@cached_property
def preset_modes(self) -> list[str] | None:
@@ -548,14 +521,4 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Requires FanEntityFeature.SET_SPEED.
"""
- if hasattr(self, "_attr_preset_modes"):
- return self._attr_preset_modes
- return None
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = ft.partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
+ return self._attr_preset_modes
diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json
index aab714d3e07..c4951e88c91 100644
--- a/homeassistant/components/fan/strings.json
+++ b/homeassistant/components/fan/strings.json
@@ -56,17 +56,17 @@
"services": {
"set_preset_mode": {
"name": "Set preset mode",
- "description": "Sets preset mode.",
+ "description": "Sets preset fan mode.",
"fields": {
"preset_mode": {
"name": "Preset mode",
- "description": "Preset mode."
+ "description": "Preset fan mode."
}
}
},
"set_percentage": {
"name": "Set speed",
- "description": "Sets the fan speed.",
+ "description": "Sets the speed of a fan.",
"fields": {
"percentage": {
"name": "Percentage",
@@ -94,45 +94,45 @@
},
"oscillate": {
"name": "Oscillate",
- "description": "Controls oscillatation of the fan.",
+ "description": "Controls the oscillation of a fan.",
"fields": {
"oscillating": {
"name": "Oscillating",
- "description": "Turn on/off oscillation."
+ "description": "Turns oscillation on/off."
}
}
},
"toggle": {
"name": "[%key:common::action::toggle%]",
- "description": "Toggles the fan on/off."
+ "description": "Toggles a fan on/off."
},
"set_direction": {
"name": "Set direction",
- "description": "Sets the fan rotation direction.",
+ "description": "Sets a fan's rotation direction.",
"fields": {
"direction": {
"name": "Direction",
- "description": "Direction to rotate."
+ "description": "Direction of the fan rotation."
}
}
},
"increase_speed": {
"name": "Increase speed",
- "description": "Increases the speed of the fan.",
+ "description": "Increases the speed of a fan.",
"fields": {
"percentage_step": {
"name": "Increment",
- "description": "Increases the speed by a percentage step."
+ "description": "Percentage step by which the speed should be increased."
}
}
},
"decrease_speed": {
"name": "Decrease speed",
- "description": "Decreases the speed of the fan.",
+ "description": "Decreases the speed of a fan.",
"fields": {
"percentage_step": {
"name": "Decrement",
- "description": "Decreases the speed by a percentage step."
+ "description": "Percentage step by which the speed should be decreased."
}
}
}
diff --git a/homeassistant/components/fastdotcom/manifest.json b/homeassistant/components/fastdotcom/manifest.json
index 9e2e077858c..10b6fdb5b5d 100644
--- a/homeassistant/components/fastdotcom/manifest.json
+++ b/homeassistant/components/fastdotcom/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/fastdotcom",
"iot_class": "cloud_polling",
"loggers": ["fastdotcom"],
- "quality_scale": "gold",
"requirements": ["fastdotcom==0.0.3"],
"single_config_entry": true
}
diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py
index b902d48a1c8..72042de25ed 100644
--- a/homeassistant/components/feedreader/config_flow.py
+++ b/homeassistant/components/feedreader/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import html
import logging
from typing import Any
import urllib.error
@@ -107,7 +108,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
return self.abort_on_import_error(user_input[CONF_URL], "url_error")
return self.show_user_form(user_input, {"base": "url_error"})
- feed_title = feed["feed"]["title"]
+ feed_title = html.unescape(feed["feed"]["title"])
return self.async_create_entry(
title=feed_title,
diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py
index 6608c4312fe..f45b303946a 100644
--- a/homeassistant/components/feedreader/coordinator.py
+++ b/homeassistant/components/feedreader/coordinator.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from calendar import timegm
from datetime import datetime
+import html
from logging import getLogger
from time import gmtime, struct_time
from typing import TYPE_CHECKING
@@ -102,7 +103,8 @@ class FeedReaderCoordinator(
"""Set up the feed manager."""
feed = await self._async_fetch_feed()
self.logger.debug("Feed data fetched from %s : %s", self.url, feed["feed"])
- self.feed_author = feed["feed"].get("author")
+ if feed_author := feed["feed"].get("author"):
+ self.feed_author = html.unescape(feed_author)
self.feed_version = feedparser.api.SUPPORTED_VERSIONS.get(feed["version"])
self._feed = feed
diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py
index 4b3fb2e2524..ad6aed0fc76 100644
--- a/homeassistant/components/feedreader/event.py
+++ b/homeassistant/components/feedreader/event.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import html
import logging
from feedparser import FeedParserDict
@@ -76,15 +77,22 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity):
# so we always take the first entry in list, since we only care about the latest entry
feed_data: FeedParserDict = data[0]
+ if description := feed_data.get("description"):
+ description = html.unescape(description)
+
+ if title := feed_data.get("title"):
+ title = html.unescape(title)
+
if content := feed_data.get("content"):
if isinstance(content, list) and isinstance(content[0], dict):
content = content[0].get("value")
+ content = html.unescape(content)
self._trigger_event(
EVENT_FEEDREADER,
{
- ATTR_DESCRIPTION: feed_data.get("description"),
- ATTR_TITLE: feed_data.get("title"),
+ ATTR_DESCRIPTION: description,
+ ATTR_TITLE: title,
ATTR_LINK: feed_data.get("link"),
ATTR_CONTENT: content,
},
diff --git a/homeassistant/components/ffmpeg_motion/manifest.json b/homeassistant/components/ffmpeg_motion/manifest.json
index 0115ed712e3..f51a6206e2b 100644
--- a/homeassistant/components/ffmpeg_motion/manifest.json
+++ b/homeassistant/components/ffmpeg_motion/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/ffmpeg_motion",
- "iot_class": "calculated"
+ "iot_class": "calculated",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/ffmpeg_noise/manifest.json b/homeassistant/components/ffmpeg_noise/manifest.json
index 6352fed88c4..f1c0cc9f673 100644
--- a/homeassistant/components/ffmpeg_noise/manifest.json
+++ b/homeassistant/components/ffmpeg_noise/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/ffmpeg_noise",
- "iot_class": "calculated"
+ "iot_class": "calculated",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py
index c787ca70272..0898d1c9318 100644
--- a/homeassistant/components/fibaro/cover.py
+++ b/homeassistant/components/fibaro/cover.py
@@ -69,37 +69,29 @@ class FibaroCover(FibaroEntity, CoverEntity):
# so if it is missing we have a device which supports open / close only
return not self.fibaro_device.value.has_value
- @property
- def current_cover_position(self) -> int | None:
- """Return current position of cover. 0 is closed, 100 is open."""
- return self.bound(self.level)
+ def update(self) -> None:
+ """Update the state."""
+ super().update()
- @property
- def current_cover_tilt_position(self) -> int | None:
- """Return the current tilt position for venetian blinds."""
- return self.bound(self.level2)
+ self._attr_current_cover_position = self.bound(self.level)
+ self._attr_current_cover_tilt_position = self.bound(self.level2)
- @property
- def is_opening(self) -> bool | None:
- """Return if the cover is opening or not.
+ device_state = self.fibaro_device.state
- Be aware that this property is only available for some modern devices.
- For example the Fibaro Roller Shutter 4 reports this correctly.
- """
- if self.fibaro_device.state.has_value:
- return self.fibaro_device.state.str_value().lower() == "opening"
- return None
+ # Be aware that opening and closing is only available for some modern
+ # devices.
+ # For example the Fibaro Roller Shutter 4 reports this correctly.
+ if device_state.has_value:
+ self._attr_is_opening = device_state.str_value().lower() == "opening"
+ self._attr_is_closing = device_state.str_value().lower() == "closing"
- @property
- def is_closing(self) -> bool | None:
- """Return if the cover is closing or not.
-
- Be aware that this property is only available for some modern devices.
- For example the Fibaro Roller Shutter 4 reports this correctly.
- """
- if self.fibaro_device.state.has_value:
- return self.fibaro_device.state.str_value().lower() == "closing"
- return None
+ closed: bool | None = None
+ if self._is_open_close_only():
+ if device_state.has_value and device_state.str_value().lower() != "unknown":
+ closed = device_state.str_value().lower() == "closed"
+ elif self.current_cover_position is not None:
+ closed = self.current_cover_position == 0
+ self._attr_is_closed = closed
def set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
@@ -109,19 +101,6 @@ class FibaroCover(FibaroEntity, CoverEntity):
"""Move the cover to a specific position."""
self.set_level2(cast(int, kwargs.get(ATTR_TILT_POSITION)))
- @property
- def is_closed(self) -> bool | None:
- """Return if the cover is closed."""
- if self._is_open_close_only():
- state = self.fibaro_device.state
- if not state.has_value or state.str_value().lower() == "unknown":
- return None
- return state.str_value().lower() == "closed"
-
- if self.current_cover_position is None:
- return None
- return self.current_cover_position == 0
-
def open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
self.action("open")
diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py
index 17831a36a4a..18f86b6df7d 100644
--- a/homeassistant/components/fibaro/light.py
+++ b/homeassistant/components/fibaro/light.py
@@ -132,32 +132,25 @@ class FibaroLight(FibaroEntity, LightEntity):
"""Turn the light off."""
self.call_turn_off()
- @property
- def is_on(self) -> bool | None:
- """Return true if device is on.
-
- Dimmable and RGB lights can be on based on different
- properties, so we need to check here several values.
-
- JSON for HC2 uses always string, HC3 uses int for integers.
- """
- if self.current_binary_state:
- return True
- with suppress(TypeError):
- if self.fibaro_device.brightness != 0:
- return True
- with suppress(TypeError):
- if self.fibaro_device.current_program != 0:
- return True
- with suppress(TypeError):
- if self.fibaro_device.current_program_id != 0:
- return True
-
- return False
-
def update(self) -> None:
"""Update the state."""
super().update()
+
+ # Dimmable and RGB lights can be on based on different
+ # properties, so we need to check here several values
+ # to see if the light is on.
+ light_is_on = self.current_binary_state
+ with suppress(TypeError):
+ if self.fibaro_device.brightness != 0:
+ light_is_on = True
+ with suppress(TypeError):
+ if self.fibaro_device.current_program != 0:
+ light_is_on = True
+ with suppress(TypeError):
+ if self.fibaro_device.current_program_id != 0:
+ light_is_on = True
+ self._attr_is_on = light_is_on
+
# Brightness handling
if brightness_supported(self.supported_color_modes):
self._attr_brightness = scaleto255(self.fibaro_device.value.int_value())
@@ -172,7 +165,7 @@ class FibaroLight(FibaroEntity, LightEntity):
if rgbw == (0, 0, 0, 0) and self.fibaro_device.last_color_set.has_color:
rgbw = self.fibaro_device.last_color_set.rgbw_color
- if self._attr_color_mode == ColorMode.RGB:
+ if self.color_mode == ColorMode.RGB:
self._attr_rgb_color = rgbw[:3]
else:
self._attr_rgbw_color = rgbw
diff --git a/homeassistant/components/fido/manifest.json b/homeassistant/components/fido/manifest.json
index dc440304646..23949a56ee2 100644
--- a/homeassistant/components/fido/manifest.json
+++ b/homeassistant/components/fido/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/fido",
"iot_class": "cloud_polling",
"loggers": ["pyfido"],
+ "quality_scale": "legacy",
"requirements": ["pyfido==2.1.2"]
}
diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json
index 8806c67cd96..bd8f23602e3 100644
--- a/homeassistant/components/file/strings.json
+++ b/homeassistant/components/file/strings.json
@@ -18,7 +18,7 @@
},
"data_description": {
"file_path": "The local file path to retrieve the sensor value from",
- "value_template": "A template to render the sensors value based on the file content",
+ "value_template": "A template to render the sensor's value based on the file content",
"unit_of_measurement": "Unit of measurement for the sensor"
}
},
diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py
index 51eff46bdb3..8ffe3f94353 100644
--- a/homeassistant/components/filesize/config_flow.py
+++ b/homeassistant/components/filesize/config_flow.py
@@ -11,7 +11,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_FILE_PATH
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
@@ -20,20 +19,20 @@ DATA_SCHEMA = vol.Schema({vol.Required(CONF_FILE_PATH): str})
_LOGGER = logging.getLogger(__name__)
-def validate_path(hass: HomeAssistant, path: str) -> str:
+def validate_path(hass: HomeAssistant, path: str) -> tuple[str | None, dict[str, str]]:
"""Validate path."""
get_path = pathlib.Path(path)
if not get_path.exists() or not get_path.is_file():
_LOGGER.error("Can not access file %s", path)
- raise NotValidError
+ return (None, {"base": "not_valid"})
if not hass.config.is_allowed_path(path):
_LOGGER.error("Filepath %s is not allowed", path)
- raise NotAllowedError
+ return (None, {"base": "not_allowed"})
full_path = get_path.absolute()
- return str(full_path)
+ return (str(full_path), {})
class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -45,18 +44,13 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
- errors: dict[str, Any] = {}
+ errors: dict[str, str] = {}
if user_input is not None:
- try:
- full_path = await self.hass.async_add_executor_job(
- validate_path, self.hass, user_input[CONF_FILE_PATH]
- )
- except NotValidError:
- errors["base"] = "not_valid"
- except NotAllowedError:
- errors["base"] = "not_allowed"
- else:
+ full_path, errors = await self.hass.async_add_executor_job(
+ validate_path, self.hass, user_input[CONF_FILE_PATH]
+ )
+ if not errors:
await self.async_set_unique_id(full_path)
self._abort_if_unique_id_configured()
@@ -70,10 +64,29 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a reconfigure flow initialized by the user."""
+ errors: dict[str, str] = {}
-class NotValidError(HomeAssistantError):
- """Path is not valid error."""
+ if user_input is not None:
+ reconfigure_entry = self._get_reconfigure_entry()
+ full_path, errors = await self.hass.async_add_executor_job(
+ validate_path, self.hass, user_input[CONF_FILE_PATH]
+ )
+ if not errors:
+ await self.async_set_unique_id(full_path)
+ self._abort_if_unique_id_configured()
+ name = str(user_input[CONF_FILE_PATH]).rsplit("/", maxsplit=1)[-1]
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ title=name,
+ unique_id=self.unique_id,
+ data_updates={CONF_FILE_PATH: user_input[CONF_FILE_PATH]},
+ )
-class NotAllowedError(HomeAssistantError):
- """Path is not allowed error."""
+ return self.async_show_form(
+ step_id="reconfigure", data_schema=DATA_SCHEMA, errors=errors
+ )
diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py
index c0dbb14555e..8350cee91bf 100644
--- a/homeassistant/components/filesize/coordinator.py
+++ b/homeassistant/components/filesize/coordinator.py
@@ -60,12 +60,14 @@ class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime
statinfo = await self.hass.async_add_executor_job(self._update)
size = statinfo.st_size
last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime)
+ created = dt_util.utc_from_timestamp(statinfo.st_ctime)
_LOGGER.debug("size %s, last updated %s", size, last_updated)
data: dict[str, int | float | datetime] = {
"file": round(size / 1e6, 2),
"bytes": size,
"last_updated": last_updated,
+ "created": created,
}
return data
diff --git a/homeassistant/components/filesize/icons.json b/homeassistant/components/filesize/icons.json
index 15829589853..059a51a9e34 100644
--- a/homeassistant/components/filesize/icons.json
+++ b/homeassistant/components/filesize/icons.json
@@ -9,6 +9,9 @@
},
"last_updated": {
"default": "mdi:file"
+ },
+ "created": {
+ "default": "mdi:file"
}
}
}
diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py
index 71a4e50edfe..2eb170af99d 100644
--- a/homeassistant/components/filesize/sensor.py
+++ b/homeassistant/components/filesize/sensor.py
@@ -47,6 +47,13 @@ SENSOR_TYPES = (
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
+ SensorEntityDescription(
+ key="created",
+ translation_key="created",
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
)
@@ -75,7 +82,6 @@ class FilesizeEntity(CoordinatorEntity[FileSizeCoordinator], SensorEntity):
) -> None:
"""Initialize the Filesize sensor."""
super().__init__(coordinator)
- base_name = str(coordinator.path.absolute()).rsplit("/", maxsplit=1)[-1]
self._attr_unique_id = (
entry_id if description.key == "file" else f"{entry_id}-{description.key}"
)
@@ -83,7 +89,6 @@ class FilesizeEntity(CoordinatorEntity[FileSizeCoordinator], SensorEntity):
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry_id)},
- name=base_name,
)
@property
diff --git a/homeassistant/components/filesize/strings.json b/homeassistant/components/filesize/strings.json
index 3323c3411b2..6623cf9c375 100644
--- a/homeassistant/components/filesize/strings.json
+++ b/homeassistant/components/filesize/strings.json
@@ -5,6 +5,11 @@
"data": {
"file_path": "Path to file"
}
+ },
+ "reconfigure": {
+ "data": {
+ "file_path": "[%key:component::filesize::config::step::user::data::file_path%]"
+ }
}
},
"error": {
@@ -12,7 +17,8 @@
"not_allowed": "Path is not allowed"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"title": "Filesize",
@@ -26,6 +32,9 @@
},
"last_updated": {
"name": "Last updated"
+ },
+ "created": {
+ "name": "Created"
}
}
}
diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json
index 063e612d35d..0a9c5389cd9 100644
--- a/homeassistant/components/fints/manifest.json
+++ b/homeassistant/components/fints/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["fints", "mt_940", "sepaxml"],
+ "quality_scale": "legacy",
"requirements": ["fints==3.1.0"]
}
diff --git a/homeassistant/components/firmata/manifest.json b/homeassistant/components/firmata/manifest.json
index a35b6f179ce..363b5bd60c6 100644
--- a/homeassistant/components/firmata/manifest.json
+++ b/homeassistant/components/firmata/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/firmata",
"iot_class": "local_push",
"loggers": ["pymata_express"],
+ "quality_scale": "legacy",
"requirements": ["pymata-express==1.19"]
}
diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py
index cb4e3fb4ea3..d5b33a731e3 100644
--- a/homeassistant/components/fitbit/config_flow.py
+++ b/homeassistant/components/fitbit/config_flow.py
@@ -86,7 +86,3 @@ class OAuth2FlowHandler(
self._abort_if_unique_id_configured()
return self.async_create_entry(title=profile.display_name, data=data)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Handle import from YAML."""
- return await self.async_oauth_create_entry(import_data)
diff --git a/homeassistant/components/fixer/manifest.json b/homeassistant/components/fixer/manifest.json
index 052a594b745..3c457919ac3 100644
--- a/homeassistant/components/fixer/manifest.json
+++ b/homeassistant/components/fixer/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/fixer",
"iot_class": "cloud_polling",
"loggers": ["fixerio"],
+ "quality_scale": "legacy",
"requirements": ["fixerio==1.0.0a0"]
}
diff --git a/homeassistant/components/fleetgo/manifest.json b/homeassistant/components/fleetgo/manifest.json
index 9e916bd7fcd..ad00ca3b7b1 100644
--- a/homeassistant/components/fleetgo/manifest.json
+++ b/homeassistant/components/fleetgo/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/fleetgo",
"iot_class": "cloud_polling",
"loggers": ["geopy", "ritassist"],
+ "quality_scale": "legacy",
"requirements": ["ritassist==0.9.2"]
}
diff --git a/homeassistant/components/flexit/manifest.json b/homeassistant/components/flexit/manifest.json
index 98e5a3734a8..b3b66fb871e 100644
--- a/homeassistant/components/flexit/manifest.json
+++ b/homeassistant/components/flexit/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["modbus"],
"documentation": "https://www.home-assistant.io/integrations/flexit",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/flic/manifest.json b/homeassistant/components/flic/manifest.json
index 0442e4a7b7b..67a9a2e901c 100644
--- a/homeassistant/components/flic/manifest.json
+++ b/homeassistant/components/flic/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/flic",
"iot_class": "local_push",
"loggers": ["pyflic"],
+ "quality_scale": "legacy",
"requirements": ["pyflic==2.0.4"]
}
diff --git a/homeassistant/components/flock/manifest.json b/homeassistant/components/flock/manifest.json
index 29c3e1c881f..c4cd5cdadb3 100644
--- a/homeassistant/components/flock/manifest.json
+++ b/homeassistant/components/flock/manifest.json
@@ -3,5 +3,6 @@
"name": "Flock",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/flock",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/folder/manifest.json b/homeassistant/components/folder/manifest.json
index 2436d5dbe9a..984b287c2c0 100644
--- a/homeassistant/components/folder/manifest.json
+++ b/homeassistant/components/folder/manifest.json
@@ -3,5 +3,6 @@
"name": "Folder",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/folder",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json
index a517f1fea6f..147a0037a18 100644
--- a/homeassistant/components/foobot/manifest.json
+++ b/homeassistant/components/foobot/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/foobot",
"iot_class": "cloud_polling",
"loggers": ["foobot_async"],
+ "quality_scale": "legacy",
"requirements": ["foobot_async==1.0.0"]
}
diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json
index f5dd79281e6..1eb9c98701d 100644
--- a/homeassistant/components/forecast_solar/manifest.json
+++ b/homeassistant/components/forecast_solar/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
- "requirements": ["forecast-solar==3.1.0"]
+ "requirements": ["forecast-solar==4.0.0"]
}
diff --git a/homeassistant/components/fortios/manifest.json b/homeassistant/components/fortios/manifest.json
index 93e55071178..22c44acfd82 100644
--- a/homeassistant/components/fortios/manifest.json
+++ b/homeassistant/components/fortios/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/fortios",
"iot_class": "local_polling",
"loggers": ["fortiosapi", "paramiko"],
+ "quality_scale": "legacy",
"requirements": ["fortiosapi==1.0.5"]
}
diff --git a/homeassistant/components/foursquare/manifest.json b/homeassistant/components/foursquare/manifest.json
index ce1c87814d7..0503ea4abb5 100644
--- a/homeassistant/components/foursquare/manifest.json
+++ b/homeassistant/components/foursquare/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/foursquare",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/free_mobile/manifest.json b/homeassistant/components/free_mobile/manifest.json
index 61a1f94c19d..9ce9bc72c76 100644
--- a/homeassistant/components/free_mobile/manifest.json
+++ b/homeassistant/components/free_mobile/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/free_mobile",
"iot_class": "cloud_push",
"loggers": ["freesms"],
+ "quality_scale": "legacy",
"requirements": ["freesms==0.2.0"]
}
diff --git a/homeassistant/components/freedns/manifest.json b/homeassistant/components/freedns/manifest.json
index ac320a51d93..7c6bceb11a6 100644
--- a/homeassistant/components/freedns/manifest.json
+++ b/homeassistant/components/freedns/manifest.json
@@ -3,5 +3,6 @@
"name": "FreeDNS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/freedns",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py
index 31d8ff81491..90bd6068ecb 100644
--- a/homeassistant/components/fritz/coordinator.py
+++ b/homeassistant/components/fritz/coordinator.py
@@ -326,7 +326,11 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
"call_deflections"
] = await self.async_update_call_deflections()
except FRITZ_EXCEPTIONS as ex:
- raise UpdateFailed(ex) from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": str(ex)},
+ ) from ex
_LOGGER.debug("enity_data: %s", entity_data)
return entity_data
diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml
new file mode 100644
index 00000000000..b832492cf9d
--- /dev/null
+++ b/homeassistant/components/fritz/quality_scale.yaml
@@ -0,0 +1,98 @@
+rules:
+ # Bronze
+ action-setup:
+ status: todo
+ comment: still in async_setup_entry, needs to be moved to async_setup
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage:
+ status: todo
+ comment: one coverage miss in line 110
+ config-flow:
+ status: todo
+ comment: data_description are missing
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions:
+ status: todo
+ comment: include the proper docs snippet
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name:
+ status: todo
+ comment: partially done
+ runtime-data:
+ status: todo
+ comment: still uses hass.data
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters:
+ status: todo
+ comment: add the proper configuration_basic block
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: todo
+ comment: not set at the moment, we use a coordinator
+ reauthentication-flow: done
+ test-coverage:
+ status: todo
+ comment: we are close to the goal of 95%
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: todo
+ discovery: done
+ docs-data-update: todo
+ docs-examples: done
+ docs-known-limitations:
+ status: exempt
+ comment: no known limitations, yet
+ docs-supported-devices:
+ status: todo
+ comment: add the known supported devices
+ docs-supported-functions:
+ status: todo
+ comment: need to be overhauled
+ docs-troubleshooting: done
+ docs-use-cases:
+ status: todo
+ comment: need to be overhauled
+ dynamic-devices: done
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: no known use cases for repair issues or flows, yet
+ stale-devices:
+ status: todo
+ comment: automate the current cleanup process and deprecate the corresponding button
+
+ # Platinum
+ async-dependency:
+ status: todo
+ comment: |
+ the fritzconnection lib is not async
+ changing this might need a bit more efforts to be spent
+ inject-websession:
+ status: todo
+ comment: |
+ the fritzconnection lib is not async and relies on requests
+ changing this might need a bit more efforts to be spent
+ strict-typing: done
diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json
index 96eb6243529..06a07cba79e 100644
--- a/homeassistant/components/fritz/strings.json
+++ b/homeassistant/components/fritz/strings.json
@@ -176,6 +176,9 @@
},
"unable_to_connect": {
"message": "Unable to establish a connection"
+ },
+ "update_failed": {
+ "message": "Error while uptaing the data: {error}"
}
}
}
diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json
index 3735c16571e..1a127597b81 100644
--- a/homeassistant/components/fritzbox/manifest.json
+++ b/homeassistant/components/fritzbox/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
- "quality_scale": "gold",
"requirements": ["pyfritzhome==0.6.12"],
"ssdp": [
{
diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json
index c2f635119aa..227234f9937 100644
--- a/homeassistant/components/fronius/manifest.json
+++ b/homeassistant/components/fronius/manifest.json
@@ -11,6 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/fronius",
"iot_class": "local_polling",
"loggers": ["pyfronius"],
- "quality_scale": "platinum",
"requirements": ["PyFronius==0.7.3"]
}
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 4dc5a2b0ae4..264f0756b82 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20241106.2"]
+ "requirements": ["home-assistant-frontend==20241127.3"]
}
diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json
index f7f3af8d037..ea08a2cfe02 100644
--- a/homeassistant/components/fujitsu_fglair/manifest.json
+++ b/homeassistant/components/fujitsu_fglair/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
"iot_class": "cloud_polling",
- "requirements": ["ayla-iot-unofficial==1.4.3"]
+ "requirements": ["ayla-iot-unofficial==1.4.4"]
}
diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py
index 99b477c2989..074ec3feaa0 100644
--- a/homeassistant/components/fully_kiosk/__init__.py
+++ b/homeassistant/components/fully_kiosk/__init__.py
@@ -10,6 +10,8 @@ from .const import DOMAIN
from .coordinator import FullyKioskDataUpdateCoordinator
from .services import async_setup_services
+type FullyKioskConfigEntry = ConfigEntry[FullyKioskDataUpdateCoordinator]
+
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
@@ -33,13 +35,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: FullyKioskConfigEntry) -> bool:
"""Set up Fully Kiosk Browser from a config entry."""
coordinator = FullyKioskDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
coordinator.async_update_listeners()
@@ -47,10 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: FullyKioskConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/fully_kiosk/binary_sensor.py b/homeassistant/components/fully_kiosk/binary_sensor.py
index 3cf9adea1d5..c039baa0397 100644
--- a/homeassistant/components/fully_kiosk/binary_sensor.py
+++ b/homeassistant/components/fully_kiosk/binary_sensor.py
@@ -7,12 +7,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -38,13 +37,11 @@ SENSORS: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser sensor."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullyBinarySensor(coordinator, description)
diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py
index 94c34b50de1..4b172d45ae2 100644
--- a/homeassistant/components/fully_kiosk/button.py
+++ b/homeassistant/components/fully_kiosk/button.py
@@ -13,12 +13,11 @@ from homeassistant.components.button import (
ButtonEntity,
ButtonEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -68,13 +67,11 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser button entities."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullyButtonEntity(coordinator, description) for description in BUTTONS
diff --git a/homeassistant/components/fully_kiosk/camera.py b/homeassistant/components/fully_kiosk/camera.py
index d55875e094f..7dfbe9e9257 100644
--- a/homeassistant/components/fully_kiosk/camera.py
+++ b/homeassistant/components/fully_kiosk/camera.py
@@ -5,21 +5,22 @@ from __future__ import annotations
from fullykiosk import FullyKioskError
from homeassistant.components.camera import Camera, CameraEntityFeature
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FullyKioskConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the cameras."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities([FullyCameraEntity(coordinator)])
diff --git a/homeassistant/components/fully_kiosk/diagnostics.py b/homeassistant/components/fully_kiosk/diagnostics.py
index 0ff567b0b46..c8364c77753 100644
--- a/homeassistant/components/fully_kiosk/diagnostics.py
+++ b/homeassistant/components/fully_kiosk/diagnostics.py
@@ -5,11 +5,10 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
DEVICE_INFO_TO_REDACT = {
"serial",
@@ -57,10 +56,10 @@ SETTINGS_TO_REDACT = {
async def async_get_device_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry
+ hass: HomeAssistant, entry: FullyKioskConfigEntry, device: dr.DeviceEntry
) -> dict[str, Any]:
"""Return device diagnostics."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
data = coordinator.data
data["settings"] = async_redact_data(data["settings"], SETTINGS_TO_REDACT)
return async_redact_data(data, DEVICE_INFO_TO_REDACT)
diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py
index fbf3481e38b..00318a77ab5 100644
--- a/homeassistant/components/fully_kiosk/image.py
+++ b/homeassistant/components/fully_kiosk/image.py
@@ -9,13 +9,12 @@ from typing import Any
from fullykiosk import FullyKiosk, FullyKioskError
from homeassistant.components.image import ImageEntity, ImageEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -37,10 +36,12 @@ IMAGES: tuple[FullyImageEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FullyKioskConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser image entities."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
FullyImageEntity(coordinator, description) for description in IMAGES
)
diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json
index 4d7d1a2d7da..1fbbb6656a2 100644
--- a/homeassistant/components/fully_kiosk/manifest.json
+++ b/homeassistant/components/fully_kiosk/manifest.json
@@ -12,5 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/fully_kiosk",
"iot_class": "local_polling",
"mqtt": ["fully/deviceInfo/+"],
+ "quality_scale": "bronze",
"requirements": ["python-fullykiosk==0.0.14"]
}
diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py
index ae61a39bb81..24f002a7544 100644
--- a/homeassistant/components/fully_kiosk/media_player.py
+++ b/homeassistant/components/fully_kiosk/media_player.py
@@ -12,23 +12,23 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import AUDIOMANAGER_STREAM_MUSIC, DOMAIN, MEDIA_SUPPORT_FULLYKIOSK
+from . import FullyKioskConfigEntry
+from .const import AUDIOMANAGER_STREAM_MUSIC, MEDIA_SUPPORT_FULLYKIOSK
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser media player entity."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
async_add_entities([FullyMediaPlayer(coordinator)])
diff --git a/homeassistant/components/fully_kiosk/notify.py b/homeassistant/components/fully_kiosk/notify.py
index aa47c178f03..bddc07439b3 100644
--- a/homeassistant/components/fully_kiosk/notify.py
+++ b/homeassistant/components/fully_kiosk/notify.py
@@ -7,12 +7,11 @@ from dataclasses import dataclass
from fullykiosk import FullyKioskError
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -39,10 +38,12 @@ NOTIFIERS: tuple[FullyNotifyEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FullyKioskConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser notify entities."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
FullyNotifyEntity(coordinator, description) for description in NOTIFIERS
)
diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py
index 59c249fd1c2..ef25a69f1ee 100644
--- a/homeassistant/components/fully_kiosk/number.py
+++ b/homeassistant/components/fully_kiosk/number.py
@@ -5,12 +5,11 @@ from __future__ import annotations
from contextlib import suppress
from homeassistant.components.number import NumberEntity, NumberEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -54,11 +53,11 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser number entities."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullyNumberEntity(coordinator, entity)
diff --git a/homeassistant/components/fully_kiosk/quality_scale.yaml b/homeassistant/components/fully_kiosk/quality_scale.yaml
new file mode 100644
index 00000000000..68fa7b9c3f9
--- /dev/null
+++ b/homeassistant/components/fully_kiosk/quality_scale.yaml
@@ -0,0 +1,66 @@
+rules:
+ # Bronze
+ config-flow: done
+ test-before-configure: done
+ unique-config-entry: done
+ config-flow-test-coverage: done
+ runtime-data: done
+ test-before-setup: done
+ appropriate-polling: done
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup: done
+ dependency-transparency: done
+ action-setup: done
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-actions: done
+ brands: done
+
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions: todo
+ reauthentication-flow: todo
+ parallel-updates: todo
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: This integration does not utilize an options flow.
+
+ # Gold
+ entity-translations: todo
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery: done
+ stale-devices:
+ status: exempt
+ comment: Each config entry maps to a single device
+ diagnostics: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ dynamic-devices:
+ status: exempt
+ comment: Each config entry maps to a single device
+ discovery-update-info: done
+ repair-issues: todo
+ docs-use-cases: todo
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-data-update: todo
+ docs-known-limitations: done
+ docs-troubleshooting: todo
+ docs-examples: done
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py
index 48fc8e51425..ed95323547f 100644
--- a/homeassistant/components/fully_kiosk/sensor.py
+++ b/homeassistant/components/fully_kiosk/sensor.py
@@ -12,13 +12,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -114,13 +113,11 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser sensor."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullySensor(coordinator, description)
for description in SENSORS
diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py
index b9369198940..089ae1d4246 100644
--- a/homeassistant/components/fully_kiosk/services.py
+++ b/homeassistant/components/fully_kiosk/services.py
@@ -53,7 +53,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
for config_entry in config_entries:
if config_entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(f"{config_entry.title} is not loaded")
- coordinators.append(hass.data[DOMAIN][config_entry.entry_id])
+ coordinators.append(config_entry.runtime_data)
return coordinators
async def async_load_url(call: ServiceCall) -> None:
diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json
index 9c0049d3e5f..a4b466926f0 100644
--- a/homeassistant/components/fully_kiosk/strings.json
+++ b/homeassistant/components/fully_kiosk/strings.json
@@ -1,10 +1,22 @@
{
+ "common": {
+ "data_description_password": "The Remote Admin Password from the Fully Kiosk Browser app settings.",
+ "data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?",
+ "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates."
+ },
"config": {
"step": {
"discovery_confirm": {
"description": "Do you want to set up {name} ({host})?",
"data": {
- "password": "[%key:common::config_flow::data::password%]"
+ "password": "[%key:common::config_flow::data::password%]",
+ "ssl": "[%key:common::config_flow::data::ssl%]",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "password": "[%key:component::fully_kiosk::common::data_description_password%]",
+ "ssl": "[%key:component::fully_kiosk::common::data_description_ssl%]",
+ "verify_ssl": "[%key:component::fully_kiosk::common::data_description_verify_ssl%]"
}
},
"user": {
@@ -15,7 +27,10 @@
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
- "host": "The hostname or IP address of the device running your Fully Kiosk Browser application."
+ "host": "The hostname or IP address of the device running your Fully Kiosk Browser application.",
+ "password": "[%key:component::fully_kiosk::common::data_description_password%]",
+ "ssl": "[%key:component::fully_kiosk::common::data_description_ssl%]",
+ "verify_ssl": "[%key:component::fully_kiosk::common::data_description_verify_ssl%]"
}
}
},
diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py
index 9d5af87abe9..4adf8e8c924 100644
--- a/homeassistant/components/fully_kiosk/switch.py
+++ b/homeassistant/components/fully_kiosk/switch.py
@@ -9,12 +9,11 @@ from typing import Any
from fullykiosk import FullyKiosk
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
from .entity import FullyKioskEntity
@@ -84,13 +83,11 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FullyKioskConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser switch."""
- coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinator = config_entry.runtime_data
async_add_entities(
FullySwitchEntity(coordinator, description) for description in SWITCHES
diff --git a/homeassistant/components/futurenow/manifest.json b/homeassistant/components/futurenow/manifest.json
index dbe1b2d06fb..32a8761b1db 100644
--- a/homeassistant/components/futurenow/manifest.json
+++ b/homeassistant/components/futurenow/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/futurenow",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pyfnip==0.2"]
}
diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py
index efbb1453456..b29789be87e 100644
--- a/homeassistant/components/fyta/__init__.py
+++ b/homeassistant/components/fyta/__init__.py
@@ -15,6 +15,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.dt import async_get_time_zone
from .const import CONF_EXPIRATION
@@ -39,7 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool
entry.data[CONF_EXPIRATION]
).astimezone(await async_get_time_zone(tz))
- fyta = FytaConnector(username, password, access_token, expiration, tz)
+ fyta = FytaConnector(
+ username, password, access_token, expiration, tz, async_get_clientsession(hass)
+ )
coordinator = FytaCoordinator(hass, fyta)
diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py
index c4aa9bfe589..553960bdcc6 100644
--- a/homeassistant/components/fyta/coordinator.py
+++ b/homeassistant/components/fyta/coordinator.py
@@ -61,7 +61,9 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
try:
data = await self.fyta.update_all_plants()
except (FytaConnectionError, FytaPlantError) as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_error"
+ ) from err
_LOGGER.debug("Data successfully updated")
# data must be assigned before _async_add_remove_devices, as it is uses to set-up possible new devices
@@ -122,9 +124,14 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
try:
credentials = await self.fyta.login()
except FytaConnectionError as ex:
- raise ConfigEntryNotReady from ex
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN, translation_key="config_entry_not_ready"
+ ) from ex
except (FytaAuthentificationError, FytaPasswordError) as ex:
- raise ConfigEntryAuthFailed from ex
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from ex
new_config_entry = {**self.config_entry.data}
new_config_entry[CONF_ACCESS_TOKEN] = credentials.access_token
diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json
index 17fe5199eee..0df9eca2e38 100644
--- a/homeassistant/components/fyta/manifest.json
+++ b/homeassistant/components/fyta/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["fyta_cli"],
- "quality_scale": "platinum",
- "requirements": ["fyta_cli==0.6.10"]
+ "requirements": ["fyta_cli==0.7.0"]
}
diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json
index bacd24555b0..edd65ad228d 100644
--- a/homeassistant/components/fyta/strings.json
+++ b/homeassistant/components/fyta/strings.json
@@ -3,10 +3,14 @@
"step": {
"user": {
"title": "Credentials for FYTA API",
- "description": "Provide username and password to connect to the FYTA server",
+ "description": "Provide email and password to connect to the FYTA server",
"data": {
- "username": "[%key:common::config_flow::data::username%]",
+ "username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "The email address to login to your FYTA account.",
+ "password": "The password to login to your FYTA account."
}
},
"reauth_confirm": {
@@ -14,6 +18,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::fyta::config::step::user::data_description::username%]",
+ "password": "[%key:component::fyta::config::step::user::data_description::password%]"
}
}
},
@@ -93,5 +101,16 @@
"name": "Salinity"
}
}
+ },
+ "exceptions": {
+ "update_error": {
+ "message": "Error while updating data from the API."
+ },
+ "config_entry_not_ready": {
+ "message": "Error while loading the config entry."
+ },
+ "auth_failed": {
+ "message": "Error while logging in to the API."
+ }
}
}
diff --git a/homeassistant/components/garadget/manifest.json b/homeassistant/components/garadget/manifest.json
index c7a30a465d2..bd1920a7c4c 100644
--- a/homeassistant/components/garadget/manifest.json
+++ b/homeassistant/components/garadget/manifest.json
@@ -3,5 +3,6 @@
"name": "Garadget",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/garadget",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py
index 81ec72d9fbf..99d751cfcc8 100644
--- a/homeassistant/components/garages_amsterdam/__init__.py
+++ b/homeassistant/components/garages_amsterdam/__init__.py
@@ -1,62 +1,38 @@
"""The Garages Amsterdam integration."""
-import asyncio
-from datetime import timedelta
-import logging
+from __future__ import annotations
-from odp_amsterdam import ODPAmsterdam, VehicleType
+from odp_amsterdam import ODPAmsterdam
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import aiohttp_client
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN
+from .coordinator import GaragesAmsterdamDataUpdateCoordinator
-PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
+
+type GaragesAmsterdamConfigEntry = ConfigEntry[GaragesAmsterdamDataUpdateCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, entry: GaragesAmsterdamConfigEntry
+) -> bool:
"""Set up Garages Amsterdam from a config entry."""
- await get_coordinator(hass)
+ client = ODPAmsterdam(session=async_get_clientsession(hass))
+ coordinator = GaragesAmsterdamDataUpdateCoordinator(hass, client)
+
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: GaragesAmsterdamConfigEntry
+) -> bool:
"""Unload Garages Amsterdam config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if len(hass.config_entries.async_entries(DOMAIN)) == 1:
- hass.data.pop(DOMAIN)
-
- return unload_ok
-
-
-async def get_coordinator(
- hass: HomeAssistant,
-) -> DataUpdateCoordinator:
- """Get the data update coordinator."""
- if DOMAIN in hass.data:
- return hass.data[DOMAIN]
-
- async def async_get_garages():
- async with asyncio.timeout(10):
- return {
- garage.garage_name: garage
- for garage in await ODPAmsterdam(
- session=aiohttp_client.async_get_clientsession(hass)
- ).all_garages(vehicle=VehicleType.CAR)
- }
-
- coordinator = DataUpdateCoordinator(
- hass,
- logging.getLogger(__name__),
- name=DOMAIN,
- update_method=async_get_garages,
- update_interval=timedelta(minutes=10),
- )
- await coordinator.async_config_entry_first_refresh()
-
- hass.data[DOMAIN] = coordinator
- return coordinator
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py
index 0aebe36baeb..b93b43e1173 100644
--- a/homeassistant/components/garages_amsterdam/binary_sensor.py
+++ b/homeassistant/components/garages_amsterdam/binary_sensor.py
@@ -2,47 +2,77 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from odp_amsterdam import Garage
+
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
+ BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import get_coordinator
+from . import GaragesAmsterdamConfigEntry
+from .coordinator import GaragesAmsterdamDataUpdateCoordinator
from .entity import GaragesAmsterdamEntity
-BINARY_SENSORS = {
- "state",
-}
+
+@dataclass(frozen=True, kw_only=True)
+class GaragesAmsterdamBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Class describing Garages Amsterdam binary sensor entity."""
+
+ is_on: Callable[[Garage], bool]
+
+
+BINARY_SENSORS: tuple[GaragesAmsterdamBinarySensorEntityDescription, ...] = (
+ GaragesAmsterdamBinarySensorEntityDescription(
+ key="state",
+ translation_key="state",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ is_on=lambda garage: garage.state != "ok",
+ ),
+)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: GaragesAmsterdamConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
- coordinator = await get_coordinator(hass)
+ coordinator = entry.runtime_data
async_add_entities(
GaragesAmsterdamBinarySensor(
- coordinator, config_entry.data["garage_name"], info_type
+ coordinator=coordinator,
+ garage_name=entry.data["garage_name"],
+ description=description,
)
- for info_type in BINARY_SENSORS
+ for description in BINARY_SENSORS
)
class GaragesAmsterdamBinarySensor(GaragesAmsterdamEntity, BinarySensorEntity):
"""Binary Sensor representing garages amsterdam data."""
- _attr_device_class = BinarySensorDeviceClass.PROBLEM
- _attr_name = None
+ entity_description: GaragesAmsterdamBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ *,
+ coordinator: GaragesAmsterdamDataUpdateCoordinator,
+ garage_name: str,
+ description: GaragesAmsterdamBinarySensorEntityDescription,
+ ) -> None:
+ """Initialize garages amsterdam binary sensor."""
+ super().__init__(coordinator, garage_name)
+ self.entity_description = description
+ self._attr_unique_id = f"{garage_name}-{description.key}"
@property
def is_on(self) -> bool:
"""If the binary sensor is currently on or off."""
- return (
- getattr(self.coordinator.data[self._garage_name], self._info_type) != "ok"
- )
+ return self.entity_description.is_on(self.coordinator.data[self._garage_name])
diff --git a/homeassistant/components/garages_amsterdam/const.py b/homeassistant/components/garages_amsterdam/const.py
index ae7801a9abd..be5e2216a81 100644
--- a/homeassistant/components/garages_amsterdam/const.py
+++ b/homeassistant/components/garages_amsterdam/const.py
@@ -1,4 +1,13 @@
"""Constants for the Garages Amsterdam integration."""
-DOMAIN = "garages_amsterdam"
-ATTRIBUTION = f'{"Data provided by municipality of Amsterdam"}'
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+from typing import Final
+
+DOMAIN: Final = "garages_amsterdam"
+ATTRIBUTION = "Data provided by municipality of Amsterdam"
+
+LOGGER = logging.getLogger(__package__)
+SCAN_INTERVAL = timedelta(minutes=10)
diff --git a/homeassistant/components/garages_amsterdam/coordinator.py b/homeassistant/components/garages_amsterdam/coordinator.py
new file mode 100644
index 00000000000..3d06aba79e2
--- /dev/null
+++ b/homeassistant/components/garages_amsterdam/coordinator.py
@@ -0,0 +1,34 @@
+"""Coordinator for the Garages Amsterdam integration."""
+
+from __future__ import annotations
+
+from odp_amsterdam import Garage, ODPAmsterdam, VehicleType
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN, LOGGER, SCAN_INTERVAL
+
+
+class GaragesAmsterdamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Garage]]):
+ """Class to manage fetching Garages Amsterdam data from single endpoint."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ client: ODPAmsterdam,
+ ) -> None:
+ """Initialize global Garages Amsterdam data updater."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+ self.client = client
+
+ async def _async_update_data(self) -> dict[str, Garage]:
+ return {
+ garage.garage_name: garage
+ for garage in await self.client.all_garages(vehicle=VehicleType.CAR)
+ }
diff --git a/homeassistant/components/garages_amsterdam/entity.py b/homeassistant/components/garages_amsterdam/entity.py
index 671405235d4..433bc75b962 100644
--- a/homeassistant/components/garages_amsterdam/entity.py
+++ b/homeassistant/components/garages_amsterdam/entity.py
@@ -3,28 +3,26 @@
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN
+from .coordinator import GaragesAmsterdamDataUpdateCoordinator
-class GaragesAmsterdamEntity(CoordinatorEntity):
+class GaragesAmsterdamEntity(CoordinatorEntity[GaragesAmsterdamDataUpdateCoordinator]):
"""Base Entity for garages amsterdam data."""
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__(
- self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str
+ self,
+ coordinator: GaragesAmsterdamDataUpdateCoordinator,
+ garage_name: str,
) -> None:
"""Initialize garages amsterdam entity."""
super().__init__(coordinator)
- self._attr_unique_id = f"{garage_name}-{info_type}"
self._garage_name = garage_name
- self._info_type = info_type
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, garage_name)},
name=garage_name,
diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py
index b6fc950a843..b562fff841a 100644
--- a/homeassistant/components/garages_amsterdam/sensor.py
+++ b/homeassistant/components/garages_amsterdam/sensor.py
@@ -2,49 +2,93 @@
from __future__ import annotations
-from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from odp_amsterdam import Garage
+
+from homeassistant.components.sensor import (
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.typing import StateType
-from . import get_coordinator
+from . import GaragesAmsterdamConfigEntry
+from .coordinator import GaragesAmsterdamDataUpdateCoordinator
from .entity import GaragesAmsterdamEntity
-SENSORS = {
- "free_space_short",
- "free_space_long",
- "short_capacity",
- "long_capacity",
-}
+
+@dataclass(frozen=True, kw_only=True)
+class GaragesAmsterdamSensorEntityDescription(SensorEntityDescription):
+ """Class describing Garages Amsterdam sensor entity."""
+
+ value_fn: Callable[[Garage], StateType]
+
+
+SENSORS: tuple[GaragesAmsterdamSensorEntityDescription, ...] = (
+ GaragesAmsterdamSensorEntityDescription(
+ key="free_space_short",
+ translation_key="free_space_short",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda garage: garage.free_space_short,
+ ),
+ GaragesAmsterdamSensorEntityDescription(
+ key="free_space_long",
+ translation_key="free_space_long",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda garage: garage.free_space_long,
+ ),
+ GaragesAmsterdamSensorEntityDescription(
+ key="short_capacity",
+ translation_key="short_capacity",
+ value_fn=lambda garage: garage.short_capacity,
+ ),
+ GaragesAmsterdamSensorEntityDescription(
+ key="long_capacity",
+ translation_key="long_capacity",
+ value_fn=lambda garage: garage.long_capacity,
+ ),
+)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: GaragesAmsterdamConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
- coordinator = await get_coordinator(hass)
+ coordinator = entry.runtime_data
async_add_entities(
- GaragesAmsterdamSensor(coordinator, config_entry.data["garage_name"], info_type)
- for info_type in SENSORS
- if getattr(coordinator.data[config_entry.data["garage_name"]], info_type) != ""
+ GaragesAmsterdamSensor(
+ coordinator=coordinator,
+ garage_name=entry.data["garage_name"],
+ description=description,
+ )
+ for description in SENSORS
+ if description.value_fn(coordinator.data[entry.data["garage_name"]]) is not None
)
class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity):
"""Sensor representing garages amsterdam data."""
- _attr_native_unit_of_measurement = "cars"
+ entity_description: GaragesAmsterdamSensorEntityDescription
def __init__(
- self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str
+ self,
+ *,
+ coordinator: GaragesAmsterdamDataUpdateCoordinator,
+ garage_name: str,
+ description: GaragesAmsterdamSensorEntityDescription,
) -> None:
"""Initialize garages amsterdam sensor."""
- super().__init__(coordinator, garage_name, info_type)
- self._attr_translation_key = info_type
+ super().__init__(coordinator, garage_name)
+ self.entity_description = description
+ self._attr_unique_id = f"{garage_name}-{description.key}"
@property
def available(self) -> bool:
@@ -54,6 +98,8 @@ class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity):
)
@property
- def native_value(self) -> str:
+ def native_value(self) -> StateType:
"""Return the state of the sensor."""
- return getattr(self.coordinator.data[self._garage_name], self._info_type)
+ return self.entity_description.value_fn(
+ self.coordinator.data[self._garage_name]
+ )
diff --git a/homeassistant/components/garages_amsterdam/strings.json b/homeassistant/components/garages_amsterdam/strings.json
index 89a85f97448..19157afdafb 100644
--- a/homeassistant/components/garages_amsterdam/strings.json
+++ b/homeassistant/components/garages_amsterdam/strings.json
@@ -3,8 +3,13 @@
"config": {
"step": {
"user": {
- "title": "Pick a garage to monitor",
- "data": { "garage_name": "Garage name" }
+ "description": "Select a garage from the list",
+ "data": {
+ "garage_name": "Garage name"
+ },
+ "data_description": {
+ "garage_name": "The name of the garage you want to monitor."
+ }
}
},
"abort": {
@@ -16,16 +21,25 @@
"entity": {
"sensor": {
"free_space_short": {
- "name": "Short parking free space"
+ "name": "Short parking free space",
+ "unit_of_measurement": "cars"
},
"free_space_long": {
- "name": "Long parking free space"
+ "name": "Long parking free space",
+ "unit_of_measurement": "cars"
},
"short_capacity": {
- "name": "Short parking capacity"
+ "name": "Short parking capacity",
+ "unit_of_measurement": "cars"
},
"long_capacity": {
- "name": "Long parking capacity"
+ "name": "Long parking capacity",
+ "unit_of_measurement": "cars"
+ }
+ },
+ "binary_sensor": {
+ "state": {
+ "name": "State"
}
}
}
diff --git a/homeassistant/components/gc100/manifest.json b/homeassistant/components/gc100/manifest.json
index b4af14a323b..687e09f5c89 100644
--- a/homeassistant/components/gc100/manifest.json
+++ b/homeassistant/components/gc100/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/gc100",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["python-gc100==1.0.3a0"]
}
diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json
index fab47e00904..a40dc8cf91b 100644
--- a/homeassistant/components/gdacs/manifest.json
+++ b/homeassistant/components/gdacs/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_georss_gdacs", "aio_georss_client"],
- "quality_scale": "platinum",
"requirements": ["aio-georss-gdacs==0.10"]
}
diff --git a/homeassistant/components/generic_hygrostat/strings.json b/homeassistant/components/generic_hygrostat/strings.json
index 2be3955eff1..7b8d56dbaa5 100644
--- a/homeassistant/components/generic_hygrostat/strings.json
+++ b/homeassistant/components/generic_hygrostat/strings.json
@@ -3,8 +3,8 @@
"config": {
"step": {
"user": {
- "title": "Add generic hygrostat",
- "description": "Create a humidifier entity that control the humidity via a switch and sensor.",
+ "title": "Create generic hygrostat",
+ "description": "Create a humidifier entity that controls the humidity via a switch and sensor.",
"data": {
"device_class": "Device class",
"dry_tolerance": "Dry tolerance",
@@ -17,7 +17,7 @@
"data_description": {
"dry_tolerance": "The minimum amount of difference between the humidity read by the sensor specified in the target sensor option and the target humidity that must change prior to being switched on.",
"humidifier": "Humidifier or dehumidifier switch; must be a toggle device.",
- "min_cycle_duration": "Set a minimum amount of time that the switch specified in the humidifier option must be in its current state prior to being switched either off or on.",
+ "min_cycle_duration": "Set a minimum duration for which the specified switch must remain in its current state before it can be toggled off or on.",
"target_sensor": "Sensor with current humidity.",
"wet_tolerance": "The minimum amount of difference between the humidity read by the sensor specified in the target sensor option and the target humidity that must change prior to being switched off."
}
diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json
index 51549dc844e..fd89bec6349 100644
--- a/homeassistant/components/generic_thermostat/strings.json
+++ b/homeassistant/components/generic_thermostat/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add generic thermostat",
+ "title": "Create generic thermostat",
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
"data": {
"ac_mode": "Cooling mode",
diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json
index 8f4b36657dd..c41796514a5 100644
--- a/homeassistant/components/geo_json_events/manifest.json
+++ b/homeassistant/components/geo_json_events/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_geojson_generic_client"],
- "requirements": ["aio-geojson-generic-client==0.4"]
+ "requirements": ["aio-geojson-generic-client==0.5"]
}
diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json
index 17640e37278..7c089bfa4e9 100644
--- a/homeassistant/components/geo_rss_events/manifest.json
+++ b/homeassistant/components/geo_rss_events/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/geo_rss_events",
"iot_class": "cloud_polling",
"loggers": ["georss_client", "georss_generic_client"],
+ "quality_scale": "legacy",
"requirements": ["georss-generic-client==0.8"]
}
diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json
index 2314dabcf0f..e8f4ee1a8c1 100644
--- a/homeassistant/components/geonetnz_quakes/manifest.json
+++ b/homeassistant/components/geonetnz_quakes/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_geojson_geonetnz_quakes"],
- "quality_scale": "platinum",
"requirements": ["aio-geojson-geonetnz-quakes==0.16"]
}
diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json
index b1eae512688..3d2e719fab6 100644
--- a/homeassistant/components/gios/manifest.json
+++ b/homeassistant/components/gios/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["dacite", "gios"],
- "quality_scale": "platinum",
"requirements": ["gios==5.0.0"]
}
diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py
index 9a2b5ef5ac4..614ebe254c4 100644
--- a/homeassistant/components/github/sensor.py
+++ b/homeassistant/components/github/sensor.py
@@ -37,7 +37,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="discussions_count",
translation_key="discussions_count",
- native_unit_of_measurement="Discussions",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["discussion"]["total"],
@@ -45,7 +44,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="stargazers_count",
translation_key="stargazers_count",
- native_unit_of_measurement="Stars",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["stargazers_count"],
@@ -53,7 +51,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="subscribers_count",
translation_key="subscribers_count",
- native_unit_of_measurement="Watchers",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["watchers"]["total"],
@@ -61,7 +58,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="forks_count",
translation_key="forks_count",
- native_unit_of_measurement="Forks",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["forks_count"],
@@ -69,7 +65,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="issues_count",
translation_key="issues_count",
- native_unit_of_measurement="Issues",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["issue"]["total"],
@@ -77,7 +72,6 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
GitHubSensorEntityDescription(
key="pulls_count",
translation_key="pulls_count",
- native_unit_of_measurement="Pull Requests",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data["pull_request"]["total"],
diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json
index 38b796e2fd2..bcda47d72fb 100644
--- a/homeassistant/components/github/strings.json
+++ b/homeassistant/components/github/strings.json
@@ -19,22 +19,28 @@
"entity": {
"sensor": {
"discussions_count": {
- "name": "Discussions"
+ "name": "Discussions",
+ "unit_of_measurement": "discussions"
},
"stargazers_count": {
- "name": "Stars"
+ "name": "Stars",
+ "unit_of_measurement": "stars"
},
"subscribers_count": {
- "name": "Watchers"
+ "name": "Watchers",
+ "unit_of_measurement": "watchers"
},
"forks_count": {
- "name": "Forks"
+ "name": "Forks",
+ "unit_of_measurement": "forks"
},
"issues_count": {
- "name": "Issues"
+ "name": "Issues",
+ "unit_of_measurement": "issues"
},
"pulls_count": {
- "name": "Pull requests"
+ "name": "Pull requests",
+ "unit_of_measurement": "pull requests"
},
"latest_commit": {
"name": "Latest commit"
diff --git a/homeassistant/components/gitlab_ci/manifest.json b/homeassistant/components/gitlab_ci/manifest.json
index 36fb356dae4..58fd827ff31 100644
--- a/homeassistant/components/gitlab_ci/manifest.json
+++ b/homeassistant/components/gitlab_ci/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/gitlab_ci",
"iot_class": "cloud_polling",
"loggers": ["gitlab"],
+ "quality_scale": "legacy",
"requirements": ["python-gitlab==1.6.0"]
}
diff --git a/homeassistant/components/gitter/manifest.json b/homeassistant/components/gitter/manifest.json
index 009746a06c6..c578f7c2242 100644
--- a/homeassistant/components/gitter/manifest.json
+++ b/homeassistant/components/gitter/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/gitter",
"iot_class": "cloud_polling",
"loggers": ["gitterpy"],
+ "quality_scale": "legacy",
"requirements": ["gitterpy==0.1.7"]
}
diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py
index 0ddd8a86979..9d09e63606e 100644
--- a/homeassistant/components/glances/__init__.py
+++ b/homeassistant/components/glances/__init__.py
@@ -28,9 +28,7 @@ from homeassistant.exceptions import (
HomeAssistantError,
)
from homeassistant.helpers.httpx_client import get_async_client
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from .const import DOMAIN
from .coordinator import GlancesDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
@@ -71,7 +69,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: GlancesConfigEntry) ->
async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances:
"""Return the api from glances_api."""
httpx_client = get_async_client(hass, verify_ssl=entry_data[CONF_VERIFY_SSL])
- for version in (4, 3, 2):
+ for version in (4, 3):
api = Glances(
host=entry_data[CONF_HOST],
port=entry_data[CONF_PORT],
@@ -86,19 +84,9 @@ async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances:
except GlancesApiNoDataAvailable as err:
_LOGGER.debug("Failed to connect to Glances API v%s: %s", version, err)
continue
- if version == 2:
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_version",
- breaks_in_ha_version="2024.8.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_version",
- )
_LOGGER.debug("Connected to Glances API v%s", version)
return api
- raise ServerVersionMismatch("Could not connect to Glances API version 2, 3 or 4")
+ raise ServerVersionMismatch("Could not connect to Glances API version 3 or 4")
class ServerVersionMismatch(HomeAssistantError):
diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json
index 11735601ce9..92aa1b47e31 100644
--- a/homeassistant/components/glances/strings.json
+++ b/homeassistant/components/glances/strings.json
@@ -123,11 +123,5 @@
"name": "{sensor_label} TX"
}
}
- },
- "issues": {
- "deprecated_version": {
- "title": "Glances servers with version 2 is deprecated",
- "description": "Glances servers with version 2 is deprecated and will not be supported in future versions of HA. It is recommended to update your server to Glances version 3 then reload the integration."
- }
}
}
diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json
index 201b7168847..bedee99f930 100644
--- a/homeassistant/components/go2rtc/manifest.json
+++ b/homeassistant/components/go2rtc/manifest.json
@@ -7,6 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["go2rtc-client==0.1.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json
index f1bfc7de876..a9fcbf26d36 100644
--- a/homeassistant/components/goalzero/manifest.json
+++ b/homeassistant/components/goalzero/manifest.json
@@ -15,6 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["goalzero"],
- "quality_scale": "silver",
"requirements": ["goalzero==0.2.2"]
}
diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json
index 9c3a3e03dfd..85469a464b3 100644
--- a/homeassistant/components/google_assistant_sdk/manifest.json
+++ b/homeassistant/components/google_assistant_sdk/manifest.json
@@ -7,7 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["gassist-text==0.0.11"],
"single_config_entry": true
}
diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json
index f390b1f83e9..7b687b7da6f 100644
--- a/homeassistant/components/google_generative_ai_conversation/manifest.json
+++ b/homeassistant/components/google_generative_ai_conversation/manifest.json
@@ -8,6 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["google-generativeai==0.8.2"]
}
diff --git a/homeassistant/components/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json
index d7364e834a3..8311f75b732 100644
--- a/homeassistant/components/google_maps/manifest.json
+++ b/homeassistant/components/google_maps/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/google_maps",
"iot_class": "cloud_polling",
"loggers": ["locationsharinglib"],
+ "quality_scale": "legacy",
"requirements": ["locationsharinglib==5.0.1"]
}
diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json
index aa13f1808c4..9ea747898b2 100644
--- a/homeassistant/components/google_pubsub/manifest.json
+++ b/homeassistant/components/google_pubsub/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/google_pubsub",
"iot_class": "cloud_push",
+ "quality_scale": "legacy",
"requirements": ["google-cloud-pubsub==2.23.0"]
}
diff --git a/homeassistant/components/google_wifi/manifest.json b/homeassistant/components/google_wifi/manifest.json
index 200684b2e1c..a71558a7d6f 100644
--- a/homeassistant/components/google_wifi/manifest.json
+++ b/homeassistant/components/google_wifi/manifest.json
@@ -3,5 +3,6 @@
"name": "Google Wifi",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/google_wifi",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/graphite/manifest.json b/homeassistant/components/graphite/manifest.json
index da249a22829..cd50a5933f1 100644
--- a/homeassistant/components/graphite/manifest.json
+++ b/homeassistant/components/graphite/manifest.json
@@ -3,5 +3,6 @@
"name": "Graphite",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/graphite",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json
index fcf4d004d26..15c4c2123e3 100644
--- a/homeassistant/components/greeneye_monitor/manifest.json
+++ b/homeassistant/components/greeneye_monitor/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/greeneye_monitor",
"iot_class": "local_push",
"loggers": ["greeneye"],
+ "quality_scale": "legacy",
"requirements": ["greeneye_monitor==3.0.3"]
}
diff --git a/homeassistant/components/greenwave/manifest.json b/homeassistant/components/greenwave/manifest.json
index 5cb3255192f..422d3bc512e 100644
--- a/homeassistant/components/greenwave/manifest.json
+++ b/homeassistant/components/greenwave/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/greenwave",
"iot_class": "local_polling",
"loggers": ["greenwavereality"],
+ "quality_scale": "legacy",
"requirements": ["greenwavereality==0.5.1"]
}
diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json
index dbb6fb01f7b..cf694af0d98 100644
--- a/homeassistant/components/group/strings.json
+++ b/homeassistant/components/group/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Group",
+ "title": "Create Group",
"description": "Groups allow you to create a new entity that represents multiple entities of the same type.",
"menu_options": {
"binary_sensor": "Binary sensor group",
@@ -283,20 +283,20 @@
},
"issues": {
"uoms_not_matching_device_class": {
- "title": "Unit of measurements are not correct",
- "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible and can't be converted with the device class `{device_class}` of sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities and reload the group sensor to fix this issue."
+ "title": "Units of measurement are not correct",
+ "description": "Units of measurement `{uoms}` of input sensors `{source_entities}` are not compatible and can't be converted with the device class `{device_class}` of sensor group `{entity_id}`.\n\nPlease correct the unit of measurement on the source entities and reload the group sensor to fix this issue."
},
"uoms_not_matching_no_device_class": {
- "title": "Unit of measurements is not correct",
- "description": "Unit of measurements `{uoms}` of input sensors `{source_entities}` are not compatible when not using a device class on sensor group `{entity_id}`.\n\nPlease correct the unit of measurements on the source entities or set a proper device class on the sensor group and reload the group sensor to fix this issue."
+ "title": "Units of measurement are not correct",
+ "description": "Units of measurement `{uoms}` of input sensors `{source_entities}` are not compatible when not using a device class on sensor group `{entity_id}`.\n\nPlease correct the unit of measurement on the source entities or set a proper device class on the sensor group and reload the group sensor to fix this issue."
},
"device_classes_not_matching": {
- "title": "Device classes is not correct",
- "description": "Device classes `{device_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the device classes on the source entities and reload the group sensor to fix this issue."
+ "title": "Device classes are not correct",
+ "description": "Device classes `{device_classes}` on source entities `{source_entities}` need to be identical for sensor group `{entity_id}`.\n\nPlease correct the device classes on the source entities and reload the group sensor to fix this issue."
},
"state_classes_not_matching": {
- "title": "State classes is not correct",
- "description": "State classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue."
+ "title": "State classes are not correct",
+ "description": "State classes `{state_classes}` on source entities `{source_entities}` need to be identical for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue."
}
}
}
diff --git a/homeassistant/components/gstreamer/manifest.json b/homeassistant/components/gstreamer/manifest.json
index 95df94ef834..3ea9010a9d7 100644
--- a/homeassistant/components/gstreamer/manifest.json
+++ b/homeassistant/components/gstreamer/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/gstreamer",
"iot_class": "local_push",
"loggers": ["gsp"],
+ "quality_scale": "legacy",
"requirements": ["gstreamer-player==1.1.2"]
}
diff --git a/homeassistant/components/gtfs/manifest.json b/homeassistant/components/gtfs/manifest.json
index 73a5998ea92..3bf41a1c763 100644
--- a/homeassistant/components/gtfs/manifest.json
+++ b/homeassistant/components/gtfs/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/gtfs",
"iot_class": "local_polling",
"loggers": ["pygtfs"],
+ "quality_scale": "legacy",
"requirements": ["pygtfs==0.1.9"]
}
diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py
index 8b41fb8c987..2b9a4199133 100644
--- a/homeassistant/components/habitica/button.py
+++ b/homeassistant/components/habitica/button.py
@@ -25,13 +25,15 @@ from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
class HabiticaButtonEntityDescription(ButtonEntityDescription):
"""Describes Habitica button entity."""
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
- available_fn: Callable[[HabiticaData], bool] | None = None
+ available_fn: Callable[[HabiticaData], bool]
class_needed: str | None = None
entity_picture: str | None = None
@@ -341,11 +343,10 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
@property
def available(self) -> bool:
"""Is entity available."""
- if not super().available:
- return False
- if self.entity_description.available_fn:
- return self.entity_description.available_fn(self.coordinator.data)
- return True
+
+ return super().available and self.entity_description.available_fn(
+ self.coordinator.data
+ )
@property
def entity_picture(self) -> str | None:
diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py
index 5a0470c3440..ff483b71fd8 100644
--- a/homeassistant/components/habitica/calendar.py
+++ b/homeassistant/components/habitica/calendar.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from abc import abstractmethod
from datetime import date, datetime, timedelta
from enum import StrEnum
@@ -28,6 +29,8 @@ class HabiticaCalendar(StrEnum):
DAILIES = "dailys"
TODOS = "todos"
+ TODO_REMINDERS = "todo_reminders"
+ DAILY_REMINDERS = "daily_reminders"
async def async_setup_entry(
@@ -42,6 +45,8 @@ async def async_setup_entry(
[
HabiticaTodosCalendarEntity(coordinator),
HabiticaDailiesCalendarEntity(coordinator),
+ HabiticaTodoRemindersCalendarEntity(coordinator),
+ HabiticaDailyRemindersCalendarEntity(coordinator),
]
)
@@ -56,6 +61,43 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
"""Initialize calendar entity."""
super().__init__(coordinator, self.entity_description)
+ @abstractmethod
+ def get_events(
+ self, start_date: datetime, end_date: datetime | None = None
+ ) -> list[CalendarEvent]:
+ """Return events."""
+
+ @property
+ def event(self) -> CalendarEvent | None:
+ """Return the current or next upcoming event."""
+
+ return next(iter(self.get_events(dt_util.now())), None)
+
+ async def async_get_events(
+ self, hass: HomeAssistant, start_date: datetime, end_date: datetime
+ ) -> list[CalendarEvent]:
+ """Return calendar events within a datetime range."""
+
+ return self.get_events(start_date, end_date)
+
+ @property
+ def start_of_today(self) -> datetime:
+ """Habitica daystart."""
+ return dt_util.start_of_local_day(
+ datetime.fromisoformat(self.coordinator.data.user["lastCron"])
+ )
+
+ def get_recurrence_dates(
+ self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None
+ ) -> list[datetime]:
+ """Calculate recurrence dates based on start_date and end_date."""
+ if end_date:
+ return recurrences.between(
+ start_date, end_date - timedelta(days=1), inc=True
+ )
+ # if no end_date is given, return only the next recurrence
+ return [recurrences.after(start_date, inc=True)]
+
class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
"""Habitica todos calendar entity."""
@@ -65,7 +107,7 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
translation_key=HabiticaCalendar.TODOS,
)
- def dated_todos(
+ def get_events(
self, start_date: datetime, end_date: datetime | None = None
) -> list[CalendarEvent]:
"""Get all dated todos."""
@@ -108,18 +150,6 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
),
)
- @property
- def event(self) -> CalendarEvent | None:
- """Return the current or next upcoming event."""
-
- return next(iter(self.dated_todos(dt_util.now())), None)
-
- async def async_get_events(
- self, hass: HomeAssistant, start_date: datetime, end_date: datetime
- ) -> list[CalendarEvent]:
- """Return calendar events within a datetime range."""
- return self.dated_todos(start_date, end_date)
-
class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
"""Habitica dailies calendar entity."""
@@ -129,13 +159,6 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
translation_key=HabiticaCalendar.DAILIES,
)
- @property
- def today(self) -> datetime:
- """Habitica daystart."""
- return dt_util.start_of_local_day(
- datetime.fromisoformat(self.coordinator.data.user["lastCron"])
- )
-
def end_date(self, recurrence: datetime, end: datetime | None = None) -> date:
"""Calculate the end date for a yesterdaily.
@@ -148,29 +171,20 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
if end:
return recurrence.date() + timedelta(days=1)
return (
- dt_util.start_of_local_day() if recurrence == self.today else recurrence
+ dt_util.start_of_local_day()
+ if recurrence == self.start_of_today
+ else recurrence
).date() + timedelta(days=1)
- def get_recurrence_dates(
- self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None
- ) -> list[datetime]:
- """Calculate recurrence dates based on start_date and end_date."""
- if end_date:
- return recurrences.between(
- start_date, end_date - timedelta(days=1), inc=True
- )
- # if no end_date is given, return only the next recurrence
- return [recurrences.after(self.today, inc=True)]
-
- def due_dailies(
+ def get_events(
self, start_date: datetime, end_date: datetime | None = None
) -> list[CalendarEvent]:
"""Get dailies and recurrences for a given period or the next upcoming."""
# we only have dailies for today and future recurrences
- if end_date and end_date < self.today:
+ if end_date and end_date < self.start_of_today:
return []
- start_date = max(start_date, self.today)
+ start_date = max(start_date, self.start_of_today)
events = []
for task in self.coordinator.data.tasks:
@@ -183,10 +197,12 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
recurrences, start_date, end_date
)
for recurrence in recurrence_dates:
- is_future_event = recurrence > self.today
- is_current_event = recurrence <= self.today and not task["completed"]
+ is_future_event = recurrence > self.start_of_today
+ is_current_event = (
+ recurrence <= self.start_of_today and not task["completed"]
+ )
- if not (is_future_event or is_current_event):
+ if not is_future_event and not is_current_event:
continue
events.append(
@@ -210,18 +226,144 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
- return next(iter(self.due_dailies(self.today)), None)
-
- async def async_get_events(
- self, hass: HomeAssistant, start_date: datetime, end_date: datetime
- ) -> list[CalendarEvent]:
- """Return calendar events within a datetime range."""
-
- return self.due_dailies(start_date, end_date)
+ return next(iter(self.get_events(self.start_of_today)), None)
@property
def extra_state_attributes(self) -> dict[str, bool | None] | None:
"""Return entity specific state attributes."""
return {
- "yesterdaily": self.event.start < self.today.date() if self.event else None
+ "yesterdaily": self.event.start < self.start_of_today.date()
+ if self.event
+ else None
}
+
+
+class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
+ """Habitica to-do reminders calendar entity."""
+
+ entity_description = CalendarEntityDescription(
+ key=HabiticaCalendar.TODO_REMINDERS,
+ translation_key=HabiticaCalendar.TODO_REMINDERS,
+ )
+
+ def get_events(
+ self, start_date: datetime, end_date: datetime | None = None
+ ) -> list[CalendarEvent]:
+ """Reminders for todos."""
+
+ events = []
+
+ for task in self.coordinator.data.tasks:
+ if task["type"] != HabiticaTaskType.TODO or task["completed"]:
+ continue
+
+ for reminder in task.get("reminders", []):
+ # reminders are returned by the API in local time but with wrong
+ # timezone (UTC) and arbitrary added seconds/microseconds. When
+ # creating reminders in Habitica only hours and minutes can be defined.
+ start = datetime.fromisoformat(reminder["time"]).replace(
+ tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0
+ )
+ end = start + timedelta(hours=1)
+
+ if end < start_date:
+ # Event ends before date range
+ continue
+
+ if end_date and start > end_date:
+ # Event starts after date range
+ continue
+
+ events.append(
+ CalendarEvent(
+ start=start,
+ end=end,
+ summary=task["text"],
+ description=task["notes"],
+ uid=f"{task["id"]}_{reminder["id"]}",
+ )
+ )
+
+ return sorted(
+ events,
+ key=lambda event: event.start,
+ )
+
+
+class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
+ """Habitica daily reminders calendar entity."""
+
+ entity_description = CalendarEntityDescription(
+ key=HabiticaCalendar.DAILY_REMINDERS,
+ translation_key=HabiticaCalendar.DAILY_REMINDERS,
+ )
+
+ def start(self, reminder_time: str, reminder_date: date) -> datetime:
+ """Generate reminder times for dailies.
+
+ Reminders for dailies have a datetime but the date part is arbitrary,
+ only the time part is evaluated. The dates for the reminders are the
+ dailies' due dates.
+ """
+ return datetime.combine(
+ reminder_date,
+ datetime.fromisoformat(reminder_time)
+ .replace(
+ second=0,
+ microsecond=0,
+ )
+ .time(),
+ tzinfo=dt_util.DEFAULT_TIME_ZONE,
+ )
+
+ def get_events(
+ self, start_date: datetime, end_date: datetime | None = None
+ ) -> list[CalendarEvent]:
+ """Reminders for dailies."""
+
+ events = []
+ if end_date and end_date < self.start_of_today:
+ return []
+ start_date = max(start_date, self.start_of_today)
+
+ for task in self.coordinator.data.tasks:
+ if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
+ continue
+
+ recurrences = build_rrule(task)
+ recurrences_start = self.start_of_today
+
+ recurrence_dates = self.get_recurrence_dates(
+ recurrences, recurrences_start, end_date
+ )
+ for recurrence in recurrence_dates:
+ is_future_event = recurrence > self.start_of_today
+ is_current_event = (
+ recurrence <= self.start_of_today and not task["completed"]
+ )
+
+ if not is_future_event and not is_current_event:
+ continue
+
+ for reminder in task.get("reminders", []):
+ start = self.start(reminder["time"], recurrence)
+ end = start + timedelta(hours=1)
+
+ if end < start_date:
+ # Event ends before date range
+ continue
+
+ events.append(
+ CalendarEvent(
+ start=start,
+ end=end,
+ summary=task["text"],
+ description=task["notes"],
+ uid=f"{task["id"]}_{reminder["id"]}",
+ )
+ )
+
+ return sorted(
+ events,
+ key=lambda event: event.start,
+ )
diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py
index 88f3d1b803c..d168a5f57b4 100644
--- a/homeassistant/components/habitica/config_flow.py
+++ b/homeassistant/components/habitica/config_flow.py
@@ -25,7 +25,15 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
-from .const import CONF_API_USER, DEFAULT_URL, DOMAIN
+from .const import (
+ CONF_API_USER,
+ DEFAULT_URL,
+ DOMAIN,
+ FORGOT_PASSWORD_URL,
+ HABITICANS_URL,
+ SIGN_UP_URL,
+ SITE_DATA_URL,
+)
STEP_ADVANCED_DATA_SCHEMA = vol.Schema(
{
@@ -69,6 +77,10 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_menu(
step_id="user",
menu_options=["login", "advanced"],
+ description_placeholders={
+ "signup": SIGN_UP_URL,
+ "habiticans": HABITICANS_URL,
+ },
)
async def async_step_login(
@@ -125,6 +137,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=STEP_LOGIN_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
+ description_placeholders={"forgot_password": FORGOT_PASSWORD_URL},
)
async def async_step_advanced(
@@ -175,4 +188,8 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=STEP_ADVANCED_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
+ description_placeholders={
+ "site_data": SITE_DATA_URL,
+ "default_url": DEFAULT_URL,
+ },
)
diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py
index 1fcc4b36053..42d64ca7d3f 100644
--- a/homeassistant/components/habitica/const.py
+++ b/homeassistant/components/habitica/const.py
@@ -6,6 +6,11 @@ CONF_API_USER = "api_user"
DEFAULT_URL = "https://habitica.com"
ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/"
+SITE_DATA_URL = "https://habitica.com/user/settings/siteData"
+FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password"
+SIGN_UP_URL = "https://habitica.com/register"
+HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png"
+
DOMAIN = "habitica"
# service constants
@@ -20,8 +25,6 @@ ATTR_DATA = "data"
MANUFACTURER = "HabitRPG, Inc."
NAME = "Habitica"
-UNIT_TASKS = "tasks"
-
ATTR_CONFIG_ENTRY = "config_entry"
ATTR_SKILL = "skill"
ATTR_TASK = "task"
diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py
new file mode 100644
index 00000000000..bca79946503
--- /dev/null
+++ b/homeassistant/components/habitica/diagnostics.py
@@ -0,0 +1,27 @@
+"""Diagnostics platform for Habitica integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.const import CONF_URL
+from homeassistant.core import HomeAssistant
+
+from .const import CONF_API_USER
+from .types import HabiticaConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: HabiticaConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ habitica_data = await config_entry.runtime_data.api.user.anonymized.get()
+
+ return {
+ "config_entry_data": {
+ CONF_URL: config_entry.data[CONF_URL],
+ CONF_API_USER: config_entry.data[CONF_API_USER],
+ },
+ "habitica_data": habitica_data,
+ }
diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json
index ca0ae604f14..d4ca5dba10d 100644
--- a/homeassistant/components/habitica/icons.json
+++ b/homeassistant/components/habitica/icons.json
@@ -64,6 +64,12 @@
},
"dailys": {
"default": "mdi:calendar-multiple"
+ },
+ "todo_reminders": {
+ "default": "mdi:reminder"
+ },
+ "daily_reminders": {
+ "default": "mdi:reminder"
}
},
"sensor": {
diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json
index 8e3396d32cf..a01697c3945 100644
--- a/homeassistant/components/habitica/manifest.json
+++ b/homeassistant/components/habitica/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "habitica",
"name": "Habitica",
- "codeowners": ["@ASMfreaK", "@leikoilja", "@tr4nt0r"],
+ "codeowners": ["@tr4nt0r"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py
index d6943fcae56..bead15d109b 100644
--- a/homeassistant/components/habitica/sensor.py
+++ b/homeassistant/components/habitica/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.helpers.issue_registry import (
)
from homeassistant.helpers.typing import StateType
-from .const import ASSETS_URL, DOMAIN, UNIT_TASKS
+from .const import ASSETS_URL, DOMAIN
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
from .util import entity_used_in, get_attribute_points, get_attributes_total
@@ -84,40 +84,34 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH,
translation_key=HabitipySensorEntity.HEALTH,
- native_unit_of_measurement="HP",
suggested_display_precision=0,
value_fn=lambda user, _: user.get("stats", {}).get("hp"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.HEALTH_MAX,
translation_key=HabitipySensorEntity.HEALTH_MAX,
- native_unit_of_measurement="HP",
entity_registry_enabled_default=False,
value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA,
translation_key=HabitipySensorEntity.MANA,
- native_unit_of_measurement="MP",
suggested_display_precision=0,
value_fn=lambda user, _: user.get("stats", {}).get("mp"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.MANA_MAX,
translation_key=HabitipySensorEntity.MANA_MAX,
- native_unit_of_measurement="MP",
value_fn=lambda user, _: user.get("stats", {}).get("maxMP"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE,
translation_key=HabitipySensorEntity.EXPERIENCE,
- native_unit_of_measurement="XP",
value_fn=lambda user, _: user.get("stats", {}).get("exp"),
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.EXPERIENCE_MAX,
translation_key=HabitipySensorEntity.EXPERIENCE_MAX,
- native_unit_of_measurement="XP",
value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"),
),
HabitipySensorEntityDescription(
@@ -128,7 +122,6 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
HabitipySensorEntityDescription(
key=HabitipySensorEntity.GOLD,
translation_key=HabitipySensorEntity.GOLD,
- native_unit_of_measurement="GP",
suggested_display_precision=2,
value_fn=lambda user, _: user.get("stats", {}).get("gp"),
),
@@ -144,7 +137,6 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
translation_key=HabitipySensorEntity.GEMS,
value_fn=lambda user, _: user.get("balance", 0) * 4,
suggested_display_precision=0,
- native_unit_of_measurement="gems",
entity_picture="shop_gem.png",
),
HabitipySensorEntityDescription(
@@ -158,6 +150,7 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
),
suggested_display_precision=0,
native_unit_of_measurement="⧖",
+ entity_picture="notif_subscriber_reward.png",
),
HabitipySensorEntityDescription(
key=HabitipySensorEntity.STRENGTH,
@@ -228,20 +221,17 @@ TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = (
HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.HABITS,
translation_key=HabitipySensorEntity.HABITS,
- native_unit_of_measurement=UNIT_TASKS,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "habit"],
),
HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.DAILIES,
translation_key=HabitipySensorEntity.DAILIES,
- native_unit_of_measurement=UNIT_TASKS,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "daily"],
entity_registry_enabled_default=False,
),
HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.TODOS,
translation_key=HabitipySensorEntity.TODOS,
- native_unit_of_measurement=UNIT_TASKS,
value_fn=lambda tasks: [
r for r in tasks if r.get("type") == "todo" and not r.get("completed")
],
@@ -250,7 +240,6 @@ TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = (
HabitipyTaskSensorEntityDescription(
key=HabitipySensorEntity.REWARDS,
translation_key=HabitipySensorEntity.REWARDS,
- native_unit_of_measurement=UNIT_TASKS,
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "reward"],
),
)
diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json
index d32e4a048c7..f1b956fe17e 100644
--- a/homeassistant/components/habitica/strings.json
+++ b/homeassistant/components/habitica/strings.json
@@ -2,7 +2,11 @@
"common": {
"todos": "To-Do's",
"dailies": "Dailies",
- "config_entry_name": "Select character"
+ "config_entry_name": "Select character",
+ "unit_tasks": "tasks",
+ "unit_health_points": "HP",
+ "unit_mana_points": "MP",
+ "unit_experience_points": "XP"
},
"config": {
"abort": {
@@ -15,26 +19,39 @@
},
"step": {
"user": {
+ "title": "Habitica - Gamify your life",
"menu_options": {
"login": "Login to Habitica",
"advanced": "Login to other instances"
},
- "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks."
+ "description": " Connect your Habitica account to keep track of your adventurer's stats, progress, and manage your to-dos and daily tasks.\n\n[Don't have a Habitica account? Sign up here.]({signup})"
},
"login": {
+ "title": "[%key:component::habitica::config::step::user::menu_options::login%]",
"data": {
"username": "Email or username (case-sensitive)",
"password": "[%key:common::config_flow::data::password%]"
- }
+ },
+ "data_description": {
+ "username": "Email or username (case-sensitive) to connect Home Assistant to your Habitica account",
+ "password": "Password for the account to connect Home Assistant to Habitica"
+ },
+ "description": "Enter your login details to start using Habitica with Home Assistant\n\n[Forgot your password?]({forgot_password})"
},
"advanced": {
+ "title": "[%key:component::habitica::config::step::user::menu_options::advanced%]",
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_user": "User ID",
"api_key": "API Token",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
- "description": "You can retrieve your `User ID` and `API Token` from **Settings -> Site Data** on Habitica or the instance you want to connect to"
+ "data_description": {
+ "url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
+ "api_user": "User ID of your Habitica account",
+ "api_key": "API Token of the Habitica account"
+ },
+ "description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to"
}
}
},
@@ -109,6 +126,12 @@
}
}
}
+ },
+ "todo_reminders": {
+ "name": "To-do reminders"
+ },
+ "daily_reminders": {
+ "name": "Daily reminders"
}
},
"sensor": {
@@ -116,31 +139,39 @@
"name": "Display name"
},
"health": {
- "name": "Health"
+ "name": "Health",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
},
"health_max": {
- "name": "Max. health"
+ "name": "Max. health",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
},
"mana": {
- "name": "Mana"
+ "name": "Mana",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_mana_points%]"
},
"mana_max": {
- "name": "Max. mana"
+ "name": "Max. mana",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_mana_points%]"
},
"experience": {
- "name": "Experience"
+ "name": "Experience",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_experience_points%]"
},
"experience_max": {
- "name": "Next level"
+ "name": "Next level",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_experience_points%]"
},
"level": {
"name": "Level"
},
"gold": {
- "name": "Gold"
+ "name": "Gold",
+ "unit_of_measurement": "GP"
},
"gems": {
- "name": "Gems"
+ "name": "Gems",
+ "unit_of_measurement": "gems"
},
"trinkets": {
"name": "Mystic hourglasses"
@@ -155,16 +186,20 @@
}
},
"todos": {
- "name": "[%key:component::habitica::common::todos%]"
+ "name": "[%key:component::habitica::common::todos%]",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
},
"dailys": {
- "name": "[%key:component::habitica::common::dailies%]"
+ "name": "[%key:component::habitica::common::dailies%]",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
},
"habits": {
- "name": "Habits"
+ "name": "Habits",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
},
"rewards": {
- "name": "Rewards"
+ "name": "Rewards",
+ "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]"
},
"strength": {
"name": "Strength",
diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py
index 6682911e892..de0cc533050 100644
--- a/homeassistant/components/habitica/switch.py
+++ b/homeassistant/components/habitica/switch.py
@@ -19,6 +19,8 @@ from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator
from .entity import HabiticaBase
from .types import HabiticaConfigEntry
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
class HabiticaSwitchEntityDescription(SwitchEntityDescription):
diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py
index 0fff7b66605..0ca5f723c45 100644
--- a/homeassistant/components/habitica/todo.py
+++ b/homeassistant/components/habitica/todo.py
@@ -27,6 +27,8 @@ from .entity import HabiticaBase
from .types import HabiticaConfigEntry, HabiticaTaskType
from .util import next_due_date
+PARALLEL_UPDATES = 1
+
class HabiticaTodoList(StrEnum):
"""Habitica Entities."""
diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py
index 03acb08baf9..b2b4430c490 100644
--- a/homeassistant/components/habitica/util.py
+++ b/homeassistant/components/habitica/util.py
@@ -174,7 +174,7 @@ def get_attribute_points(
)
return {
- "level": min(round(user["stats"]["lvl"] / 2), 50),
+ "level": min(floor(user["stats"]["lvl"] / 2), 50),
"equipment": equipment,
"class": class_bonus,
"allocated": user["stats"][attribute],
diff --git a/homeassistant/components/harman_kardon_avr/manifest.json b/homeassistant/components/harman_kardon_avr/manifest.json
index c28504cf2d8..e56aeebafe4 100644
--- a/homeassistant/components/harman_kardon_avr/manifest.json
+++ b/homeassistant/components/harman_kardon_avr/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/harman_kardon_avr",
"iot_class": "local_polling",
"loggers": ["hkavr"],
+ "quality_scale": "legacy",
"requirements": ["hkavr==0.0.5"]
}
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index 306c9d43d72..a2a9d8ff028 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -119,7 +119,6 @@ from .handler import ( # noqa: F401
async_create_backup,
async_get_green_settings,
async_get_yellow_settings,
- async_reboot_host,
async_set_green_settings,
async_set_yellow_settings,
async_update_diagnostics,
diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py
index 58f2aa8c144..254c392462c 100644
--- a/homeassistant/components/hassio/handler.py
+++ b/homeassistant/components/hassio/handler.py
@@ -133,16 +133,6 @@ async def async_set_yellow_settings(
)
-@api_data
-async def async_reboot_host(hass: HomeAssistant) -> dict:
- """Reboot the host.
-
- Returns an empty dict.
- """
- hassio: HassIO = hass.data[DOMAIN]
- return await hassio.send_command("/host/reboot", method="post", timeout=60)
-
-
class HassIO:
"""Small API wrapper for Hass.io."""
diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json
index 09ed45bd5bc..556a5a13f95 100644
--- a/homeassistant/components/hassio/strings.json
+++ b/homeassistant/components/hassio/strings.json
@@ -274,60 +274,60 @@
"fields": {
"addon": {
"name": "Add-on",
- "description": "The add-on slug."
+ "description": "The add-on to start."
}
}
},
"addon_restart": {
- "name": "Restart add-on.",
+ "name": "Restart add-on",
"description": "Restarts an add-on.",
"fields": {
"addon": {
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
- "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
+ "description": "The add-on to restart."
}
}
},
"addon_stdin": {
- "name": "Write data to add-on stdin.",
- "description": "Writes data to add-on stdin.",
+ "name": "Write data to add-on stdin",
+ "description": "Writes data to the add-on's standard input.",
"fields": {
"addon": {
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
- "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
+ "description": "The add-on to write to."
}
}
},
"addon_stop": {
- "name": "Stop add-on.",
+ "name": "Stop add-on",
"description": "Stops an add-on.",
"fields": {
"addon": {
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
- "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
+ "description": "The add-on to stop."
}
}
},
"addon_update": {
- "name": "Update add-on.",
+ "name": "Update add-on",
"description": "Updates an add-on. This action should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on.",
"fields": {
"addon": {
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
- "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
+ "description": "The add-on to update."
}
}
},
"host_reboot": {
- "name": "Reboot the host system.",
+ "name": "Reboot the host system",
"description": "Reboots the host system."
},
"host_shutdown": {
- "name": "Power off the host system.",
+ "name": "Power off the host system",
"description": "Powers off the host system."
},
"backup_full": {
- "name": "Create a full backup.",
+ "name": "Create a full backup",
"description": "Creates a full backup.",
"fields": {
"name": {
@@ -353,7 +353,7 @@
}
},
"backup_partial": {
- "name": "Create a partial backup.",
+ "name": "Create a partial backup",
"description": "Creates a partial backup.",
"fields": {
"homeassistant": {
@@ -391,7 +391,7 @@
}
},
"restore_full": {
- "name": "Restore from full backup.",
+ "name": "Restore from full backup",
"description": "Restores from full backup.",
"fields": {
"slug": {
@@ -405,7 +405,7 @@
}
},
"restore_partial": {
- "name": "Restore from partial backup.",
+ "name": "Restore from partial backup",
"description": "Restores from a partial backup.",
"fields": {
"slug": {
diff --git a/homeassistant/components/haveibeenpwned/manifest.json b/homeassistant/components/haveibeenpwned/manifest.json
index 2451871f0c8..eb9ad4c356f 100644
--- a/homeassistant/components/haveibeenpwned/manifest.json
+++ b/homeassistant/components/haveibeenpwned/manifest.json
@@ -3,5 +3,6 @@
"name": "HaveIBeenPwned",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/haveibeenpwned",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/hddtemp/manifest.json b/homeassistant/components/hddtemp/manifest.json
index 8dd2676596c..4fe23233870 100644
--- a/homeassistant/components/hddtemp/manifest.json
+++ b/homeassistant/components/hddtemp/manifest.json
@@ -3,5 +3,6 @@
"name": "hddtemp",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/hddtemp",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/hdmi_cec/entity.py b/homeassistant/components/hdmi_cec/entity.py
index b1bcb2720d4..bdb796e6a36 100644
--- a/homeassistant/components/hdmi_cec/entity.py
+++ b/homeassistant/components/hdmi_cec/entity.py
@@ -36,7 +36,7 @@ class CecEntity(Entity):
"""Initialize the device."""
self._device = device
self._logical_address = logical
- self.entity_id = "%s.%d" % (DOMAIN, self._logical_address)
+ self.entity_id = f"{DOMAIN}.{self._logical_address}"
self._set_attr_name()
self._attr_icon = ICONS_BY_TYPE.get(self._device.type, ICON_UNKNOWN)
diff --git a/homeassistant/components/hdmi_cec/manifest.json b/homeassistant/components/hdmi_cec/manifest.json
index fbd9e2304d9..2e37e908e16 100644
--- a/homeassistant/components/hdmi_cec/manifest.json
+++ b/homeassistant/components/hdmi_cec/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/hdmi_cec",
"iot_class": "local_push",
"loggers": ["pycec"],
+ "quality_scale": "legacy",
"requirements": ["pyCEC==0.5.2"]
}
diff --git a/homeassistant/components/heatmiser/manifest.json b/homeassistant/components/heatmiser/manifest.json
index f3f33f79b04..c7ffeb237ed 100644
--- a/homeassistant/components/heatmiser/manifest.json
+++ b/homeassistant/components/heatmiser/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/heatmiser",
"iot_class": "local_polling",
"loggers": ["heatmiserV3"],
+ "quality_scale": "legacy",
"requirements": ["heatmiserV3==2.0.3"]
}
diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py
index 1573ff3f23e..de56e541501 100644
--- a/homeassistant/components/heos/__init__.py
+++ b/homeassistant/components/heos/__init__.py
@@ -3,10 +3,11 @@
from __future__ import annotations
import asyncio
+from dataclasses import dataclass
from datetime import timedelta
import logging
-from pyheos import Heos, HeosError, const as heos_const
+from pyheos import Heos, HeosError, HeosPlayer, const as heos_const
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
@@ -27,10 +28,6 @@ from .config_flow import format_title
from .const import (
COMMAND_RETRY_ATTEMPTS,
COMMAND_RETRY_DELAY,
- DATA_CONTROLLER_MANAGER,
- DATA_ENTITY_ID_MAP,
- DATA_GROUP_MANAGER,
- DATA_SOURCE_MANAGER,
DOMAIN,
SIGNAL_HEOS_PLAYER_ADDED,
SIGNAL_HEOS_UPDATED,
@@ -51,6 +48,19 @@ MIN_UPDATE_SOURCES = timedelta(seconds=1)
_LOGGER = logging.getLogger(__name__)
+@dataclass
+class HeosRuntimeData:
+ """Runtime data and coordinators for HEOS config entries."""
+
+ controller_manager: ControllerManager
+ group_manager: GroupManager
+ source_manager: SourceManager
+ players: dict[int, HeosPlayer]
+
+
+type HeosConfigEntry = ConfigEntry[HeosRuntimeData]
+
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HEOS component."""
if DOMAIN not in config:
@@ -75,7 +85,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
"""Initialize config entry which represents the HEOS controller."""
# For backwards compat
if entry.unique_id is None:
@@ -128,17 +138,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
source_manager = SourceManager(favorites, inputs)
source_manager.connect_update(hass, controller)
- group_manager = GroupManager(hass, controller)
+ group_manager = GroupManager(hass, controller, players)
- hass.data[DOMAIN] = {
- DATA_CONTROLLER_MANAGER: controller_manager,
- DATA_GROUP_MANAGER: group_manager,
- DATA_SOURCE_MANAGER: source_manager,
- Platform.MEDIA_PLAYER: players,
- # Maps player_id to entity_id. Populated by the individual
- # HeosMediaPlayer entities.
- DATA_ENTITY_ID_MAP: {},
- }
+ entry.runtime_data = HeosRuntimeData(
+ controller_manager, group_manager, source_manager, players
+ )
services.register(hass, controller)
group_manager.connect_update()
@@ -149,11 +153,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
"""Unload a config entry."""
- controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER]
- await controller_manager.disconnect()
- hass.data.pop(DOMAIN)
+ await entry.runtime_data.controller_manager.disconnect()
services.remove(hass)
@@ -246,21 +248,25 @@ class ControllerManager:
class GroupManager:
"""Class that manages HEOS groups."""
- def __init__(self, hass, controller):
+ def __init__(
+ self, hass: HomeAssistant, controller: Heos, players: dict[int, HeosPlayer]
+ ) -> None:
"""Init group manager."""
self._hass = hass
- self._group_membership = {}
+ self._group_membership: dict[str, str] = {}
self._disconnect_player_added = None
self._initialized = False
self.controller = controller
+ self.players = players
+ self.entity_id_map: dict[int, str] = {}
def _get_entity_id_to_player_id_map(self) -> dict:
"""Return mapping of all HeosMediaPlayer entity_ids to player_ids."""
- return {v: k for k, v in self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP].items()}
+ return {v: k for k, v in self.entity_id_map.items()}
- async def async_get_group_membership(self):
+ async def async_get_group_membership(self) -> dict[str, list[str]]:
"""Return all group members for each player as entity_ids."""
- group_info_by_entity_id = {
+ group_info_by_entity_id: dict[str, list[str]] = {
player_entity_id: []
for player_entity_id in self._get_entity_id_to_player_id_map()
}
@@ -271,7 +277,7 @@ class GroupManager:
_LOGGER.error("Unable to get HEOS group info: %s", err)
return group_info_by_entity_id
- player_id_to_entity_id_map = self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP]
+ player_id_to_entity_id_map = self.entity_id_map
for group in groups.values():
leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id)
member_entity_ids = [
@@ -282,9 +288,9 @@ class GroupManager:
# Make sure the group leader is always the first element
group_info = [leader_entity_id, *member_entity_ids]
if leader_entity_id:
- group_info_by_entity_id[leader_entity_id] = group_info
+ group_info_by_entity_id[leader_entity_id] = group_info # type: ignore[assignment]
for member_entity_id in member_entity_ids:
- group_info_by_entity_id[member_entity_id] = group_info
+ group_info_by_entity_id[member_entity_id] = group_info # type: ignore[assignment]
return group_info_by_entity_id
@@ -358,13 +364,9 @@ class GroupManager:
# When adding a new HEOS player we need to update the groups.
async def _async_handle_player_added():
- # Avoid calling async_update_groups when `DATA_ENTITY_ID_MAP` has not been
+ # Avoid calling async_update_groups when the entity_id map has not been
# fully populated yet. This may only happen during early startup.
- if (
- len(self._hass.data[DOMAIN][Platform.MEDIA_PLAYER])
- <= len(self._hass.data[DOMAIN][DATA_ENTITY_ID_MAP])
- and not self._initialized
- ):
+ if len(self.players) <= len(self.entity_id_map) and not self._initialized:
self._initialized = True
await self.async_update_groups(SIGNAL_HEOS_PLAYER_ADDED)
diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py
index 636751d150b..827a0c53fbf 100644
--- a/homeassistant/components/heos/const.py
+++ b/homeassistant/components/heos/const.py
@@ -4,10 +4,6 @@ ATTR_PASSWORD = "password"
ATTR_USERNAME = "username"
COMMAND_RETRY_ATTEMPTS = 2
COMMAND_RETRY_DELAY = 1
-DATA_CONTROLLER_MANAGER = "controller"
-DATA_ENTITY_ID_MAP = "entity_id_map"
-DATA_GROUP_MANAGER = "group_manager"
-DATA_SOURCE_MANAGER = "source_manager"
DATA_DISCOVERED_HOSTS = "heos_discovered_hosts"
DOMAIN = "heos"
SERVICE_SIGN_IN = "sign_in"
diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py
index 0f9f7facd33..5255d369c2f 100644
--- a/homeassistant/components/heos/media_player.py
+++ b/homeassistant/components/heos/media_player.py
@@ -13,7 +13,6 @@ from pyheos import HeosError, const as heos_const
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
- DOMAIN as MEDIA_PLAYER_DOMAIN,
BrowseMedia,
MediaPlayerEnqueue,
MediaPlayerEntity,
@@ -22,7 +21,6 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
@@ -32,14 +30,8 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.dt import utcnow
-from .const import (
- DATA_ENTITY_ID_MAP,
- DATA_GROUP_MANAGER,
- DATA_SOURCE_MANAGER,
- DOMAIN as HEOS_DOMAIN,
- SIGNAL_HEOS_PLAYER_ADDED,
- SIGNAL_HEOS_UPDATED,
-)
+from . import GroupManager, HeosConfigEntry, SourceManager
+from .const import DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_PLAYER_ADDED, SIGNAL_HEOS_UPDATED
BASE_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.VOLUME_MUTE
@@ -80,11 +72,16 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Add media players for a config entry."""
- players = hass.data[HEOS_DOMAIN][MEDIA_PLAYER_DOMAIN]
- devices = [HeosMediaPlayer(player) for player in players.values()]
+ players = entry.runtime_data.players
+ devices = [
+ HeosMediaPlayer(
+ player, entry.runtime_data.source_manager, entry.runtime_data.group_manager
+ )
+ for player in players.values()
+ ]
async_add_entities(devices, True)
@@ -120,13 +117,15 @@ class HeosMediaPlayer(MediaPlayerEntity):
_attr_has_entity_name = True
_attr_name = None
- def __init__(self, player):
+ def __init__(
+ self, player, source_manager: SourceManager, group_manager: GroupManager
+ ) -> None:
"""Initialize."""
self._media_position_updated_at = None
self._player = player
- self._signals = []
- self._source_manager = None
- self._group_manager = None
+ self._signals: list = []
+ self._source_manager = source_manager
+ self._group_manager = group_manager
self._attr_unique_id = str(player.player_id)
self._attr_device_info = DeviceInfo(
identifiers={(HEOS_DOMAIN, player.player_id)},
@@ -161,9 +160,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated)
)
# Register this player's entity_id so it can be resolved by the group manager
- self.hass.data[HEOS_DOMAIN][DATA_ENTITY_ID_MAP][self._player.player_id] = (
- self.entity_id
- )
+ self._group_manager.entity_id_map[self._player.player_id] = self.entity_id
async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED)
@log_command_error("clear playlist")
@@ -294,12 +291,6 @@ class HeosMediaPlayer(MediaPlayerEntity):
ior, current_support, BASE_SUPPORTED_FEATURES
)
- if self._group_manager is None:
- self._group_manager = self.hass.data[HEOS_DOMAIN][DATA_GROUP_MANAGER]
-
- if self._source_manager is None:
- self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER]
-
@log_command_error("unjoin_player")
async def async_unjoin_player(self) -> None:
"""Remove this player from any group."""
diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json
index e37e149ccda..a0832732105 100644
--- a/homeassistant/components/hikvision/manifest.json
+++ b/homeassistant/components/hikvision/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/hikvision",
"iot_class": "local_push",
"loggers": ["pyhik"],
+ "quality_scale": "legacy",
"requirements": ["pyHik==0.3.2"]
}
diff --git a/homeassistant/components/hikvisioncam/manifest.json b/homeassistant/components/hikvisioncam/manifest.json
index 28f677512b7..badb38a52d5 100644
--- a/homeassistant/components/hikvisioncam/manifest.json
+++ b/homeassistant/components/hikvisioncam/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/hikvisioncam",
"iot_class": "local_polling",
"loggers": ["hikvision"],
+ "quality_scale": "legacy",
"requirements": ["hikvision==0.4"]
}
diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py
index 365be06fd2d..7241e1fac9a 100644
--- a/homeassistant/components/history/__init__.py
+++ b/homeassistant/components/history/__init__.py
@@ -22,7 +22,7 @@ import homeassistant.util.dt as dt_util
from . import websocket_api
from .const import DOMAIN
-from .helpers import entities_may_have_state_changes_after, has_recorder_run_after
+from .helpers import entities_may_have_state_changes_after, has_states_before
CONF_ORDER = "use_include_order"
@@ -107,7 +107,10 @@ class HistoryPeriodView(HomeAssistantView):
no_attributes = "no_attributes" in request.query
if (
- (end_time and not has_recorder_run_after(hass, end_time))
+ # has_states_before will return True if there are states older than
+ # end_time. If it's false, we know there are no states in the
+ # database up until end_time.
+ (end_time and not has_states_before(hass, end_time))
or not include_start_time_state
and entity_ids
and not entities_may_have_state_changes_after(
diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py
index bd477e7e4ed..2010b7373ff 100644
--- a/homeassistant/components/history/helpers.py
+++ b/homeassistant/components/history/helpers.py
@@ -6,7 +6,6 @@ from collections.abc import Iterable
from datetime import datetime as dt
from homeassistant.components.recorder import get_instance
-from homeassistant.components.recorder.models import process_timestamp
from homeassistant.core import HomeAssistant
@@ -26,8 +25,10 @@ def entities_may_have_state_changes_after(
return False
-def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool:
- """Check if the recorder has any runs after a specific time."""
- return run_time >= process_timestamp(
- get_instance(hass).recorder_runs_manager.first.start
- )
+def has_states_before(hass: HomeAssistant, run_time: dt) -> bool:
+ """Check if the recorder has states as old or older than run_time.
+
+ Returns True if there may be such states.
+ """
+ oldest_ts = get_instance(hass).states_manager.oldest_ts
+ return oldest_ts is not None and run_time.timestamp() >= oldest_ts
diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py
index c85d975c3c9..35f8ed5f1ac 100644
--- a/homeassistant/components/history/websocket_api.py
+++ b/homeassistant/components/history/websocket_api.py
@@ -39,7 +39,7 @@ from homeassistant.util.async_ import create_eager_task
import homeassistant.util.dt as dt_util
from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES
-from .helpers import entities_may_have_state_changes_after, has_recorder_run_after
+from .helpers import entities_may_have_state_changes_after, has_states_before
_LOGGER = logging.getLogger(__name__)
@@ -142,7 +142,10 @@ async def ws_get_history_during_period(
no_attributes = msg["no_attributes"]
if (
- (end_time and not has_recorder_run_after(hass, end_time))
+ # has_states_before will return True if there are states older than
+ # end_time. If it's false, we know there are no states in the
+ # database up until end_time.
+ (end_time and not has_states_before(hass, end_time))
or not include_start_time_state
and entity_ids
and not entities_may_have_state_changes_after(
diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py
index 544e1772b01..f9b79d74cb4 100644
--- a/homeassistant/components/history_stats/data.py
+++ b/homeassistant/components/history_stats/data.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from dataclasses import dataclass
import datetime
+import logging
+import math
from homeassistant.components.recorder import get_instance, history
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State
@@ -14,6 +16,8 @@ from .helpers import async_calculate_period, floored_timestamp
MIN_TIME_UTC = datetime.datetime.min.replace(tzinfo=dt_util.UTC)
+_LOGGER = logging.getLogger(__name__)
+
@dataclass
class HistoryStatsState:
@@ -176,26 +180,32 @@ class HistoryStats:
# state_changes_during_period is called with include_start_time_state=True
# which is the default and always provides the state at the start
# of the period
- previous_state_matches = (
- self._history_current_period
- and self._history_current_period[0].state in self._entity_states
- )
- last_state_change_timestamp = start_timestamp
+ previous_state_matches = False
+ last_state_change_timestamp = 0.0
elapsed = 0.0
- match_count = 1 if previous_state_matches else 0
+ match_count = 0
# Make calculations
for history_state in self._history_current_period:
current_state_matches = history_state.state in self._entity_states
state_change_timestamp = history_state.last_changed
+ if math.floor(state_change_timestamp) > now_timestamp:
+ # Shouldn't count states that are in the future
+ _LOGGER.debug(
+ "Skipping future timestamp %s (now %s)",
+ state_change_timestamp,
+ now_timestamp,
+ )
+ continue
+
if previous_state_matches:
elapsed += state_change_timestamp - last_state_change_timestamp
elif current_state_matches:
match_count += 1
previous_state_matches = current_state_matches
- last_state_change_timestamp = state_change_timestamp
+ last_state_change_timestamp = max(start_timestamp, state_change_timestamp)
# Count time elapsed between last history state and end of measure
if previous_state_matches:
diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json
index 8961d66118d..aff2ac50bef 100644
--- a/homeassistant/components/history_stats/strings.json
+++ b/homeassistant/components/history_stats/strings.json
@@ -9,7 +9,7 @@
},
"step": {
"user": {
- "description": "Add a history stats sensor",
+ "description": "Create a history stats sensor",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"entity_id": "Entity",
diff --git a/homeassistant/components/hitron_coda/manifest.json b/homeassistant/components/hitron_coda/manifest.json
index 2f18707c95e..15f71b62cf3 100644
--- a/homeassistant/components/hitron_coda/manifest.json
+++ b/homeassistant/components/hitron_coda/manifest.json
@@ -3,5 +3,6 @@
"name": "Rogers Hitron CODA",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/hitron_coda",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json
index 8c64f492d42..a3c0a4514d3 100644
--- a/homeassistant/components/holiday/manifest.json
+++ b/homeassistant/components/holiday/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
- "requirements": ["holidays==0.60", "babel==2.15.0"]
+ "requirements": ["holidays==0.61", "babel==2.15.0"]
}
diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py
index c60515eb57f..6e89fd2c9f7 100644
--- a/homeassistant/components/home_connect/__init__.py
+++ b/homeassistant/components/home_connect/__init__.py
@@ -4,7 +4,8 @@ from __future__ import annotations
from datetime import timedelta
import logging
-from typing import Any
+import re
+from typing import Any, cast
from requests import HTTPError
import voluptuous as vol
@@ -40,8 +41,12 @@ from .const import (
SERVICE_START_PROGRAM,
)
+type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth]
+
_LOGGER = logging.getLogger(__name__)
+RE_CAMEL_CASE = re.compile(r"(? api.HomeConnectDevice:
- """Return a Home Connect appliance instance given an device_id."""
- for hc_api in hass.data[DOMAIN].values():
- for device in hc_api.devices:
- if device.device_id == device_id:
- return device.appliance
- raise ValueError(f"Appliance for device id {device_id} not found")
+def _get_appliance(
+ hass: HomeAssistant,
+ device_id: str | None = None,
+ device_entry: dr.DeviceEntry | None = None,
+ entry: HomeConnectConfigEntry | None = None,
+) -> api.HomeConnectAppliance:
+ """Return a Home Connect appliance instance given a device id or a device entry."""
+ if device_id is not None and device_entry is None:
+ device_registry = dr.async_get(hass)
+ device_entry = device_registry.async_get(device_id)
+ assert device_entry, "Either a device id or a device entry must be provided"
+
+ ha_id = next(
+ (
+ identifier[1]
+ for identifier in device_entry.identifiers
+ if identifier[0] == DOMAIN
+ ),
+ None,
+ )
+ assert ha_id
+
+ def find_appliance(
+ entry: HomeConnectConfigEntry,
+ ) -> api.HomeConnectAppliance | None:
+ for device in entry.runtime_data.devices:
+ appliance = device.appliance
+ if appliance.haId == ha_id:
+ return appliance
+ return None
+
+ if entry is None:
+ for entry_id in device_entry.config_entries:
+ entry = hass.config_entries.async_get_entry(entry_id)
+ assert entry
+ if entry.domain == DOMAIN:
+ entry = cast(HomeConnectConfigEntry, entry)
+ if (appliance := find_appliance(entry)) is not None:
+ return appliance
+ elif (appliance := find_appliance(entry)) is not None:
+ return appliance
+ raise ValueError(f"Appliance for device id {device_entry.id} not found")
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Home Connect component."""
- hass.data[DOMAIN] = {}
async def _async_service_program(call, method):
"""Execute calls to services taking a program."""
@@ -121,14 +159,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
options.append(option)
- appliance = _get_appliance_by_device_id(hass, device_id)
+ appliance = _get_appliance(hass, device_id)
await hass.async_add_executor_job(getattr(appliance, method), program, options)
async def _async_service_command(call, command):
"""Execute calls to services executing a command."""
device_id = call.data[ATTR_DEVICE_ID]
- appliance = _get_appliance_by_device_id(hass, device_id)
+ appliance = _get_appliance(hass, device_id)
await hass.async_add_executor_job(appliance.execute_command, command)
async def _async_service_key_value(call, method):
@@ -138,7 +176,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
unit = call.data.get(ATTR_UNIT)
device_id = call.data[ATTR_DEVICE_ID]
- appliance = _get_appliance_by_device_id(hass, device_id)
+ appliance = _get_appliance(hass, device_id)
if unit is not None:
await hass.async_add_executor_job(
getattr(appliance, method),
@@ -224,7 +262,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) -> bool:
"""Set up Home Connect from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
@@ -232,9 +270,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
- hc_api = api.ConfigEntryAuth(hass, entry, implementation)
-
- hass.data[DOMAIN][entry.entry_id] = hc_api
+ entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation)
await update_all_devices(hass, entry)
@@ -243,45 +279,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: HomeConnectConfigEntry
+) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@Throttle(SCAN_INTERVAL)
-async def update_all_devices(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_all_devices(
+ hass: HomeAssistant, entry: HomeConnectConfigEntry
+) -> None:
"""Update all the devices."""
- data = hass.data[DOMAIN]
- hc_api = data[entry.entry_id]
+ hc_api = entry.runtime_data
- device_registry = dr.async_get(hass)
try:
await hass.async_add_executor_job(hc_api.get_devices)
for device in hc_api.devices:
- device_entry = device_registry.async_get_or_create(
- config_entry_id=entry.entry_id,
- identifiers={(DOMAIN, device.appliance.haId)},
- name=device.appliance.name,
- manufacturer=device.appliance.brand,
- model=device.appliance.vib,
- )
-
- device.device_id = device_entry.id
-
await hass.async_add_executor_job(device.initialize)
except HTTPError as err:
_LOGGER.warning("Cannot update devices: %s", err.response.status_code)
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, entry: HomeConnectConfigEntry
+) -> bool:
"""Migrate old entry."""
- _LOGGER.debug("Migrating from version %s", config_entry.version)
+ _LOGGER.debug("Migrating from version %s", entry.version)
- if config_entry.version == 1 and config_entry.minor_version == 1:
+ if entry.version == 1 and entry.minor_version == 1:
@callback
def update_unique_id(
@@ -297,20 +323,31 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
}
return None
- await async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
+ await async_migrate_entries(hass, entry.entry_id, update_unique_id)
- hass.config_entries.async_update_entry(config_entry, minor_version=2)
+ hass.config_entries.async_update_entry(entry, minor_version=2)
- _LOGGER.debug("Migration to version %s successful", config_entry.version)
+ _LOGGER.debug("Migration to version %s successful", entry.version)
return True
def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]:
"""Return a dict from a Home Connect error."""
- return (
- err.args[0]
+ return {
+ "description": cast(dict[str, Any], err.args[0]).get("description", "?")
if len(err.args) > 0 and isinstance(err.args[0], dict)
- else {"description": err.args[0]}
+ else err.args[0]
if len(err.args) > 0 and isinstance(err.args[0], str)
- else {}
- )
+ else "?",
+ }
+
+
+def bsh_key_to_translation_key(bsh_key: str) -> str:
+ """Convert a BSH key to a translation key format.
+
+ This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`,
+ and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`.
+ """
+ return "_".join(
+ RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".")
+ ).lower()
diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py
index 232b581d58b..f9775918f16 100644
--- a/homeassistant/components/home_connect/binary_sensor.py
+++ b/homeassistant/components/home_connect/binary_sensor.py
@@ -10,7 +10,6 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.components.script import scripts_with_entity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -20,6 +19,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
+from . import HomeConnectConfigEntry
from .api import HomeConnectDevice
from .const import (
ATTR_VALUE,
@@ -118,15 +118,14 @@ BINARY_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect binary sensor."""
def get_entities() -> list[BinarySensorEntity]:
entities: list[BinarySensorEntity] = []
- hc_api = hass.data[DOMAIN][config_entry.entry_id]
- for device in hc_api.devices:
+ for device in entry.runtime_data.devices:
entities.extend(
HomeConnectBinarySensor(device, description)
for description in BINARY_SENSORS
diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py
index e49a56b9b97..e9f32b0e772 100644
--- a/homeassistant/components/home_connect/const.py
+++ b/homeassistant/components/home_connect/const.py
@@ -5,10 +5,23 @@ DOMAIN = "home_connect"
OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize"
OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token"
+APPLIANCES_WITH_PROGRAMS = (
+ "CleaningRobot",
+ "CoffeeMaker",
+ "Dishwasher",
+ "Dryer",
+ "Hood",
+ "Oven",
+ "WarmingDrawer",
+ "Washer",
+ "WasherDryer",
+)
+
BSH_POWER_STATE = "BSH.Common.Setting.PowerState"
BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On"
BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off"
BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby"
+BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram"
BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram"
BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive"
BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed"
diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py
index ae484ae1d72..d2505853d23 100644
--- a/homeassistant/components/home_connect/diagnostics.py
+++ b/homeassistant/components/home_connect/diagnostics.py
@@ -4,17 +4,43 @@ from __future__ import annotations
from typing import Any
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
+from homeconnect.api import HomeConnectAppliance
-from .const import DOMAIN
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntry
+
+from . import HomeConnectConfigEntry, _get_appliance
+from .api import HomeConnectDevice
+
+
+def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]:
+ return {
+ "status": appliance.status,
+ "programs": appliance.get_programs_available(),
+ }
+
+
+def _generate_entry_diagnostics(
+ devices: list[HomeConnectDevice],
+) -> dict[str, dict[str, Any]]:
+ return {
+ device.appliance.haId: _generate_appliance_diagnostics(device.appliance)
+ for device in devices
+ }
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- return {
- device.appliance.haId: device.appliance.status
- for device in hass.data[DOMAIN][config_entry.entry_id].devices
- }
+ return await hass.async_add_executor_job(
+ _generate_entry_diagnostics, entry.runtime_data.devices
+ )
+
+
+async def async_get_device_diagnostics(
+ hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a device."""
+ appliance = _get_appliance(hass, device_entry=device, entry=entry)
+ return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance)
diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py
index 873e7d24f93..e33017cd51f 100644
--- a/homeassistant/components/home_connect/light.py
+++ b/homeassistant/components/home_connect/light.py
@@ -15,14 +15,13 @@ from homeassistant.components.light import (
LightEntity,
LightEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.color as color_util
-from . import get_dict_from_home_connect_error
-from .api import ConfigEntryAuth, HomeConnectDevice
+from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
+from .api import HomeConnectDevice
from .const import (
ATTR_VALUE,
BSH_AMBIENT_LIGHT_BRIGHTNESS,
@@ -88,18 +87,17 @@ LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect light."""
def get_entities() -> list[LightEntity]:
"""Get a list of entities."""
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
return [
HomeConnectLight(device, description)
for description in LIGHTS
- for device in hc_api.devices
+ for device in entry.runtime_data.devices
if description.key in device.appliance.status
]
@@ -152,7 +150,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self.device.appliance.set_setting, self.bsh_key, True
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_on_light",
translation_placeholders={
@@ -171,7 +169,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self._enable_custom_color_value_key,
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="select_light_custom_color",
translation_placeholders={
@@ -189,7 +187,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
f"#{hex_val}",
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_light_color",
translation_placeholders={
@@ -221,7 +219,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
f"#{hex_val}",
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_light_color",
translation_placeholders={
@@ -246,7 +244,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self.device.appliance.set_setting, self._brightness_key, brightness
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_light_brightness",
translation_placeholders={
@@ -265,7 +263,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self.device.appliance.set_setting, self.bsh_key, False
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_off_light",
translation_placeholders={
diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py
index ad853df77d0..fc53939b9d8 100644
--- a/homeassistant/components/home_connect/number.py
+++ b/homeassistant/components/home_connect/number.py
@@ -11,13 +11,11 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import get_dict_from_home_connect_error
-from .api import ConfigEntryAuth
+from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .const import (
ATTR_CONSTRAINTS,
ATTR_STEPSIZE,
@@ -84,18 +82,17 @@ NUMBERS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect number."""
def get_entities() -> list[HomeConnectNumberEntity]:
"""Get a list of entities."""
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
return [
HomeConnectNumberEntity(device, description)
for description in NUMBERS
- for device in hc_api.devices
+ for device in entry.runtime_data.devices
if description.key in device.appliance.status
]
@@ -120,7 +117,7 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
value,
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_setting",
translation_placeholders={
diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py
new file mode 100644
index 00000000000..46b2bda24d6
--- /dev/null
+++ b/homeassistant/components/home_connect/select.py
@@ -0,0 +1,300 @@
+"""Provides a select platform for Home Connect."""
+
+import contextlib
+import logging
+
+from homeconnect.api import HomeConnectError
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import (
+ HomeConnectConfigEntry,
+ bsh_key_to_translation_key,
+ get_dict_from_home_connect_error,
+)
+from .api import HomeConnectDevice
+from .const import (
+ APPLIANCES_WITH_PROGRAMS,
+ ATTR_VALUE,
+ BSH_ACTIVE_PROGRAM,
+ BSH_SELECTED_PROGRAM,
+ DOMAIN,
+)
+from .entity import HomeConnectEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+TRANSLATION_KEYS_PROGRAMS_MAP = {
+ bsh_key_to_translation_key(program): program
+ for program in (
+ "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll",
+ "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap",
+ "ConsumerProducts.CleaningRobot.Program.Basic.GoHome",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye",
+ "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye",
+ "ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater",
+ "Dishcare.Dishwasher.Program.PreRinse",
+ "Dishcare.Dishwasher.Program.Auto1",
+ "Dishcare.Dishwasher.Program.Auto2",
+ "Dishcare.Dishwasher.Program.Auto3",
+ "Dishcare.Dishwasher.Program.Eco50",
+ "Dishcare.Dishwasher.Program.Quick45",
+ "Dishcare.Dishwasher.Program.Intensiv70",
+ "Dishcare.Dishwasher.Program.Normal65",
+ "Dishcare.Dishwasher.Program.Glas40",
+ "Dishcare.Dishwasher.Program.GlassCare",
+ "Dishcare.Dishwasher.Program.NightWash",
+ "Dishcare.Dishwasher.Program.Quick65",
+ "Dishcare.Dishwasher.Program.Normal45",
+ "Dishcare.Dishwasher.Program.Intensiv45",
+ "Dishcare.Dishwasher.Program.AutoHalfLoad",
+ "Dishcare.Dishwasher.Program.IntensivPower",
+ "Dishcare.Dishwasher.Program.MagicDaily",
+ "Dishcare.Dishwasher.Program.Super60",
+ "Dishcare.Dishwasher.Program.Kurz60",
+ "Dishcare.Dishwasher.Program.ExpressSparkle65",
+ "Dishcare.Dishwasher.Program.MachineCare",
+ "Dishcare.Dishwasher.Program.SteamFresh",
+ "Dishcare.Dishwasher.Program.MaximumCleaning",
+ "Dishcare.Dishwasher.Program.MixedLoad",
+ "LaundryCare.Dryer.Program.Cotton",
+ "LaundryCare.Dryer.Program.Synthetic",
+ "LaundryCare.Dryer.Program.Mix",
+ "LaundryCare.Dryer.Program.Blankets",
+ "LaundryCare.Dryer.Program.BusinessShirts",
+ "LaundryCare.Dryer.Program.DownFeathers",
+ "LaundryCare.Dryer.Program.Hygiene",
+ "LaundryCare.Dryer.Program.Jeans",
+ "LaundryCare.Dryer.Program.Outdoor",
+ "LaundryCare.Dryer.Program.SyntheticRefresh",
+ "LaundryCare.Dryer.Program.Towels",
+ "LaundryCare.Dryer.Program.Delicates",
+ "LaundryCare.Dryer.Program.Super40",
+ "LaundryCare.Dryer.Program.Shirts15",
+ "LaundryCare.Dryer.Program.Pillow",
+ "LaundryCare.Dryer.Program.AntiShrink",
+ "LaundryCare.Dryer.Program.MyTime.MyDryingTime",
+ "LaundryCare.Dryer.Program.TimeCold",
+ "LaundryCare.Dryer.Program.TimeWarm",
+ "LaundryCare.Dryer.Program.InBasket",
+ "LaundryCare.Dryer.Program.TimeColdFix.TimeCold20",
+ "LaundryCare.Dryer.Program.TimeColdFix.TimeCold30",
+ "LaundryCare.Dryer.Program.TimeColdFix.TimeCold60",
+ "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30",
+ "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40",
+ "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60",
+ "LaundryCare.Dryer.Program.Dessous",
+ "Cooking.Common.Program.Hood.Automatic",
+ "Cooking.Common.Program.Hood.Venting",
+ "Cooking.Common.Program.Hood.DelayedShutOff",
+ "Cooking.Oven.Program.HeatingMode.PreHeating",
+ "Cooking.Oven.Program.HeatingMode.HotAir",
+ "Cooking.Oven.Program.HeatingMode.HotAirEco",
+ "Cooking.Oven.Program.HeatingMode.HotAirGrilling",
+ "Cooking.Oven.Program.HeatingMode.TopBottomHeating",
+ "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco",
+ "Cooking.Oven.Program.HeatingMode.BottomHeating",
+ "Cooking.Oven.Program.HeatingMode.PizzaSetting",
+ "Cooking.Oven.Program.HeatingMode.SlowCook",
+ "Cooking.Oven.Program.HeatingMode.IntensiveHeat",
+ "Cooking.Oven.Program.HeatingMode.KeepWarm",
+ "Cooking.Oven.Program.HeatingMode.PreheatOvenware",
+ "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial",
+ "Cooking.Oven.Program.HeatingMode.Desiccation",
+ "Cooking.Oven.Program.HeatingMode.Defrost",
+ "Cooking.Oven.Program.HeatingMode.Proof",
+ "Cooking.Oven.Program.HeatingMode.HotAir30Steam",
+ "Cooking.Oven.Program.HeatingMode.HotAir60Steam",
+ "Cooking.Oven.Program.HeatingMode.HotAir80Steam",
+ "Cooking.Oven.Program.HeatingMode.HotAir100Steam",
+ "Cooking.Oven.Program.HeatingMode.SabbathProgramme",
+ "Cooking.Oven.Program.Microwave.90Watt",
+ "Cooking.Oven.Program.Microwave.180Watt",
+ "Cooking.Oven.Program.Microwave.360Watt",
+ "Cooking.Oven.Program.Microwave.600Watt",
+ "Cooking.Oven.Program.Microwave.900Watt",
+ "Cooking.Oven.Program.Microwave.1000Watt",
+ "Cooking.Oven.Program.Microwave.Max",
+ "Cooking.Oven.Program.HeatingMode.WarmingDrawer",
+ "LaundryCare.Washer.Program.Cotton",
+ "LaundryCare.Washer.Program.Cotton.CottonEco",
+ "LaundryCare.Washer.Program.Cotton.Eco4060",
+ "LaundryCare.Washer.Program.Cotton.Colour",
+ "LaundryCare.Washer.Program.EasyCare",
+ "LaundryCare.Washer.Program.Mix",
+ "LaundryCare.Washer.Program.Mix.NightWash",
+ "LaundryCare.Washer.Program.DelicatesSilk",
+ "LaundryCare.Washer.Program.Wool",
+ "LaundryCare.Washer.Program.Sensitive",
+ "LaundryCare.Washer.Program.Auto30",
+ "LaundryCare.Washer.Program.Auto40",
+ "LaundryCare.Washer.Program.Auto60",
+ "LaundryCare.Washer.Program.Chiffon",
+ "LaundryCare.Washer.Program.Curtains",
+ "LaundryCare.Washer.Program.DarkWash",
+ "LaundryCare.Washer.Program.Dessous",
+ "LaundryCare.Washer.Program.Monsoon",
+ "LaundryCare.Washer.Program.Outdoor",
+ "LaundryCare.Washer.Program.PlushToy",
+ "LaundryCare.Washer.Program.ShirtsBlouses",
+ "LaundryCare.Washer.Program.SportFitness",
+ "LaundryCare.Washer.Program.Towels",
+ "LaundryCare.Washer.Program.WaterProof",
+ "LaundryCare.Washer.Program.PowerSpeed59",
+ "LaundryCare.Washer.Program.Super153045.Super15",
+ "LaundryCare.Washer.Program.Super153045.Super1530",
+ "LaundryCare.Washer.Program.DownDuvet.Duvet",
+ "LaundryCare.Washer.Program.Rinse.RinseSpinDrain",
+ "LaundryCare.Washer.Program.DrumClean",
+ "LaundryCare.WasherDryer.Program.Cotton",
+ "LaundryCare.WasherDryer.Program.Cotton.Eco4060",
+ "LaundryCare.WasherDryer.Program.Mix",
+ "LaundryCare.WasherDryer.Program.EasyCare",
+ "LaundryCare.WasherDryer.Program.WashAndDry60",
+ "LaundryCare.WasherDryer.Program.WashAndDry90",
+ )
+}
+
+PROGRAMS_TRANSLATION_KEYS_MAP = {
+ value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
+}
+
+PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
+ SelectEntityDescription(
+ key=BSH_ACTIVE_PROGRAM,
+ translation_key="active_program",
+ ),
+ SelectEntityDescription(
+ key=BSH_SELECTED_PROGRAM,
+ translation_key="selected_program",
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: HomeConnectConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Home Connect select entities."""
+
+ def get_entities() -> list[HomeConnectProgramSelectEntity]:
+ """Get a list of entities."""
+ entities: list[HomeConnectProgramSelectEntity] = []
+ programs_not_found = set()
+ for device in entry.runtime_data.devices:
+ if device.appliance.type in APPLIANCES_WITH_PROGRAMS:
+ with contextlib.suppress(HomeConnectError):
+ programs = device.appliance.get_programs_available()
+ if programs:
+ for program in programs:
+ if program not in PROGRAMS_TRANSLATION_KEYS_MAP:
+ programs.remove(program)
+ if program not in programs_not_found:
+ _LOGGER.info(
+ 'The program "%s" is not part of the official Home Connect API specification',
+ program,
+ )
+ programs_not_found.add(program)
+ entities.extend(
+ HomeConnectProgramSelectEntity(device, programs, desc)
+ for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
+ )
+ return entities
+
+ async_add_entities(await hass.async_add_executor_job(get_entities), True)
+
+
+class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
+ """Select class for Home Connect programs."""
+
+ def __init__(
+ self,
+ device: HomeConnectDevice,
+ programs: list[str],
+ desc: SelectEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(
+ device,
+ desc,
+ )
+ self._attr_options = [
+ PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs
+ ]
+ self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM
+
+ async def async_update(self) -> None:
+ """Update the program selection status."""
+ program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE)
+ if not program:
+ program_translation_key = None
+ elif not (
+ program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program)
+ ):
+ _LOGGER.debug(
+ 'The program "%s" is not part of the official Home Connect API specification',
+ program,
+ )
+ self._attr_current_option = program_translation_key
+ _LOGGER.debug("Updated, new program: %s", self._attr_current_option)
+
+ async def async_select_option(self, option: str) -> None:
+ """Select new program."""
+ bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
+ _LOGGER.debug(
+ "Starting program: %s" if self.start_on_select else "Selecting program: %s",
+ bsh_key,
+ )
+ if self.start_on_select:
+ target = self.device.appliance.start_program
+ else:
+ target = self.device.appliance.select_program
+ try:
+ await self.hass.async_add_executor_job(target, bsh_key)
+ except HomeConnectError as err:
+ if self.start_on_select:
+ translation_key = "start_program"
+ else:
+ translation_key = "select_program"
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=translation_key,
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ "program": bsh_key,
+ },
+ ) from err
+ self.async_entity_update()
diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py
index 70096313d86..3ccf55bac6e 100644
--- a/homeassistant/components/home_connect/sensor.py
+++ b/homeassistant/components/home_connect/sensor.py
@@ -14,14 +14,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
import homeassistant.util.dt as dt_util
-from .api import ConfigEntryAuth
+from . import HomeConnectConfigEntry
from .const import (
ATTR_VALUE,
BSH_DOOR_STATE,
@@ -34,7 +33,6 @@ from .const import (
COFFEE_EVENT_WATER_TANK_EMPTY,
DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY,
DISHWASHER_EVENT_SALT_NEARLY_EMPTY,
- DOMAIN,
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR,
REFRIGERATION_EVENT_TEMP_ALARM_FREEZER,
@@ -253,7 +251,7 @@ EVENT_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect sensor."""
@@ -261,8 +259,7 @@ async def async_setup_entry(
def get_entities() -> list[SensorEntity]:
"""Get a list of entities."""
entities: list[SensorEntity] = []
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
- for device in hc_api.devices:
+ for device in entry.runtime_data.devices:
entities.extend(
HomeConnectSensor(
device,
diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json
index eb57d822b15..5f5ed3cee54 100644
--- a/homeassistant/components/home_connect/strings.json
+++ b/homeassistant/components/home_connect/strings.json
@@ -23,40 +23,43 @@
},
"exceptions": {
"turn_on_light": {
- "message": "Error while trying to turn on {entity_id}: {description}"
+ "message": "Error turning on {entity_id}: {description}"
},
"turn_off_light": {
- "message": "Error while trying to turn off {entity_id}: {description}"
+ "message": "Error turning off {entity_id}: {description}"
},
"set_light_brightness": {
- "message": "Error while trying to set brightness of {entity_id}: {description}"
+ "message": "Error setting brightness of {entity_id}: {description}"
},
"select_light_custom_color": {
- "message": "Error while trying to select custom color of {entity_id}: {description}"
+ "message": "Error selecting custom color of {entity_id}: {description}"
},
"set_light_color": {
- "message": "Error while trying to set color of {entity_id}: {description}"
+ "message": "Error setting color of {entity_id}: {description}"
},
"set_setting": {
- "message": "Error while trying to assign the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}"
+ "message": "Error assigning the value \"{value}\" to the setting \"{setting_key}\" for {entity_id}: {description}"
},
"turn_on": {
- "message": "Error while trying to turn on {entity_id} ({key}): {description}"
+ "message": "Error turning on {entity_id} ({setting_key}): {description}"
},
"turn_off": {
- "message": "Error while trying to turn off {entity_id} ({key}): {description}"
+ "message": "Error turning off {entity_id} ({setting_key}): {description}"
+ },
+ "select_program": {
+ "message": "Error selecting program {program}: {description}"
},
"start_program": {
- "message": "Error while trying to start program {program}: {description}"
+ "message": "Error starting program {program}: {description}"
},
"stop_program": {
- "message": "Error while trying to stop program {program}: {description}"
+ "message": "Error stopping program {program}: {description}"
},
"power_on": {
- "message": "Error while trying to turn on {appliance_name}: {description}"
+ "message": "Error turning on {appliance_name}: {description}"
},
"power_off": {
- "message": "Error while trying to turn off {appliance_name} with value \"{value}\": {description}"
+ "message": "Error turning off {appliance_name} with value \"{value}\": {description}"
},
"turn_off_not_supported": {
"message": "{appliance_name} does not support turning off or entering standby mode."
@@ -267,6 +270,326 @@
"name": "Wine compartment 3 temperature"
}
},
+ "select": {
+ "selected_program": {
+ "name": "Selected program",
+ "state": {
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map",
+ "consumer_products_cleaning_robot_program_basic_go_home": "Go home",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto",
+ "consumer_products_coffee_maker_program_beverage_espresso": "Espresso",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio",
+ "consumer_products_coffee_maker_program_beverage_coffee": "Coffee",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "Galao",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "Americano",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water",
+ "dishcare_dishwasher_program_pre_rinse": "Pre_rinse",
+ "dishcare_dishwasher_program_auto_1": "Auto 1",
+ "dishcare_dishwasher_program_auto_2": "Auto 2",
+ "dishcare_dishwasher_program_auto_3": "Auto 3",
+ "dishcare_dishwasher_program_eco_50": "Eco 50ºC",
+ "dishcare_dishwasher_program_quick_45": "Quick 45ºC",
+ "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC",
+ "dishcare_dishwasher_program_normal_65": "Normal 65ºC",
+ "dishcare_dishwasher_program_glas_40": "Glass 40ºC",
+ "dishcare_dishwasher_program_glass_care": "Glass care",
+ "dishcare_dishwasher_program_night_wash": "Night wash",
+ "dishcare_dishwasher_program_quick_65": "Quick 65ºC",
+ "dishcare_dishwasher_program_normal_45": "Normal 45ºC",
+ "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC",
+ "dishcare_dishwasher_program_auto_half_load": "Auto half load",
+ "dishcare_dishwasher_program_intensiv_power": "Intensive power",
+ "dishcare_dishwasher_program_magic_daily": "Magic daily",
+ "dishcare_dishwasher_program_super_60": "Super 60ºC",
+ "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC",
+ "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC",
+ "dishcare_dishwasher_program_machine_care": "Machine care",
+ "dishcare_dishwasher_program_steam_fresh": "Steam fresh",
+ "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning",
+ "dishcare_dishwasher_program_mixed_load": "Mixed load",
+ "laundry_care_dryer_program_cotton": "Cotton",
+ "laundry_care_dryer_program_synthetic": "Synthetic",
+ "laundry_care_dryer_program_mix": "Mix",
+ "laundry_care_dryer_program_blankets": "Blankets",
+ "laundry_care_dryer_program_business_shirts": "Business shirts",
+ "laundry_care_dryer_program_down_feathers": "Down feathers",
+ "laundry_care_dryer_program_hygiene": "Hygiene",
+ "laundry_care_dryer_program_jeans": "Jeans",
+ "laundry_care_dryer_program_outdoor": "Outdoor",
+ "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh",
+ "laundry_care_dryer_program_towels": "Towels",
+ "laundry_care_dryer_program_delicates": "Delicates",
+ "laundry_care_dryer_program_super_40": "Super 40ºC",
+ "laundry_care_dryer_program_shirts_15": "Shirts 15ºC",
+ "laundry_care_dryer_program_pillow": "Pillow",
+ "laundry_care_dryer_program_anti_shrink": "Anti shrink",
+ "laundry_care_dryer_program_my_time_my_drying_time": "My drying time",
+ "laundry_care_dryer_program_time_cold": "Cold (variable time)",
+ "laundry_care_dryer_program_time_warm": "Warm (variable time)",
+ "laundry_care_dryer_program_in_basket": "In basket",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)",
+ "laundry_care_dryer_program_dessous": "Dessous",
+ "cooking_common_program_hood_automatic": "Automatic",
+ "cooking_common_program_hood_venting": "Venting",
+ "cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
+ "cooking_oven_program_heating_mode_pre_heating": "Pre-heating",
+ "cooking_oven_program_heating_mode_hot_air": "Hot air",
+ "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco",
+ "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
+ "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting",
+ "cooking_oven_program_heating_mode_slow_cook": "Slow cook",
+ "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
+ "cooking_oven_program_heating_mode_keep_warm": "Keep warm",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products",
+ "cooking_oven_program_heating_mode_desiccation": "Desiccation",
+ "cooking_oven_program_heating_mode_defrost": "Defrost",
+ "cooking_oven_program_heating_mode_proof": "Proof",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
+ "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme",
+ "cooking_oven_program_microwave_90_watt": "90 Watt",
+ "cooking_oven_program_microwave_180_watt": "180 Watt",
+ "cooking_oven_program_microwave_360_watt": "360 Watt",
+ "cooking_oven_program_microwave_600_watt": "600 Watt",
+ "cooking_oven_program_microwave_900_watt": "900 Watt",
+ "cooking_oven_program_microwave_1000_watt": "1000 Watt",
+ "cooking_oven_program_microwave_max": "Max",
+ "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer",
+ "laundry_care_washer_program_cotton": "Cotton",
+ "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco",
+ "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC",
+ "laundry_care_washer_program_cotton_colour": "Cotton color",
+ "laundry_care_washer_program_easy_care": "Easy care",
+ "laundry_care_washer_program_mix": "Mix",
+ "laundry_care_washer_program_mix_night_wash": "Mix night wash",
+ "laundry_care_washer_program_delicates_silk": "Delicates silk",
+ "laundry_care_washer_program_wool": "Wool",
+ "laundry_care_washer_program_sensitive": "Sensitive",
+ "laundry_care_washer_program_auto_30": "Auto 30ºC",
+ "laundry_care_washer_program_auto_40": "Auto 40ºC",
+ "laundry_care_washer_program_auto_60": "Auto 60ºC",
+ "laundry_care_washer_program_chiffon": "Chiffon",
+ "laundry_care_washer_program_curtains": "Curtains",
+ "laundry_care_washer_program_dark_wash": "Dark wash",
+ "laundry_care_washer_program_dessous": "Dessous",
+ "laundry_care_washer_program_monsoon": "Monsoon",
+ "laundry_care_washer_program_outdoor": "Outdoor",
+ "laundry_care_washer_program_plush_toy": "Plush toy",
+ "laundry_care_washer_program_shirts_blouses": "Shirts blouses",
+ "laundry_care_washer_program_sport_fitness": "Sport fitness",
+ "laundry_care_washer_program_towels": "Towels",
+ "laundry_care_washer_program_water_proof": "Water proof",
+ "laundry_care_washer_program_power_speed_59": "Power speed <60 min",
+ "laundry_care_washer_program_super_153045_super_15": "Super 15 min",
+ "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",
+ "laundry_care_washer_program_down_duvet_duvet": "Down duvet",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
+ "laundry_care_washer_program_drum_clean": "Drum clean",
+ "laundry_care_washer_dryer_program_cotton": "Cotton",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60 ºC",
+ "laundry_care_washer_dryer_program_mix": "Mix",
+ "laundry_care_washer_dryer_program_easy_care": "Easy care",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)"
+ }
+ },
+ "active_program": {
+ "name": "Active program",
+ "state": {
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_all%]",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_map%]",
+ "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_basic_go_home%]",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_ristretto%]",
+ "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_doppio%]",
+ "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_x_l_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_grande%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_cappuccino%]",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_latte_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_latte%]",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_milk_froth%]",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_warm_milk%]",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_flat_white%]",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_doppio%]",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kaapi%]",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_galao%]",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_garoto%]",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_americano%]",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_red_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_black_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_dead_eye%]",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_hot_water%]",
+ "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_pre_rinse%]",
+ "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_1%]",
+ "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_2%]",
+ "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_3%]",
+ "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_eco_50%]",
+ "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_45%]",
+ "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_70%]",
+ "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_65%]",
+ "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glas_40%]",
+ "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glass_care%]",
+ "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_night_wash%]",
+ "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_65%]",
+ "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_45%]",
+ "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_45%]",
+ "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_half_load%]",
+ "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_power%]",
+ "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_magic_daily%]",
+ "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_super_60%]",
+ "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_kurz_60%]",
+ "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_express_sparkle_65%]",
+ "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_machine_care%]",
+ "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_steam_fresh%]",
+ "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_maximum_cleaning%]",
+ "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_mixed_load%]",
+ "laundry_care_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_cotton%]",
+ "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic%]",
+ "laundry_care_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_mix%]",
+ "laundry_care_dryer_program_blankets": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_blankets%]",
+ "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_business_shirts%]",
+ "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_down_feathers%]",
+ "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_hygiene%]",
+ "laundry_care_dryer_program_jeans": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_jeans%]",
+ "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_outdoor%]",
+ "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic_refresh%]",
+ "laundry_care_dryer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_towels%]",
+ "laundry_care_dryer_program_delicates": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_delicates%]",
+ "laundry_care_dryer_program_super_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_super_40%]",
+ "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_shirts_15%]",
+ "laundry_care_dryer_program_pillow": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_pillow%]",
+ "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_anti_shrink%]",
+ "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_my_time_my_drying_time%]",
+ "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold%]",
+ "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm%]",
+ "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_in_basket%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_20%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_30%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_60%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_30%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_40%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_60%]",
+ "laundry_care_dryer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_dessous%]",
+ "cooking_common_program_hood_automatic": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_automatic%]",
+ "cooking_common_program_hood_venting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_venting%]",
+ "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_delayed_shut_off%]",
+ "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pre_heating%]",
+ "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air%]",
+ "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_eco%]",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_grilling%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating_eco%]",
+ "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_bottom_heating%]",
+ "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pizza_setting%]",
+ "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_slow_cook%]",
+ "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_intensive_heat%]",
+ "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_keep_warm%]",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_preheat_ovenware%]",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_frozen_heatup_special%]",
+ "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_desiccation%]",
+ "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_defrost%]",
+ "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_proof%]",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_30_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_60_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_80_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_100_steam%]",
+ "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_sabbath_programme%]",
+ "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_90_watt%]",
+ "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_180_watt%]",
+ "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_360_watt%]",
+ "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_600_watt%]",
+ "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_900_watt%]",
+ "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_1000_watt%]",
+ "cooking_oven_program_microwave_max": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_max%]",
+ "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_warming_drawer%]",
+ "laundry_care_washer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton%]",
+ "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_cotton_eco%]",
+ "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_eco_4060%]",
+ "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_colour%]",
+ "laundry_care_washer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_easy_care%]",
+ "laundry_care_washer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix%]",
+ "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix_night_wash%]",
+ "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_delicates_silk%]",
+ "laundry_care_washer_program_wool": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_wool%]",
+ "laundry_care_washer_program_sensitive": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sensitive%]",
+ "laundry_care_washer_program_auto_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_30%]",
+ "laundry_care_washer_program_auto_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_40%]",
+ "laundry_care_washer_program_auto_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_60%]",
+ "laundry_care_washer_program_chiffon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_chiffon%]",
+ "laundry_care_washer_program_curtains": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_curtains%]",
+ "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dark_wash%]",
+ "laundry_care_washer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dessous%]",
+ "laundry_care_washer_program_monsoon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_monsoon%]",
+ "laundry_care_washer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_outdoor%]",
+ "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_plush_toy%]",
+ "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_shirts_blouses%]",
+ "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sport_fitness%]",
+ "laundry_care_washer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_towels%]",
+ "laundry_care_washer_program_water_proof": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_water_proof%]",
+ "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_power_speed_59%]",
+ "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_15%]",
+ "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_1530%]",
+ "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_down_duvet_duvet%]",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_rinse_rinse_spin_drain%]",
+ "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_drum_clean%]",
+ "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton%]",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton_eco_4060%]",
+ "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_mix%]",
+ "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_easy_care%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_60%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_90%]"
+ }
+ }
+ },
"sensor": {
"program_progress": {
"name": "Program progress"
diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py
index 25bbb85278a..7e3a285912b 100644
--- a/homeassistant/components/home_connect/switch.py
+++ b/homeassistant/components/home_connect/switch.py
@@ -7,14 +7,13 @@ from typing import Any
from homeconnect.api import HomeConnectError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import get_dict_from_home_connect_error
-from .api import ConfigEntryAuth
+from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .const import (
+ APPLIANCES_WITH_PROGRAMS,
ATTR_ALLOWED_VALUES,
ATTR_CONSTRAINTS,
ATTR_VALUE,
@@ -38,18 +37,6 @@ from .entity import HomeConnectDevice, HomeConnectEntity
_LOGGER = logging.getLogger(__name__)
-APPLIANCES_WITH_PROGRAMS = (
- "CleaningRobot",
- "CoffeeMaker",
- "Dishwasher",
- "Dryer",
- "Hood",
- "Oven",
- "WarmingDrawer",
- "Washer",
- "WasherDryer",
-)
-
SWITCHES = (
SwitchEntityDescription(
@@ -105,7 +92,7 @@ SWITCHES = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
@@ -113,8 +100,7 @@ async def async_setup_entry(
def get_entities() -> list[SwitchEntity]:
"""Get a list of entities."""
entities: list[SwitchEntity] = []
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
- for device in hc_api.devices:
+ for device in entry.runtime_data.devices:
if device.appliance.type in APPLIANCES_WITH_PROGRAMS:
with contextlib.suppress(HomeConnectError):
programs = device.appliance.get_programs_available()
@@ -148,7 +134,7 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
)
except HomeConnectError as err:
self._attr_available = False
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_on",
translation_placeholders={
@@ -172,7 +158,7 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
except HomeConnectError as err:
_LOGGER.error("Error while trying to turn off: %s", err)
self._attr_available = False
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_off",
translation_placeholders={
@@ -223,7 +209,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
self.device.appliance.start_program, self.program_name
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="start_program",
translation_placeholders={
@@ -239,7 +225,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
try:
await self.hass.async_add_executor_job(self.device.appliance.stop_program)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="stop_program",
translation_placeholders={
@@ -292,7 +278,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
)
except HomeConnectError as err:
self._attr_is_on = False
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="power_on",
translation_placeholders={
@@ -305,7 +291,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Switch the device off."""
if not hasattr(self, "power_off_state"):
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unable_to_retrieve_turn_off",
translation_placeholders={
@@ -314,7 +300,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
)
if self.power_off_state is None:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_off_not_supported",
translation_placeholders={
@@ -330,7 +316,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
)
except HomeConnectError as err:
self._attr_is_on = True
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="power_off",
translation_placeholders={
diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py
index 946a2354938..cad16d63cb2 100644
--- a/homeassistant/components/home_connect/time.py
+++ b/homeassistant/components/home_connect/time.py
@@ -6,13 +6,11 @@ import logging
from homeconnect.api import HomeConnectError
from homeassistant.components.time import TimeEntity, TimeEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from . import get_dict_from_home_connect_error
-from .api import ConfigEntryAuth
+from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
from .const import (
ATTR_VALUE,
DOMAIN,
@@ -35,18 +33,17 @@ TIME_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
def get_entities() -> list[HomeConnectTimeEntity]:
"""Get a list of entities."""
- hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
return [
HomeConnectTimeEntity(device, description)
for description in TIME_ENTITIES
- for device in hc_api.devices
+ for device in entry.runtime_data.devices
if description.key in device.appliance.status
]
@@ -83,7 +80,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
time_to_seconds(value),
)
except HomeConnectError as err:
- raise ServiceValidationError(
+ raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_setting",
translation_placeholders={
diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json
index 0dd4eff507d..52b330bfbc8 100644
--- a/homeassistant/components/homeassistant/strings.json
+++ b/homeassistant/components/homeassistant/strings.json
@@ -134,7 +134,7 @@
},
"elevation": {
"name": "[%key:common::config_flow::data::elevation%]",
- "description": "Elevation of your location."
+ "description": "Elevation of your location above sea level."
}
}
},
@@ -224,6 +224,9 @@
"service_not_found": {
"message": "Action {domain}.{service} not found."
},
+ "service_not_supported": {
+ "message": "Entity {entity_id} does not support action {domain}.{service}."
+ },
"service_does_not_support_response": {
"message": "An action which does not return responses can't be called with {return_response}."
},
diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py
index 9edc5009171..2c58ecdfc1c 100644
--- a/homeassistant/components/homeassistant_yellow/config_flow.py
+++ b/homeassistant/components/homeassistant_yellow/config_flow.py
@@ -14,8 +14,8 @@ import voluptuous as vol
from homeassistant.components.hassio import (
HassioAPIError,
async_get_yellow_settings,
- async_reboot_host,
async_set_yellow_settings,
+ get_supervisor_client,
)
from homeassistant.components.homeassistant_hardware.firmware_config_flow import (
BaseFirmwareConfigFlow,
@@ -31,7 +31,7 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.core import callback
+from homeassistant.core import HomeAssistant, async_get_hass, callback
from homeassistant.helpers import discovery_flow, selector
from .const import DOMAIN, FIRMWARE, RADIO_DEVICE, ZHA_DOMAIN, ZHA_HW_DISCOVERY_DATA
@@ -67,11 +67,12 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
) -> OptionsFlow:
"""Return the options flow."""
firmware_type = ApplicationType(config_entry.data[FIRMWARE])
+ hass = async_get_hass()
if firmware_type is ApplicationType.CPC:
- return HomeAssistantYellowMultiPanOptionsFlowHandler(config_entry)
+ return HomeAssistantYellowMultiPanOptionsFlowHandler(hass, config_entry)
- return HomeAssistantYellowOptionsFlowHandler(config_entry)
+ return HomeAssistantYellowOptionsFlowHandler(hass, config_entry)
async def async_step_system(
self, data: dict[str, Any] | None = None
@@ -107,6 +108,11 @@ class BaseHomeAssistantYellowOptionsFlow(OptionsFlow, ABC):
_hw_settings: dict[str, bool] | None = None
+ def __init__(self, hass: HomeAssistant, *args: Any, **kwargs: Any) -> None:
+ """Instantiate options flow."""
+ super().__init__(*args, **kwargs)
+ self._supervisor_client = get_supervisor_client(hass)
+
@abstractmethod
async def async_step_main_menu(self, _: None = None) -> ConfigFlowResult:
"""Show the main menu."""
@@ -172,7 +178,7 @@ class BaseHomeAssistantYellowOptionsFlow(OptionsFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reboot now."""
- await async_reboot_host(self.hass)
+ await self._supervisor_client.host.reboot()
return self.async_create_entry(data={})
async def async_step_reboot_later(
@@ -251,9 +257,9 @@ class HomeAssistantYellowOptionsFlowHandler(
):
"""Handle a firmware options flow for Home Assistant Yellow."""
- def __init__(self, *args: Any, **kwargs: Any) -> None:
+ def __init__(self, hass: HomeAssistant, *args: Any, **kwargs: Any) -> None:
"""Instantiate options flow."""
- super().__init__(*args, **kwargs)
+ super().__init__(hass, *args, **kwargs)
self._hardware_name = BOARD_NAME
self._device = RADIO_DEVICE
diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py
index b85308ffd66..97fb17d7db5 100644
--- a/homeassistant/components/homekit/__init__.py
+++ b/homeassistant/components/homekit/__init__.py
@@ -33,6 +33,7 @@ from homeassistant.components.device_automation.trigger import (
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
@@ -1133,6 +1134,8 @@ class HomeKit:
config[entity_id].setdefault(
CONF_LINKED_MOTION_SENSOR, motion_binary_sensor_entity_id
)
+
+ if domain in (CAMERA_DOMAIN, LOCK_DOMAIN):
if doorbell_event_entity_id := lookup.get(DOORBELL_EVENT_SENSOR):
config[entity_id].setdefault(
CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id
diff --git a/homeassistant/components/homekit/doorbell.py b/homeassistant/components/homekit/doorbell.py
new file mode 100644
index 00000000000..45bbb2ea0ca
--- /dev/null
+++ b/homeassistant/components/homekit/doorbell.py
@@ -0,0 +1,121 @@
+"""Extend the doorbell functions."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from pyhap.util import callback as pyhap_callback
+
+from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
+from homeassistant.core import (
+ Event,
+ EventStateChangedData,
+ HassJobType,
+ State,
+ callback as ha_callback,
+)
+from homeassistant.helpers.event import async_track_state_change_event
+
+from .accessories import HomeAccessory
+from .const import (
+ CHAR_MUTE,
+ CHAR_PROGRAMMABLE_SWITCH_EVENT,
+ CONF_LINKED_DOORBELL_SENSOR,
+ SERV_DOORBELL,
+ SERV_SPEAKER,
+ SERV_STATELESS_PROGRAMMABLE_SWITCH,
+)
+from .util import state_changed_event_is_same_state
+
+_LOGGER = logging.getLogger(__name__)
+
+DOORBELL_SINGLE_PRESS = 0
+DOORBELL_DOUBLE_PRESS = 1
+DOORBELL_LONG_PRESS = 2
+
+
+class HomeDoorbellAccessory(HomeAccessory):
+ """Accessory with optional doorbell."""
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ """Initialize an Accessory object with optional attached doorbell."""
+ super().__init__(*args, **kwargs)
+ self._char_doorbell_detected = None
+ self._char_doorbell_detected_switch = None
+ linked_doorbell_sensor: str | None
+ linked_doorbell_sensor = self.config.get(CONF_LINKED_DOORBELL_SENSOR)
+ self.linked_doorbell_sensor = linked_doorbell_sensor
+ self.doorbell_is_event = False
+ if not linked_doorbell_sensor:
+ return
+ self.doorbell_is_event = linked_doorbell_sensor.startswith("event.")
+ if not (state := self.hass.states.get(linked_doorbell_sensor)):
+ return
+ serv_doorbell = self.add_preload_service(SERV_DOORBELL)
+ self.set_primary_service(serv_doorbell)
+ self._char_doorbell_detected = serv_doorbell.configure_char(
+ CHAR_PROGRAMMABLE_SWITCH_EVENT,
+ value=0,
+ )
+ serv_stateless_switch = self.add_preload_service(
+ SERV_STATELESS_PROGRAMMABLE_SWITCH
+ )
+ self._char_doorbell_detected_switch = serv_stateless_switch.configure_char(
+ CHAR_PROGRAMMABLE_SWITCH_EVENT,
+ value=0,
+ valid_values={"SinglePress": DOORBELL_SINGLE_PRESS},
+ )
+ serv_speaker = self.add_preload_service(SERV_SPEAKER)
+ serv_speaker.configure_char(CHAR_MUTE, value=0)
+ self.async_update_doorbell_state(None, state)
+
+ @ha_callback
+ @pyhap_callback # type: ignore[misc]
+ def run(self) -> None:
+ """Handle doorbell event."""
+ if self._char_doorbell_detected:
+ assert self.linked_doorbell_sensor
+ self._subscriptions.append(
+ async_track_state_change_event(
+ self.hass,
+ self.linked_doorbell_sensor,
+ self.async_update_doorbell_state_event,
+ job_type=HassJobType.Callback,
+ )
+ )
+
+ super().run()
+
+ @ha_callback
+ def async_update_doorbell_state_event(
+ self, event: Event[EventStateChangedData]
+ ) -> None:
+ """Handle state change event listener callback."""
+ if not state_changed_event_is_same_state(event) and (
+ new_state := event.data["new_state"]
+ ):
+ self.async_update_doorbell_state(event.data["old_state"], new_state)
+
+ @ha_callback
+ def async_update_doorbell_state(
+ self, old_state: State | None, new_state: State
+ ) -> None:
+ """Handle link doorbell sensor state change to update HomeKit value."""
+ assert self._char_doorbell_detected
+ assert self._char_doorbell_detected_switch
+ state = new_state.state
+ if state == STATE_ON or (
+ self.doorbell_is_event
+ and old_state is not None
+ and old_state.state != STATE_UNAVAILABLE
+ and state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
+ ):
+ self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS)
+ self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS)
+ _LOGGER.debug(
+ "%s: Set linked doorbell %s sensor to %d",
+ self.entity_id,
+ self.linked_doorbell_sensor,
+ DOORBELL_SINGLE_PRESS,
+ )
diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py
index 9e076f7d4d7..0fb2c2e7922 100644
--- a/homeassistant/components/homekit/type_cameras.py
+++ b/homeassistant/components/homekit/type_cameras.py
@@ -31,15 +31,12 @@ from homeassistant.helpers.event import (
)
from homeassistant.util.async_ import create_eager_task
-from .accessories import TYPES, HomeAccessory, HomeDriver
+from .accessories import TYPES, HomeDriver
from .const import (
CHAR_MOTION_DETECTED,
- CHAR_MUTE,
- CHAR_PROGRAMMABLE_SWITCH_EVENT,
CONF_AUDIO_CODEC,
CONF_AUDIO_MAP,
CONF_AUDIO_PACKET_SIZE,
- CONF_LINKED_DOORBELL_SENSOR,
CONF_LINKED_MOTION_SENSOR,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
@@ -64,18 +61,13 @@ from .const import (
DEFAULT_VIDEO_MAP,
DEFAULT_VIDEO_PACKET_SIZE,
DEFAULT_VIDEO_PROFILE_NAMES,
- SERV_DOORBELL,
SERV_MOTION_SENSOR,
- SERV_SPEAKER,
- SERV_STATELESS_PROGRAMMABLE_SWITCH,
)
+from .doorbell import HomeDoorbellAccessory
from .util import pid_is_alive, state_changed_event_is_same_state
_LOGGER = logging.getLogger(__name__)
-DOORBELL_SINGLE_PRESS = 0
-DOORBELL_DOUBLE_PRESS = 1
-DOORBELL_LONG_PRESS = 2
VIDEO_OUTPUT = (
"-map {v_map} -an "
@@ -149,7 +141,7 @@ CONFIG_DEFAULTS = {
@TYPES.register("Camera")
# False-positive on pylint, not a CameraEntity
# pylint: disable-next=hass-enforce-class-module
-class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
+class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
"""Generate a Camera accessory."""
def __init__(
@@ -237,36 +229,6 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
)
self._async_update_motion_state(None, state)
- self._char_doorbell_detected = None
- self._char_doorbell_detected_switch = None
- linked_doorbell_sensor: str | None = self.config.get(
- CONF_LINKED_DOORBELL_SENSOR
- )
- self.linked_doorbell_sensor = linked_doorbell_sensor
- self.doorbell_is_event = False
- if not linked_doorbell_sensor:
- return
- self.doorbell_is_event = linked_doorbell_sensor.startswith("event.")
- if not (state := self.hass.states.get(linked_doorbell_sensor)):
- return
- serv_doorbell = self.add_preload_service(SERV_DOORBELL)
- self.set_primary_service(serv_doorbell)
- self._char_doorbell_detected = serv_doorbell.configure_char(
- CHAR_PROGRAMMABLE_SWITCH_EVENT,
- value=0,
- )
- serv_stateless_switch = self.add_preload_service(
- SERV_STATELESS_PROGRAMMABLE_SWITCH
- )
- self._char_doorbell_detected_switch = serv_stateless_switch.configure_char(
- CHAR_PROGRAMMABLE_SWITCH_EVENT,
- value=0,
- valid_values={"SinglePress": DOORBELL_SINGLE_PRESS},
- )
- serv_speaker = self.add_preload_service(SERV_SPEAKER)
- serv_speaker.configure_char(CHAR_MUTE, value=0)
- self._async_update_doorbell_state(None, state)
-
@pyhap_callback # type: ignore[misc]
@callback
def run(self) -> None:
@@ -285,17 +247,6 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
)
)
- if self._char_doorbell_detected:
- assert self.linked_doorbell_sensor
- self._subscriptions.append(
- async_track_state_change_event(
- self.hass,
- self.linked_doorbell_sensor,
- self._async_update_doorbell_state_event,
- job_type=HassJobType.Callback,
- )
- )
-
super().run()
@callback
@@ -344,39 +295,6 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc]
detected,
)
- @callback
- def _async_update_doorbell_state_event(
- self, event: Event[EventStateChangedData]
- ) -> None:
- """Handle state change event listener callback."""
- if not state_changed_event_is_same_state(event) and (
- new_state := event.data["new_state"]
- ):
- self._async_update_doorbell_state(event.data["old_state"], new_state)
-
- @callback
- def _async_update_doorbell_state(
- self, old_state: State | None, new_state: State
- ) -> None:
- """Handle link doorbell sensor state change to update HomeKit value."""
- assert self._char_doorbell_detected
- assert self._char_doorbell_detected_switch
- state = new_state.state
- if state == STATE_ON or (
- self.doorbell_is_event
- and old_state is not None
- and old_state.state != STATE_UNAVAILABLE
- and state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
- ):
- self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS)
- self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS)
- _LOGGER.debug(
- "%s: Set linked doorbell %s sensor to %d",
- self.entity_id,
- self.linked_doorbell_sensor,
- DOORBELL_SINGLE_PRESS,
- )
-
@callback
def async_update_state(self, new_state: State | None) -> None:
"""Handle state change to update HomeKit value."""
diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py
index 70570a8fca5..59da802b8b7 100644
--- a/homeassistant/components/homekit/type_locks.py
+++ b/homeassistant/components/homekit/type_locks.py
@@ -9,8 +9,9 @@ from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState
from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN
from homeassistant.core import State, callback
-from .accessories import TYPES, HomeAccessory
+from .accessories import TYPES
from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK
+from .doorbell import HomeDoorbellAccessory
_LOGGER = logging.getLogger(__name__)
@@ -53,7 +54,7 @@ STATE_TO_SERVICE = {
@TYPES.register("Lock")
-class Lock(HomeAccessory):
+class Lock(HomeDoorbellAccessory):
"""Generate a Lock accessory for a lock entity.
The lock entity must support: unlock and lock.
diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py
index ae7e35030be..d339aa6aded 100644
--- a/homeassistant/components/homekit/util.py
+++ b/homeassistant/components/homekit/util.py
@@ -114,7 +114,7 @@ _LOGGER = logging.getLogger(__name__)
NUMBERS_ONLY_RE = re.compile(r"[^\d.]+")
VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?")
-INVALID_END_CHARS = "-_"
+INVALID_END_CHARS = "-_ "
MAX_VERSION_PART = 2**32 - 1
@@ -182,7 +182,6 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)}
)
-
COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain(
@@ -195,6 +194,14 @@ CODE_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string)}
)
+LOCK_SCHEMA = CODE_SCHEMA.extend(
+ {
+ vol.Optional(CONF_LINKED_DOORBELL_SENSOR): cv.entity_domain(
+ [binary_sensor.DOMAIN, EVENT_DOMAIN]
+ ),
+ }
+)
+
MEDIA_PLAYER_SCHEMA = vol.Schema(
{
vol.Required(CONF_FEATURE): vol.All(
@@ -284,7 +291,7 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
if not isinstance(config, dict):
raise vol.Invalid(f"The configuration for {entity} must be a dictionary.")
- if domain in ("alarm_control_panel", "lock"):
+ if domain == "alarm_control_panel":
config = CODE_SCHEMA(config)
elif domain == media_player.const.DOMAIN:
@@ -301,6 +308,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "camera":
config = CAMERA_SCHEMA(config)
+ elif domain == "lock":
+ config = LOCK_SCHEMA(config)
+
elif domain == "switch":
config = SWITCH_TYPE_SCHEMA(config)
@@ -424,20 +434,12 @@ def cleanup_name_for_homekit(name: str | None) -> str:
def temperature_to_homekit(temperature: float, unit: str) -> float:
"""Convert temperature to Celsius for HomeKit."""
- return round(
- TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS), 1
- )
+ return TemperatureConverter.convert(temperature, unit, UnitOfTemperature.CELSIUS)
def temperature_to_states(temperature: float, unit: str) -> float:
"""Convert temperature back from Celsius to Home Assistant unit."""
- return (
- round(
- TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit)
- * 2
- )
- / 2
- )
+ return TemperatureConverter.convert(temperature, UnitOfTemperature.CELSIUS, unit)
def density_to_air_quality(density: float) -> int:
diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json
index cddd61a12c1..b7c82b9fd51 100644
--- a/homeassistant/components/homekit_controller/manifest.json
+++ b/homeassistant/components/homekit_controller/manifest.json
@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
- "requirements": ["aiohomekit==3.2.6"],
+ "requirements": ["aiohomekit==3.2.7"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}
diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json
index 9c67a5da0b2..749bd7b44e8 100644
--- a/homeassistant/components/homematic/manifest.json
+++ b/homeassistant/components/homematic/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/homematic",
"iot_class": "local_push",
"loggers": ["pyhomematic"],
+ "quality_scale": "legacy",
"requirements": ["pyhomematic==0.1.77"]
}
diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json
index 97af964ffc7..7878a8b4e0a 100644
--- a/homeassistant/components/homematicip_cloud/manifest.json
+++ b/homeassistant/components/homematicip_cloud/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
- "quality_scale": "silver",
"requirements": ["homematicip==1.1.3"]
}
diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py
index a9cc19d72a7..7b05cb95271 100644
--- a/homeassistant/components/homewizard/button.py
+++ b/homeassistant/components/homewizard/button.py
@@ -10,6 +10,8 @@ from .coordinator import HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
from .helpers import homewizard_exception_handler
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py
index d52e53cf39b..a6e4356328e 100644
--- a/homeassistant/components/homewizard/config_flow.py
+++ b/homeassistant/components/homewizard/config_flow.py
@@ -6,16 +6,18 @@ from collections.abc import Mapping
import logging
from typing import Any, NamedTuple
-from homewizard_energy import HomeWizardEnergy
+from homewizard_energy import HomeWizardEnergyV1
from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError
-from homewizard_energy.models import Device
-from voluptuous import Required, Schema
+from homewizard_energy.v1.models import Device
+import voluptuous as vol
from homeassistant.components import onboarding, zeroconf
+from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.selector import TextSelector
from .const import (
CONF_API_ENABLED,
@@ -68,11 +70,11 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
user_input = user_input or {}
return self.async_show_form(
step_id="user",
- data_schema=Schema(
+ data_schema=vol.Schema(
{
- Required(
+ vol.Required(
CONF_IP_ADDRESS, default=user_input.get(CONF_IP_ADDRESS)
- ): str,
+ ): TextSelector(),
}
),
errors=errors,
@@ -110,6 +112,32 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_discovery_confirm()
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle dhcp discovery to update existing entries.
+
+ This flow is triggered only by DHCP discovery of known devices.
+ """
+ try:
+ device = await self._async_try_connect(discovery_info.ip)
+ except RecoverableError as ex:
+ _LOGGER.error(ex)
+ return self.async_abort(reason="unknown")
+
+ await self.async_set_unique_id(
+ f"{device.product_type}_{discovery_info.macaddress}"
+ )
+
+ self._abort_if_unique_id_configured(
+ updates={CONF_IP_ADDRESS: discovery_info.ip}
+ )
+
+ # This situation should never happen, as Home Assistant will only
+ # send updates for existing entries. In case it does, we'll just
+ # abort the flow with an unknown error.
+ return self.async_abort(reason="unknown")
+
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -170,6 +198,43 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(step_id="reauth_confirm", 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] = {}
+ if user_input:
+ try:
+ device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS])
+ except RecoverableError as ex:
+ _LOGGER.error(ex)
+ errors = {"base": ex.error_code}
+ else:
+ await self.async_set_unique_id(
+ f"{device_info.product_type}_{device_info.serial}"
+ )
+ self._abort_if_unique_id_mismatch(reason="wrong_device")
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
+ data_updates=user_input,
+ )
+ reconfigure_entry = self._get_reconfigure_entry()
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_IP_ADDRESS,
+ default=reconfigure_entry.data.get(CONF_IP_ADDRESS),
+ ): TextSelector(),
+ }
+ ),
+ description_placeholders={
+ "title": reconfigure_entry.title,
+ },
+ errors=errors,
+ )
+
@staticmethod
async def _async_try_connect(ip_address: str) -> Device:
"""Try to connect.
@@ -177,7 +242,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
Make connection with device to test the connection
and to get info for unique_id.
"""
- energy_api = HomeWizardEnergy(ip_address)
+ energy_api = HomeWizardEnergyV1(ip_address)
try:
return await energy_api.device()
diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py
index 8cee8350268..809ecc1416b 100644
--- a/homeassistant/components/homewizard/const.py
+++ b/homeassistant/components/homewizard/const.py
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import timedelta
import logging
-from homewizard_energy.models import Data, Device, State, System
+from homewizard_energy.v1.models import Data, Device, State, System
from homeassistant.const import Platform
diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py
index 61b304eb39c..8f5045d3b94 100644
--- a/homeassistant/components/homewizard/coordinator.py
+++ b/homeassistant/components/homewizard/coordinator.py
@@ -4,10 +4,10 @@ from __future__ import annotations
import logging
-from homewizard_energy import HomeWizardEnergy
-from homewizard_energy.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE
+from homewizard_energy import HomeWizardEnergyV1
from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError
-from homewizard_energy.models import Device
+from homewizard_energy.v1.const import SUPPORTS_IDENTIFY, SUPPORTS_STATE
+from homewizard_energy.v1.models import Device
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]):
"""Gather data for the energy device."""
- api: HomeWizardEnergy
+ api: HomeWizardEnergyV1
api_disabled: bool = False
_unsupported_error: bool = False
@@ -36,7 +36,7 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
) -> None:
"""Initialize update coordinator."""
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)
- self.api = HomeWizardEnergy(
+ self.api = HomeWizardEnergyV1(
self.config_entry.data[CONF_IP_ADDRESS],
clientsession=async_get_clientsession(hass),
)
@@ -66,7 +66,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
)
except RequestError as ex:
- raise UpdateFailed(ex) from ex
+ raise UpdateFailed(
+ ex, translation_domain=DOMAIN, translation_key="communication_error"
+ ) from ex
except DisabledError as ex:
if not self.api_disabled:
@@ -79,7 +81,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
self.config_entry.entry_id
)
- raise UpdateFailed(ex) from ex
+ raise UpdateFailed(
+ ex, translation_domain=DOMAIN, translation_key="api_disabled"
+ ) from ex
self.api_disabled = False
diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json
index 65672903eb8..13bfc512551 100644
--- a/homeassistant/components/homewizard/manifest.json
+++ b/homeassistant/components/homewizard/manifest.json
@@ -3,10 +3,15 @@
"name": "HomeWizard Energy",
"codeowners": ["@DCSBL"],
"config_flow": true,
+ "dhcp": [
+ {
+ "registered_devices": true
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/homewizard",
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
- "requirements": ["python-homewizard-energy==v6.3.0"],
+ "requirements": ["python-homewizard-energy==v7.0.0"],
"zeroconf": ["_hwenergy._tcp.local."]
}
diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py
index 1af77859a0f..1ed4c642f6b 100644
--- a/homeassistant/components/homewizard/number.py
+++ b/homeassistant/components/homewizard/number.py
@@ -13,6 +13,8 @@ from .coordinator import HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
from .helpers import homewizard_exception_handler
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -62,4 +64,4 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity):
or (brightness := self.coordinator.data.state.brightness) is None
):
return None
- return brightness_to_value((0, 100), brightness)
+ return round(brightness_to_value((0, 100), brightness))
diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml
new file mode 100644
index 00000000000..423bc4dea49
--- /dev/null
+++ b/homeassistant/components/homewizard/quality_scale.yaml
@@ -0,0 +1,81 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ The integration does not provide any additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ The integration connects to a single device per configuration entry.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connect to a single device per configuration entry.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py
index 57071875edb..24ed5933d06 100644
--- a/homeassistant/components/homewizard/sensor.py
+++ b/homeassistant/components/homewizard/sensor.py
@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
-from homewizard_energy.models import Data, ExternalDevice
+from homewizard_energy.v1.models import Data, ExternalDevice
from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json
index 751c1ec450d..4309664c4c8 100644
--- a/homeassistant/components/homewizard/strings.json
+++ b/homeassistant/components/homewizard/strings.json
@@ -6,6 +6,9 @@
"description": "Enter the IP address of your HomeWizard Energy device to integrate with Home Assistant.",
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
+ },
+ "data_description": {
+ "ip_address": "The IP address of your HomeWizard Energy device."
}
},
"discovery_confirm": {
@@ -14,10 +17,19 @@
},
"reauth_confirm": {
"description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings."
+ },
+ "reconfigure": {
+ "description": "Update configuration for {title}.",
+ "data": {
+ "ip_address": "[%key:common::config_flow::data::ip%]"
+ },
+ "data_description": {
+ "ip_address": "[%key:component::homewizard::config::step::user::data_description::ip_address%]"
+ }
}
},
"error": {
- "api_not_enabled": "The API is not enabled. Enable API in the HomeWizard Energy App under settings",
+ "api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.",
"network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network"
},
"abort": {
@@ -26,7 +38,9 @@
"device_not_supported": "This device is not supported",
"unknown_error": "[%key:common::config_flow::error::unknown%]",
"unsupported_api_version": "Detected unsupported API version",
- "reauth_successful": "Enabling API was successful"
+ "reauth_successful": "Enabling API was successful",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "wrong_device": "The configured device is not the same found on this IP address."
}
},
"entity": {
@@ -120,7 +134,7 @@
},
"exceptions": {
"api_disabled": {
- "message": "The local API of the HomeWizard device is disabled"
+ "message": "The local API is disabled."
},
"communication_error": {
"message": "An error occurred while communicating with HomeWizard device"
diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py
index 14c6e0778f1..aa0af17f578 100644
--- a/homeassistant/components/homewizard/switch.py
+++ b/homeassistant/components/homewizard/switch.py
@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
-from homewizard_energy import HomeWizardEnergy
+from homewizard_energy import HomeWizardEnergyV1
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -23,6 +23,8 @@ from .coordinator import HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
from .helpers import homewizard_exception_handler
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class HomeWizardSwitchEntityDescription(SwitchEntityDescription):
@@ -31,7 +33,7 @@ class HomeWizardSwitchEntityDescription(SwitchEntityDescription):
available_fn: Callable[[DeviceResponseEntry], bool]
create_fn: Callable[[HWEnergyDeviceUpdateCoordinator], bool]
is_on_fn: Callable[[DeviceResponseEntry], bool | None]
- set_fn: Callable[[HomeWizardEnergy, bool], Awaitable[Any]]
+ set_fn: Callable[[HomeWizardEnergyV1, bool], Awaitable[Any]]
SWITCHES = [
diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py
index 98cbae4eb7e..d4e5ee10a6b 100644
--- a/homeassistant/components/honeywell/climate.py
+++ b/homeassistant/components/honeywell/climate.py
@@ -398,7 +398,7 @@ class HoneywellUSThermostat(ClimateEntity):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temp_failed_value",
- translation_placeholders={"temp": temperature},
+ translation_placeholders={"temperature": temperature},
) from err
async def async_set_temperature(self, **kwargs: Any) -> None:
@@ -422,7 +422,7 @@ class HoneywellUSThermostat(ClimateEntity):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="temp_failed_value",
- translation_placeholders={"temp": str(temperature)},
+ translation_placeholders={"temperature": str(temperature)},
) from err
async def async_set_fan_mode(self, fan_mode: str) -> None:
diff --git a/homeassistant/components/horizon/manifest.json b/homeassistant/components/horizon/manifest.json
index d1280a6fe65..d30e2f39e34 100644
--- a/homeassistant/components/horizon/manifest.json
+++ b/homeassistant/components/horizon/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/horizon",
"iot_class": "local_polling",
"loggers": ["horimote"],
+ "quality_scale": "legacy",
"requirements": ["horimote==0.4.1"]
}
diff --git a/homeassistant/components/hp_ilo/manifest.json b/homeassistant/components/hp_ilo/manifest.json
index 378a9ac1865..9f2dfb21783 100644
--- a/homeassistant/components/hp_ilo/manifest.json
+++ b/homeassistant/components/hp_ilo/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/hp_ilo",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["python-hpilo==4.4.3"]
}
diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py
index c9c75b0c04e..95cdee9ab9e 100644
--- a/homeassistant/components/http/__init__.py
+++ b/homeassistant/components/http/__init__.py
@@ -326,7 +326,8 @@ class HomeAssistantApplication(web.Application):
protocol,
writer,
task,
- loop=self._loop,
+ # loop will never be None when called from aiohttp
+ loop=self._loop, # type: ignore[arg-type]
client_max_size=self._client_max_size,
)
@@ -509,10 +510,10 @@ class HomeAssistantHTTP:
"calls hass.http.register_static_path which is deprecated because "
"it does blocking I/O in the event loop, instead "
"call `await hass.http.async_register_static_paths("
- f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`; '
- "This function will be removed in 2025.7",
+ f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`',
exclude_integrations={"http"},
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.7",
)
configs = [StaticPathConfig(url_path, path, cache_headers)]
resources = self._make_static_resources(configs)
diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json
index dbd9b511977..22f1d3991e7 100644
--- a/homeassistant/components/hue/manifest.json
+++ b/homeassistant/components/hue/manifest.json
@@ -10,7 +10,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiohue"],
- "quality_scale": "platinum",
"requirements": ["aiohue==4.7.3"],
"zeroconf": ["_hue._tcp.local."]
}
diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py
index b556a6961bb..1498c4f6e3d 100644
--- a/homeassistant/components/humidifier/__init__.py
+++ b/homeassistant/components/humidifier/__init__.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from datetime import timedelta
from enum import StrEnum
-from functools import partial
import logging
from typing import Any, final
@@ -22,11 +21,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -34,9 +28,6 @@ from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
from .const import ( # noqa: F401
- _DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER,
- _DEPRECATED_DEVICE_CLASS_HUMIDIFIER,
- _DEPRECATED_SUPPORT_MODES,
ATTR_ACTION,
ATTR_AVAILABLE_MODES,
ATTR_CURRENT_HUMIDITY,
@@ -314,13 +305,3 @@ async def async_service_humidity_set(
)
await entity.async_set_humidity(humidity)
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py
index fc6b0fc14d4..ceef0c5a890 100644
--- a/homeassistant/components/humidifier/const.py
+++ b/homeassistant/components/humidifier/const.py
@@ -1,15 +1,6 @@
"""Provides the constants needed for component."""
from enum import IntFlag, StrEnum
-from functools import partial
-
-from homeassistant.helpers.deprecation import (
- DeprecatedConstant,
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
MODE_NORMAL = "normal"
MODE_ECO = "eco"
@@ -43,34 +34,11 @@ DEFAULT_MAX_HUMIDITY = 100
DOMAIN = "humidifier"
-# DEVICE_CLASS_* below are deprecated as of 2021.12
-# use the HumidifierDeviceClass enum instead.
-_DEPRECATED_DEVICE_CLASS_HUMIDIFIER = DeprecatedConstant(
- "humidifier", "HumidifierDeviceClass.HUMIDIFIER", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER = DeprecatedConstant(
- "dehumidifier", "HumidifierDeviceClass.DEHUMIDIFIER", "2025.1"
-)
-
SERVICE_SET_MODE = "set_mode"
SERVICE_SET_HUMIDITY = "set_humidity"
class HumidifierEntityFeature(IntFlag):
- """Supported features of the alarm control panel entity."""
+ """Supported features of the humidifier entity."""
MODES = 1
-
-
-# The SUPPORT_MODES constant is deprecated as of Home Assistant 2022.5.
-# Please use the HumidifierEntityFeature enum instead.
-_DEPRECATED_SUPPORT_MODES = DeprecatedConstantEnum(
- HumidifierEntityFeature.MODES, "2025.1"
-)
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/husqvarna_automower/api.py b/homeassistant/components/husqvarna_automower/api.py
index f1d3e1ef4fa..8a9a31b926a 100644
--- a/homeassistant/components/husqvarna_automower/api.py
+++ b/homeassistant/components/husqvarna_automower/api.py
@@ -7,6 +7,7 @@ from aioautomower.auth import AbstractAuth
from aioautomower.const import API_BASE_URL
from aiohttp import ClientSession
+from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow
_LOGGER = logging.getLogger(__name__)
@@ -28,3 +29,16 @@ class AsyncConfigEntryAuth(AbstractAuth):
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])
+
+
+class AsyncConfigFlowAuth(AbstractAuth):
+ """Provide Automower AbstractAuth for the config flow."""
+
+ def __init__(self, websession: ClientSession, token: dict) -> None:
+ """Initialize Husqvarna Automower auth."""
+ super().__init__(websession, API_BASE_URL)
+ self.token: dict = token
+
+ async def async_get_access_token(self) -> str:
+ """Return a valid access token."""
+ return cast(str, self.token[CONF_ACCESS_TOKEN])
diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py
index 22a732ec54c..ce303325496 100644
--- a/homeassistant/components/husqvarna_automower/button.py
+++ b/homeassistant/components/husqvarna_automower/button.py
@@ -22,6 +22,8 @@ from .entity import (
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class AutomowerButtonEntityDescription(ButtonEntityDescription):
diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py
index 3e76b9ac812..4da3bd14089 100644
--- a/homeassistant/components/husqvarna_automower/config_flow.py
+++ b/homeassistant/components/husqvarna_automower/config_flow.py
@@ -4,12 +4,15 @@ from collections.abc import Mapping
import logging
from typing import Any
+from aioautomower.session import AutomowerSession
from aioautomower.utils import structure_token
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, CONF_TOKEN
-from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
+from homeassistant.util import dt as dt_util
+from .api import AsyncConfigFlowAuth
from .const import DOMAIN, NAME
_LOGGER = logging.getLogger(__name__)
@@ -46,9 +49,20 @@ class HusqvarnaConfigFlowHandler(
self._abort_if_unique_id_configured()
+ websession = aiohttp_client.async_get_clientsession(self.hass)
+ tz = await dt_util.async_get_time_zone(str(dt_util.DEFAULT_TIME_ZONE))
+ automower_api = AutomowerSession(AsyncConfigFlowAuth(websession, token), tz)
+ try:
+ data = await automower_api.get_status()
+ except Exception: # noqa: BLE001
+ return self.async_abort(reason="unknown")
+ if data == {}:
+ return self.async_abort(reason="no_mower_connected")
+
structured_token = structure_token(token[CONF_ACCESS_TOKEN])
first_name = structured_token.user.first_name
last_name = structured_token.user.last_name
+
return self.async_create_entry(
title=f"{NAME} of {first_name} {last_name}",
data=data,
diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py
index eeabaa09f79..9b3ce7dab1a 100644
--- a/homeassistant/components/husqvarna_automower/lawn_mower.py
+++ b/homeassistant/components/husqvarna_automower/lawn_mower.py
@@ -22,6 +22,10 @@ from .const import DOMAIN
from .coordinator import AutomowerDataUpdateCoordinator
from .entity import AutomowerAvailableEntity, handle_sending_exception
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 1
+
DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING)
MOWING_ACTIVITIES = (
MowerActivities.MOWING,
@@ -42,9 +46,6 @@ PARK = "park"
OVERRIDE_MODES = [MOW, PARK]
-_LOGGER = logging.getLogger(__name__)
-
-
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py
index d6d794f2d83..e69b52fab93 100644
--- a/homeassistant/components/husqvarna_automower/number.py
+++ b/homeassistant/components/husqvarna_automower/number.py
@@ -24,6 +24,8 @@ from .entity import (
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
+
@callback
def _async_get_cutting_height(data: MowerAttributes) -> int:
diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py
index a9431acaae3..65960e897e4 100644
--- a/homeassistant/components/husqvarna_automower/select.py
+++ b/homeassistant/components/husqvarna_automower/select.py
@@ -16,6 +16,7 @@ from .entity import AutomowerControlEntity, handle_sending_exception
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
HEADLIGHT_MODES: list = [
HeadlightModes.ALWAYS_OFF.lower(),
diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py
index ebb68033918..70b5510de36 100644
--- a/homeassistant/components/husqvarna_automower/sensor.py
+++ b/homeassistant/components/husqvarna_automower/sensor.py
@@ -349,6 +349,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
key="number_of_collisions",
translation_key="number_of_collisions",
entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL,
exists_fn=lambda data: data.statistics.number_of_collisions is not None,
value_fn=attrgetter("statistics.number_of_collisions"),
diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json
index 0f06e9c521e..d4c91e29f7d 100644
--- a/homeassistant/components/husqvarna_automower/strings.json
+++ b/homeassistant/components/husqvarna_automower/strings.json
@@ -27,7 +27,9 @@
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You can only reauthenticate this entry with the same Husqvarna account.",
- "missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal."
+ "no_mower_connected": "No mowers connected to this account.",
+ "missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal.",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@@ -314,7 +316,7 @@
"issues": {
"deprecated_entity": {
"title": "The Husqvarna Automower {entity_name} sensor is deprecated",
- "description": "The Husqavarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`."
+ "description": "The Husqvarna Automower entity `{entity}` is deprecated and will be removed in a future release.\nYou can use the new returning state of the lawn mower entity instead.\nPlease update your automations and scripts to replace the sensor entity with the newly added lawn mower entity.\nWhen you are done migrating you can disable `{entity}`."
}
},
"services": {
diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py
index 2bbe5c87624..352b4c59ba1 100644
--- a/homeassistant/components/husqvarna_automower/switch.py
+++ b/homeassistant/components/husqvarna_automower/switch.py
@@ -19,6 +19,8 @@ from .entity import (
handle_sending_exception,
)
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json
index 3e72d9707c7..7566b5c9d32 100644
--- a/homeassistant/components/husqvarna_automower_ble/manifest.json
+++ b/homeassistant/components/husqvarna_automower_ble/manifest.json
@@ -10,7 +10,7 @@
"codeowners": ["@alistair23"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
- "documentation": "https://www.home-assistant.io/integrations/???",
+ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.0"]
}
diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json
index 9678dc83e5f..50f803c07dc 100644
--- a/homeassistant/components/hydrawise/manifest.json
+++ b/homeassistant/components/hydrawise/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
- "requirements": ["pydrawise==2024.9.0"]
+ "requirements": ["pydrawise==2024.12.0"]
}
diff --git a/homeassistant/components/hyperion/manifest.json b/homeassistant/components/hyperion/manifest.json
index f18491044fa..684fb276f53 100644
--- a/homeassistant/components/hyperion/manifest.json
+++ b/homeassistant/components/hyperion/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hyperion",
"iot_class": "local_push",
"loggers": ["hyperion"],
- "quality_scale": "platinum",
"requirements": ["hyperion-py==0.7.5"],
"ssdp": [
{
diff --git a/homeassistant/components/iammeter/manifest.json b/homeassistant/components/iammeter/manifest.json
index f1ebecab00d..22831767e62 100644
--- a/homeassistant/components/iammeter/manifest.json
+++ b/homeassistant/components/iammeter/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/iammeter",
"iot_class": "local_polling",
"loggers": ["iammeter"],
+ "quality_scale": "legacy",
"requirements": ["iammeter==0.2.1"]
}
diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json
index 17a5f519274..0f8c9eaafc9 100644
--- a/homeassistant/components/idasen_desk/manifest.json
+++ b/homeassistant/components/idasen_desk/manifest.json
@@ -11,6 +11,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/idasen_desk",
"iot_class": "local_push",
- "quality_scale": "silver",
"requirements": ["idasen-ha==2.6.2"]
}
diff --git a/homeassistant/components/idteck_prox/manifest.json b/homeassistant/components/idteck_prox/manifest.json
index e1d9b8a7ba8..92055908591 100644
--- a/homeassistant/components/idteck_prox/manifest.json
+++ b/homeassistant/components/idteck_prox/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/idteck_prox",
"iot_class": "local_push",
"loggers": ["rfk101py"],
+ "quality_scale": "legacy",
"requirements": ["rfk101py==0.0.1"]
}
diff --git a/homeassistant/components/iglo/manifest.json b/homeassistant/components/iglo/manifest.json
index f270d06bcae..7ce4804a516 100644
--- a/homeassistant/components/iglo/manifest.json
+++ b/homeassistant/components/iglo/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/iglo",
"iot_class": "local_polling",
"loggers": ["iglo"],
+ "quality_scale": "legacy",
"requirements": ["iglo==1.2.7"]
}
diff --git a/homeassistant/components/ign_sismologia/manifest.json b/homeassistant/components/ign_sismologia/manifest.json
index c76013f6821..d371f0d3614 100644
--- a/homeassistant/components/ign_sismologia/manifest.json
+++ b/homeassistant/components/ign_sismologia/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["georss_ign_sismologia_client"],
+ "quality_scale": "legacy",
"requirements": ["georss-ign-sismologia-client==0.8"]
}
diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json
index 2400206c3a0..68cc1b2c754 100644
--- a/homeassistant/components/ihc/manifest.json
+++ b/homeassistant/components/ihc/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ihc",
"iot_class": "local_push",
"loggers": ["ihcsdk"],
+ "quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"]
}
diff --git a/homeassistant/components/image_upload/media_source.py b/homeassistant/components/image_upload/media_source.py
new file mode 100644
index 00000000000..ee9511e2c36
--- /dev/null
+++ b/homeassistant/components/image_upload/media_source.py
@@ -0,0 +1,76 @@
+"""Expose image_upload as media sources."""
+
+from __future__ import annotations
+
+from homeassistant.components.media_player import BrowseError, MediaClass
+from homeassistant.components.media_source import (
+ BrowseMediaSource,
+ MediaSource,
+ MediaSourceItem,
+ PlayMedia,
+ Unresolvable,
+)
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN
+
+
+async def async_get_media_source(hass: HomeAssistant) -> ImageUploadMediaSource:
+ """Set up image media source."""
+ return ImageUploadMediaSource(hass)
+
+
+class ImageUploadMediaSource(MediaSource):
+ """Provide images as media sources."""
+
+ name: str = "Image Upload"
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize ImageMediaSource."""
+ super().__init__(DOMAIN)
+ self.hass = hass
+
+ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
+ """Resolve media to a url."""
+ image = self.hass.data[DOMAIN].data.get(item.identifier)
+
+ if not image:
+ raise Unresolvable(f"Could not resolve media item: {item.identifier}")
+
+ return PlayMedia(
+ f"/api/image/serve/{image['id']}/original", image["content_type"]
+ )
+
+ async def async_browse_media(
+ self,
+ item: MediaSourceItem,
+ ) -> BrowseMediaSource:
+ """Return media."""
+ if item.identifier:
+ raise BrowseError("Unknown item")
+
+ children = [
+ BrowseMediaSource(
+ domain=DOMAIN,
+ identifier=image["id"],
+ media_class=MediaClass.IMAGE,
+ media_content_type=image["content_type"],
+ title=image["name"],
+ thumbnail=f"/api/image/serve/{image['id']}/256x256",
+ can_play=True,
+ can_expand=False,
+ )
+ for image in self.hass.data[DOMAIN].data.values()
+ ]
+
+ return BrowseMediaSource(
+ domain=DOMAIN,
+ identifier=None,
+ media_class=MediaClass.APP,
+ media_content_type="",
+ title="Image Upload",
+ can_play=False,
+ can_expand=True,
+ children_media_class=MediaClass.IMAGE,
+ children=children,
+ )
diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py
index 994c53b5b3e..df0e63e200a 100644
--- a/homeassistant/components/imap/config_flow.py
+++ b/homeassistant/components/imap/config_flow.py
@@ -9,12 +9,7 @@ from typing import Any
from aioimaplib import AioImapException
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
@@ -35,6 +30,7 @@ from homeassistant.helpers.selector import (
)
from homeassistant.util.ssl import SSLCipherList
+from . import ImapConfigEntry
from .const import (
CONF_CHARSET,
CONF_CUSTOM_EVENT_DATA_TEMPLATE,
@@ -212,7 +208,7 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: ImapConfigEntry,
) -> ImapOptionsFlow:
"""Get the options flow for this handler."""
return ImapOptionsFlow()
diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py
index a9d0fdfbd48..1df107196ff 100644
--- a/homeassistant/components/imap/coordinator.py
+++ b/homeassistant/components/imap/coordinator.py
@@ -14,7 +14,6 @@ from typing import TYPE_CHECKING, Any
from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
CONF_PORT,
@@ -53,6 +52,9 @@ from .const import (
)
from .errors import InvalidAuth, InvalidFolder
+if TYPE_CHECKING:
+ from . import ImapConfigEntry
+
_LOGGER = logging.getLogger(__name__)
BACKOFF_TIME = 10
@@ -210,14 +212,14 @@ class ImapMessage:
class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
"""Base class for imap client."""
- config_entry: ConfigEntry
+ config_entry: ImapConfigEntry
custom_event_template: Template | None
def __init__(
self,
hass: HomeAssistant,
imap_client: IMAP4_SSL,
- entry: ConfigEntry,
+ entry: ImapConfigEntry,
update_interval: timedelta | None,
) -> None:
"""Initiate imap client."""
@@ -332,7 +334,17 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
raise UpdateFailed(
f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}"
)
- if not (count := len(message_ids := lines[0].split())):
+ # Check we do have returned items.
+ #
+ # In rare cases, when no UID's are returned,
+ # only the status line is returned, and not an empty line.
+ # See: https://github.com/home-assistant/core/issues/132042
+ #
+ # Strictly the RfC notes that 0 or more numbers should be returned
+ # delimited by a space.
+ #
+ # See: https://datatracker.ietf.org/doc/html/rfc3501#section-7.2.5
+ if len(lines) == 1 or not (count := len(message_ids := lines[0].split())):
self._last_message_uid = None
return 0
last_message_uid = (
@@ -391,7 +403,7 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator):
"""Class for imap client."""
def __init__(
- self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
+ self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ImapConfigEntry
) -> None:
"""Initiate imap client."""
_LOGGER.debug(
@@ -437,7 +449,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
"""Class for imap client."""
def __init__(
- self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry
+ self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ImapConfigEntry
) -> None:
"""Initiate imap client."""
_LOGGER.debug("Connected to server %s using IMAP push", entry.data[CONF_SERVER])
diff --git a/homeassistant/components/imap/quality_scale.yaml b/homeassistant/components/imap/quality_scale.yaml
new file mode 100644
index 00000000000..180aef93f91
--- /dev/null
+++ b/homeassistant/components/imap/quality_scale.yaml
@@ -0,0 +1,97 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency:
+ status: todo
+ comment: |
+ The package is only tested, but not built and published inside a CI pipeline yet.
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: >
+ Per IMAP service instance there is one numeric sensor entity to reflect
+ the actual number of emails for a service. There is no event registration.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ Logs for unavailability are on debug level to avoid flooding the logs.
+ entity-unavailable:
+ status: done
+ comment: >
+ An entity is available as long as the service is loaded.
+ An `unknown` value is set if the mail service is temporary unavailable.
+ action-exceptions: done
+ reauthentication-flow: done
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters: done
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default:
+ status: done
+ comment: The only entity supplied returns the primary value for the service.
+ discovery:
+ status: exempt
+ comment: |
+ Discovery for IMAP services is not desirerable.
+ stale-devices:
+ status: exempt
+ comment: >
+ The device class is a service. When removed, entities are removed as well.
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow:
+ status: todo
+ comment: |
+ Options can be set through the option flow, reconfiguration is not supported yet.
+ dynamic-devices:
+ status: exempt
+ comment: |
+ The device class is a service.
+ discovery-update-info:
+ status: exempt
+ comment: Discovery is not desirable for this integration.
+ repair-issues:
+ status: exempt
+ comment: There are no repairs currently.
+ docs-use-cases: done
+ docs-supported-devices:
+ status: exempt
+ comment: The device class is a service.
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting: done
+ docs-examples: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ This integration does not use web sessions.
+ strict-typing: done
diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py
index 625af9ce6a1..b484586e057 100644
--- a/homeassistant/components/imap/sensor.py
+++ b/homeassistant/components/imap/sensor.py
@@ -7,7 +7,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import CONF_USERNAME
+from homeassistant.const import CONF_USERNAME, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -19,10 +19,10 @@ from .coordinator import ImapDataUpdateCoordinator
IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription(
key="imap_mail_count",
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
translation_key="imap_mail_count",
- name=None,
)
diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json
index 7c4a0d9a973..8ff5d838199 100644
--- a/homeassistant/components/imap/strings.json
+++ b/homeassistant/components/imap/strings.json
@@ -10,8 +10,21 @@
"charset": "Character set",
"folder": "Folder",
"search": "IMAP search",
+ "event_message_data": "Message data to be included in the `imap_content` event data:",
"ssl_cipher_list": "SSL cipher list (Advanced)",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "username": "The IMAP username.",
+ "password": "The IMAP password",
+ "server": "The IMAP server.",
+ "port": "The IMAP port supporting SSL, usually this is 993.",
+ "charset": "The character set used. Common values are `utf-8` or `US-ASCII`.",
+ "folder": "In generally the folder is set to `INBOX`, but e.g. in case of a sub folder, named `Test`, this should be `INBOX.Test`.",
+ "search": "The IMAP search command which is `UnSeen UnDeleted` by default.",
+ "event_message_data": "Note that the event size is limited, and not all message text might be sent with the event if the message is too large.",
+ "ssl_cipher_list": "If the IMAP service only supports legacy encryption, try to change this.",
+ "verify_ssl": "Recommended, to ensure the server certificate is valid. Turn off, if the server certificate is not trusted (e.g. self signed)."
}
},
"reauth_confirm": {
@@ -19,6 +32,9 @@
"title": "[%key:common::config_flow::title::reauth%]",
"data": {
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "Correct the IMAP password."
}
}
},
@@ -35,6 +51,14 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
+ "entity": {
+ "sensor": {
+ "imap_mail_count": {
+ "name": "Messages",
+ "unit_of_measurement": "messages"
+ }
+ }
+ },
"exceptions": {
"copy_failed": {
"message": "Copying the message failed with \"{error}\"."
@@ -73,7 +97,15 @@
"custom_event_data_template": "Template to create custom event data",
"max_message_size": "Max message size (2048 < size < 30000)",
"enable_push": "Enable Push-IMAP if the server supports it. Turn off if Push-IMAP updates are unreliable.",
- "event_message_data": "Message data to be included in the `imap_content` event data:"
+ "event_message_data": "Message data to be included in the `imap_content` event data."
+ },
+ "data_description": {
+ "folder": "[%key:component::imap::config::step::user::data_description::folder%]",
+ "search": "[%key:component::imap::config::step::user::data_description::search%]",
+ "event_message_data": "[%key:component::imap::config::step::user::data_description::event_message_data%]",
+ "custom_event_data_template": "This template is evaluated when a new message was received, and the result is added to the `custom` attribute of the event data.",
+ "max_message_size": "Limit the maximum size of the event. Instead of passing the (whole) text message, using a template is a better option.",
+ "enable_push": "Using Push-IMAP is recommended. Polling will increase the time to respond."
}
}
},
diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json
index c01be10fc68..b5c35f3f1eb 100644
--- a/homeassistant/components/imgw_pib/manifest.json
+++ b/homeassistant/components/imgw_pib/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["imgw_pib==1.0.6"]
}
diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json
index ad3f282eff7..55af2b37fb7 100644
--- a/homeassistant/components/influxdb/manifest.json
+++ b/homeassistant/components/influxdb/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/influxdb",
"iot_class": "local_push",
"loggers": ["influxdb", "influxdb_client"],
+ "quality_scale": "legacy",
"requirements": ["influxdb==5.3.1", "influxdb-client==1.24.0"]
}
diff --git a/homeassistant/components/input_number/strings.json b/homeassistant/components/input_number/strings.json
index 8a2351ebad4..ed6b6fad208 100644
--- a/homeassistant/components/input_number/strings.json
+++ b/homeassistant/components/input_number/strings.json
@@ -41,7 +41,7 @@
},
"increment": {
"name": "Increment",
- "description": "Increments the value by 1 step."
+ "description": "Increments the current value by 1 step."
},
"set_value": {
"name": "Set",
diff --git a/homeassistant/components/integration/strings.json b/homeassistant/components/integration/strings.json
index 6186521aa1b..ed4f5de3ea7 100644
--- a/homeassistant/components/integration/strings.json
+++ b/homeassistant/components/integration/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Riemann sum integral sensor",
+ "title": "Create Riemann sum integral sensor",
"description": "Create a sensor that calculates a Riemann sum to estimate the integral of a sensor.",
"data": {
"method": "Integration method",
diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py
index 1322576f115..1ffb8747d91 100644
--- a/homeassistant/components/intent/__init__.py
+++ b/homeassistant/components/intent/__init__.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from datetime import datetime
import logging
from typing import Any, Protocol
@@ -42,9 +41,11 @@ from homeassistant.const import (
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State
from homeassistant.helpers import config_validation as cv, integration_platform, intent
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import dt as dt_util
from .const import DOMAIN, TIMER_DATA
from .timers import (
+ CancelAllTimersIntentHandler,
CancelTimerIntentHandler,
DecreaseTimerIntentHandler,
IncreaseTimerIntentHandler,
@@ -130,6 +131,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
intent.async_register(hass, SetPositionIntentHandler())
intent.async_register(hass, StartTimerIntentHandler())
intent.async_register(hass, CancelTimerIntentHandler())
+ intent.async_register(hass, CancelAllTimersIntentHandler())
intent.async_register(hass, IncreaseTimerIntentHandler())
intent.async_register(hass, DecreaseTimerIntentHandler())
intent.async_register(hass, PauseTimerIntentHandler())
@@ -405,7 +407,7 @@ class GetCurrentDateIntentHandler(intent.IntentHandler):
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
response = intent_obj.create_response()
- response.async_set_speech_slots({"date": datetime.now().date()})
+ response.async_set_speech_slots({"date": dt_util.now().date()})
return response
@@ -417,7 +419,7 @@ class GetCurrentTimeIntentHandler(intent.IntentHandler):
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
response = intent_obj.create_response()
- response.async_set_speech_slots({"time": datetime.now().time()})
+ response.async_set_speech_slots({"time": dt_util.now().time()})
return response
diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py
index 639744abc66..84b96492241 100644
--- a/homeassistant/components/intent/timers.py
+++ b/homeassistant/components/intent/timers.py
@@ -887,6 +887,36 @@ class CancelTimerIntentHandler(intent.IntentHandler):
return intent_obj.create_response()
+class CancelAllTimersIntentHandler(intent.IntentHandler):
+ """Intent handler for cancelling all timers."""
+
+ intent_type = intent.INTENT_CANCEL_ALL_TIMERS
+ description = "Cancels all timers"
+ slot_schema = {
+ vol.Optional("area"): cv.string,
+ }
+
+ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
+ """Handle the intent."""
+ hass = intent_obj.hass
+ timer_manager: TimerManager = hass.data[TIMER_DATA]
+ slots = self.async_validate_slots(intent_obj.slots)
+ canceled = 0
+
+ for timer in _find_timers(hass, intent_obj.device_id, slots):
+ timer_manager.cancel_timer(timer.id)
+ canceled += 1
+
+ response = intent_obj.create_response()
+ speech_slots = {"canceled": canceled}
+ if "area" in slots:
+ speech_slots["area"] = slots["area"]["value"]
+
+ response.async_set_speech_slots(speech_slots)
+
+ return response
+
+
class IncreaseTimerIntentHandler(intent.IntentHandler):
"""Intent handler for increasing the time of a timer."""
diff --git a/homeassistant/components/intesishome/manifest.json b/homeassistant/components/intesishome/manifest.json
index 6b7a579d99f..ab306fb4773 100644
--- a/homeassistant/components/intesishome/manifest.json
+++ b/homeassistant/components/intesishome/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/intesishome",
"iot_class": "cloud_push",
"loggers": ["pyintesishome"],
+ "quality_scale": "legacy",
"requirements": ["pyintesishome==1.8.0"]
}
diff --git a/homeassistant/components/iperf3/manifest.json b/homeassistant/components/iperf3/manifest.json
index a1bb26ddc1a..16e33e47331 100644
--- a/homeassistant/components/iperf3/manifest.json
+++ b/homeassistant/components/iperf3/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/iperf3",
"iot_class": "local_polling",
"loggers": ["iperf3"],
+ "quality_scale": "legacy",
"requirements": ["iperf3==0.1.11"]
}
diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json
index baa41cf00bd..54c26b63585 100644
--- a/homeassistant/components/ipp/manifest.json
+++ b/homeassistant/components/ipp/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["deepmerge", "pyipp"],
- "quality_scale": "platinum",
"requirements": ["pyipp==0.17.0"],
"zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."]
}
diff --git a/homeassistant/components/irish_rail_transport/manifest.json b/homeassistant/components/irish_rail_transport/manifest.json
index bb9b0d59ef0..2a118f17e2a 100644
--- a/homeassistant/components/irish_rail_transport/manifest.json
+++ b/homeassistant/components/irish_rail_transport/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/irish_rail_transport",
"iot_class": "cloud_polling",
"loggers": ["pyirishrail"],
+ "quality_scale": "legacy",
"requirements": ["pyirishrail==0.0.2"]
}
diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py
index 56a83117e68..35b426d11ab 100644
--- a/homeassistant/components/iron_os/__init__.py
+++ b/homeassistant/components/iron_os/__init__.py
@@ -19,15 +19,22 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
-from .coordinator import IronOSFirmwareUpdateCoordinator, IronOSLiveDataCoordinator
+from .coordinator import (
+ IronOSCoordinators,
+ IronOSFirmwareUpdateCoordinator,
+ IronOSLiveDataCoordinator,
+ IronOSSettingsCoordinator,
+)
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.UPDATE]
-type IronOSConfigEntry = ConfigEntry[IronOSLiveDataCoordinator]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+type IronOSConfigEntry = ConfigEntry[IronOSCoordinators]
IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN)
-CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@@ -59,10 +66,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo
device = Pynecil(ble_device)
- coordinator = IronOSLiveDataCoordinator(hass, device)
- await coordinator.async_config_entry_first_refresh()
+ live_data = IronOSLiveDataCoordinator(hass, device)
+ await live_data.async_config_entry_first_refresh()
- entry.runtime_data = coordinator
+ settings = IronOSSettingsCoordinator(hass, device)
+ await settings.async_config_entry_first_refresh()
+
+ entry.runtime_data = IronOSCoordinators(
+ live_data=live_data,
+ settings=settings,
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py
index 699f5a01704..690dd6f1893 100644
--- a/homeassistant/components/iron_os/coordinator.py
+++ b/homeassistant/components/iron_os/coordinator.py
@@ -2,15 +2,23 @@
from __future__ import annotations
+from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from aiogithubapi import GitHubAPI, GitHubException, GitHubReleaseModel
-from pynecil import CommunicationError, DeviceInfoResponse, LiveDataResponse, Pynecil
+from pynecil import (
+ CommunicationError,
+ DeviceInfoResponse,
+ LiveDataResponse,
+ Pynecil,
+ SettingsDataResponse,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -19,24 +27,58 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=5)
SCAN_INTERVAL_GITHUB = timedelta(hours=3)
+SCAN_INTERVAL_SETTINGS = timedelta(seconds=60)
-class IronOSLiveDataCoordinator(DataUpdateCoordinator[LiveDataResponse]):
- """IronOS live data coordinator."""
+@dataclass
+class IronOSCoordinators:
+ """IronOS data class holding coordinators."""
+
+ live_data: IronOSLiveDataCoordinator
+ settings: IronOSSettingsCoordinator
+
+
+class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
+ """IronOS base coordinator."""
device_info: DeviceInfoResponse
config_entry: ConfigEntry
- def __init__(self, hass: HomeAssistant, device: Pynecil) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ device: Pynecil,
+ update_interval: timedelta,
+ ) -> None:
"""Initialize IronOS coordinator."""
+
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
- update_interval=SCAN_INTERVAL,
+ update_interval=update_interval,
+ request_refresh_debouncer=Debouncer(
+ hass, _LOGGER, cooldown=3, immediate=False
+ ),
)
self.device = device
+ async def _async_setup(self) -> None:
+ """Set up the coordinator."""
+ try:
+ self.device_info = await self.device.get_device_info()
+
+ except CommunicationError as e:
+ raise UpdateFailed("Cannot connect to device") from e
+
+
+class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]):
+ """IronOS coordinator."""
+
+ def __init__(self, hass: HomeAssistant, device: Pynecil) -> None:
+ """Initialize IronOS coordinator."""
+ super().__init__(hass, device=device, update_interval=SCAN_INTERVAL)
+
async def _async_update_data(self) -> LiveDataResponse:
"""Fetch data from Device."""
@@ -80,3 +122,24 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel])
assert release.data
return release.data
+
+
+class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
+ """IronOS coordinator."""
+
+ def __init__(self, hass: HomeAssistant, device: Pynecil) -> None:
+ """Initialize IronOS coordinator."""
+ super().__init__(hass, device=device, update_interval=SCAN_INTERVAL_SETTINGS)
+
+ async def _async_update_data(self) -> SettingsDataResponse:
+ """Fetch data from Device."""
+
+ characteristics = set(self.async_contexts())
+
+ if self.device.is_connected and characteristics:
+ try:
+ return await self.device.get_settings(list(characteristics))
+ except CommunicationError as e:
+ _LOGGER.debug("Failed to fetch settings", exc_info=e)
+
+ return self.data or SettingsDataResponse()
diff --git a/homeassistant/components/iron_os/entity.py b/homeassistant/components/iron_os/entity.py
index 77bebda9390..684957a2197 100644
--- a/homeassistant/components/iron_os/entity.py
+++ b/homeassistant/components/iron_os/entity.py
@@ -2,28 +2,29 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import MANUFACTURER, MODEL
-from .coordinator import IronOSLiveDataCoordinator
+from .coordinator import IronOSBaseCoordinator
-class IronOSBaseEntity(CoordinatorEntity[IronOSLiveDataCoordinator]):
+class IronOSBaseEntity(CoordinatorEntity[IronOSBaseCoordinator]):
"""Base IronOS entity."""
_attr_has_entity_name = True
def __init__(
self,
- coordinator: IronOSLiveDataCoordinator,
+ coordinator: IronOSBaseCoordinator,
entity_description: EntityDescription,
+ context: Any | None = None,
) -> None:
"""Initialize the sensor."""
- super().__init__(coordinator)
+ super().__init__(coordinator, context=context)
self.entity_description = entity_description
self._attr_unique_id = (
diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json
index fa14b8134d0..24d27457689 100644
--- a/homeassistant/components/iron_os/icons.json
+++ b/homeassistant/components/iron_os/icons.json
@@ -3,6 +3,63 @@
"number": {
"setpoint_temperature": {
"default": "mdi:thermometer"
+ },
+ "sleep_temperature": {
+ "default": "mdi:thermometer-low"
+ },
+ "sleep_timeout": {
+ "default": "mdi:timer-sand"
+ },
+ "qc_max_voltage": {
+ "default": "mdi:flash-alert-outline"
+ },
+ "pd_timeout": {
+ "default": "mdi:timer-alert-outline"
+ },
+ "boost_temp": {
+ "default": "mdi:thermometer-high"
+ },
+ "shutdown_timeout": {
+ "default": "mdi:thermometer-off"
+ },
+ "display_brightness": {
+ "default": "mdi:brightness-6"
+ },
+ "voltage_div": {
+ "default": "mdi:call-split"
+ },
+ "temp_increment_short": {
+ "default": "mdi:gesture-tap-button"
+ },
+ "temp_increment_long": {
+ "default": "mdi:gesture-tap-button"
+ },
+ "accel_sensitivity": {
+ "default": "mdi:motion"
+ },
+ "calibration_offset": {
+ "default": "mdi:contrast"
+ },
+ "hall_sensitivity": {
+ "default": "mdi:leak"
+ },
+ "keep_awake_pulse_delay": {
+ "default": "mdi:clock-end"
+ },
+ "keep_awake_pulse_duration": {
+ "default": "mdi:clock-start"
+ },
+ "keep_awake_pulse_power": {
+ "default": "mdi:waves-arrow-up"
+ },
+ "min_voltage_per_cell": {
+ "default": "mdi:fuel-cell"
+ },
+ "min_dc_voltage_cells": {
+ "default": "mdi:battery-arrow-down"
+ },
+ "power_limit": {
+ "default": "mdi:flash-alert"
}
},
"sensor": {
diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json
index 4ec08a43b61..3141273e3f0 100644
--- a/homeassistant/components/iron_os/manifest.json
+++ b/homeassistant/components/iron_os/manifest.json
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/iron_os",
"iot_class": "local_polling",
"loggers": ["pynecil", "aiogithubapi"],
- "requirements": ["pynecil==0.2.1", "aiogithubapi==24.6.0"]
+ "requirements": ["pynecil==1.0.1", "aiogithubapi==24.6.0"]
}
diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py
index 9230faec1f1..a288a61b021 100644
--- a/homeassistant/components/iron_os/number.py
+++ b/homeassistant/components/iron_os/number.py
@@ -6,37 +6,76 @@ from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
-from pynecil import CharSetting, CommunicationError, LiveDataResponse
+from pynecil import (
+ CharSetting,
+ CommunicationError,
+ LiveDataResponse,
+ SettingsDataResponse,
+)
from homeassistant.components.number import (
+ DEFAULT_MAX_VALUE,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
-from homeassistant.const import UnitOfTemperature
+from homeassistant.const import (
+ EntityCategory,
+ UnitOfElectricPotential,
+ UnitOfPower,
+ UnitOfTemperature,
+ UnitOfTime,
+)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import IronOSConfigEntry
from .const import DOMAIN, MAX_TEMP, MIN_TEMP
+from .coordinator import IronOSCoordinators
from .entity import IronOSBaseEntity
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class IronOSNumberEntityDescription(NumberEntityDescription):
"""Describes IronOS number entity."""
- value_fn: Callable[[LiveDataResponse], float | int | None]
- max_value_fn: Callable[[LiveDataResponse], float | int]
- set_key: CharSetting
+ value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None]
+ max_value_fn: Callable[[LiveDataResponse], float | int] | None = None
+ characteristic: CharSetting
+ raw_value_fn: Callable[[float], float | int] | None = None
class PinecilNumber(StrEnum):
"""Number controls for Pinecil device."""
SETPOINT_TEMP = "setpoint_temperature"
+ SLEEP_TEMP = "sleep_temperature"
+ SLEEP_TIMEOUT = "sleep_timeout"
+ QC_MAX_VOLTAGE = "qc_max_voltage"
+ PD_TIMEOUT = "pd_timeout"
+ BOOST_TEMP = "boost_temp"
+ SHUTDOWN_TIMEOUT = "shutdown_timeout"
+ DISPLAY_BRIGHTNESS = "display_brightness"
+ POWER_LIMIT = "power_limit"
+ CALIBRATION_OFFSET = "calibration_offset"
+ HALL_SENSITIVITY = "hall_sensitivity"
+ MIN_VOLTAGE_PER_CELL = "min_voltage_per_cell"
+ ACCEL_SENSITIVITY = "accel_sensitivity"
+ KEEP_AWAKE_PULSE_POWER = "keep_awake_pulse_power"
+ KEEP_AWAKE_PULSE_DELAY = "keep_awake_pulse_delay"
+ KEEP_AWAKE_PULSE_DURATION = "keep_awake_pulse_duration"
+ VOLTAGE_DIV = "voltage_div"
+ TEMP_INCREMENT_SHORT = "temp_increment_short"
+ TEMP_INCREMENT_LONG = "temp_increment_long"
+
+
+def multiply(value: float | None, multiplier: float) -> float | None:
+ """Multiply if not None."""
+ return value * multiplier if value is not None else None
PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
@@ -45,13 +84,249 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
translation_key=PinecilNumber.SETPOINT_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
- value_fn=lambda data: data.setpoint_temp,
- set_key=CharSetting.SETPOINT_TEMP,
+ value_fn=lambda data, _: data.setpoint_temp,
+ characteristic=CharSetting.SETPOINT_TEMP,
mode=NumberMode.BOX,
native_min_value=MIN_TEMP,
native_step=5,
max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP),
),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.SLEEP_TEMP,
+ translation_key=PinecilNumber.SLEEP_TEMP,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ value_fn=lambda _, settings: settings.get("sleep_temp"),
+ characteristic=CharSetting.SLEEP_TEMP,
+ mode=NumberMode.BOX,
+ native_min_value=MIN_TEMP,
+ native_max_value=MAX_TEMP,
+ native_step=10,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.BOOST_TEMP,
+ translation_key=PinecilNumber.BOOST_TEMP,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ value_fn=lambda _, settings: settings.get("boost_temp"),
+ characteristic=CharSetting.BOOST_TEMP,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=MAX_TEMP,
+ native_step=10,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.QC_MAX_VOLTAGE,
+ translation_key=PinecilNumber.QC_MAX_VOLTAGE,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=NumberDeviceClass.VOLTAGE,
+ value_fn=lambda _, settings: settings.get("qc_ideal_voltage"),
+ characteristic=CharSetting.QC_IDEAL_VOLTAGE,
+ mode=NumberMode.BOX,
+ native_min_value=9.0,
+ native_max_value=22.0,
+ native_step=0.1,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.PD_TIMEOUT,
+ translation_key=PinecilNumber.PD_TIMEOUT,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ device_class=NumberDeviceClass.DURATION,
+ value_fn=lambda _, settings: settings.get("pd_negotiation_timeout"),
+ characteristic=CharSetting.PD_NEGOTIATION_TIMEOUT,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=5.0,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.SHUTDOWN_TIMEOUT,
+ translation_key=PinecilNumber.SHUTDOWN_TIMEOUT,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ device_class=NumberDeviceClass.DURATION,
+ value_fn=lambda _, settings: settings.get("shutdown_time"),
+ characteristic=CharSetting.SHUTDOWN_TIME,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=60,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.DISPLAY_BRIGHTNESS,
+ translation_key=PinecilNumber.DISPLAY_BRIGHTNESS,
+ value_fn=lambda _, settings: settings.get("display_brightness"),
+ characteristic=CharSetting.DISPLAY_BRIGHTNESS,
+ mode=NumberMode.SLIDER,
+ native_min_value=1,
+ native_max_value=5,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.SLEEP_TIMEOUT,
+ translation_key=PinecilNumber.SLEEP_TIMEOUT,
+ value_fn=lambda _, settings: settings.get("sleep_timeout"),
+ characteristic=CharSetting.SLEEP_TIMEOUT,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=15,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.POWER_LIMIT,
+ translation_key=PinecilNumber.POWER_LIMIT,
+ value_fn=lambda _, settings: settings.get("power_limit"),
+ characteristic=CharSetting.POWER_LIMIT,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=12,
+ native_step=0.1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.CALIBRATION_OFFSET,
+ translation_key=PinecilNumber.CALIBRATION_OFFSET,
+ value_fn=lambda _, settings: settings.get("calibration_offset"),
+ characteristic=CharSetting.CALIBRATION_OFFSET,
+ mode=NumberMode.BOX,
+ native_min_value=100,
+ native_max_value=2500,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfElectricPotential.MICROVOLT,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.HALL_SENSITIVITY,
+ translation_key=PinecilNumber.HALL_SENSITIVITY,
+ value_fn=lambda _, settings: settings.get("hall_sensitivity"),
+ characteristic=CharSetting.HALL_SENSITIVITY,
+ mode=NumberMode.SLIDER,
+ native_min_value=0,
+ native_max_value=9,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.MIN_VOLTAGE_PER_CELL,
+ translation_key=PinecilNumber.MIN_VOLTAGE_PER_CELL,
+ value_fn=lambda _, settings: settings.get("min_voltage_per_cell"),
+ characteristic=CharSetting.MIN_VOLTAGE_PER_CELL,
+ mode=NumberMode.BOX,
+ native_min_value=2.4,
+ native_max_value=3.8,
+ native_step=0.1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.ACCEL_SENSITIVITY,
+ translation_key=PinecilNumber.ACCEL_SENSITIVITY,
+ value_fn=lambda _, settings: settings.get("accel_sensitivity"),
+ characteristic=CharSetting.ACCEL_SENSITIVITY,
+ mode=NumberMode.SLIDER,
+ native_min_value=0,
+ native_max_value=9,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.KEEP_AWAKE_PULSE_POWER,
+ translation_key=PinecilNumber.KEEP_AWAKE_PULSE_POWER,
+ value_fn=lambda _, settings: settings.get("keep_awake_pulse_power"),
+ characteristic=CharSetting.KEEP_AWAKE_PULSE_POWER,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=9.9,
+ native_step=0.1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.KEEP_AWAKE_PULSE_DELAY,
+ translation_key=PinecilNumber.KEEP_AWAKE_PULSE_DELAY,
+ value_fn=(
+ lambda _, settings: multiply(settings.get("keep_awake_pulse_delay"), 2.5)
+ ),
+ characteristic=CharSetting.KEEP_AWAKE_PULSE_DELAY,
+ raw_value_fn=lambda value: value / 2.5,
+ mode=NumberMode.BOX,
+ native_min_value=2.5,
+ native_max_value=22.5,
+ native_step=2.5,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.KEEP_AWAKE_PULSE_DURATION,
+ translation_key=PinecilNumber.KEEP_AWAKE_PULSE_DURATION,
+ value_fn=(
+ lambda _, settings: multiply(settings.get("keep_awake_pulse_duration"), 250)
+ ),
+ characteristic=CharSetting.KEEP_AWAKE_PULSE_DURATION,
+ raw_value_fn=lambda value: value / 250,
+ mode=NumberMode.BOX,
+ native_min_value=250,
+ native_max_value=2250,
+ native_step=250,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTime.MILLISECONDS,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.VOLTAGE_DIV,
+ translation_key=PinecilNumber.VOLTAGE_DIV,
+ value_fn=(lambda _, settings: settings.get("voltage_div")),
+ characteristic=CharSetting.VOLTAGE_DIV,
+ raw_value_fn=lambda value: value,
+ mode=NumberMode.BOX,
+ native_min_value=360,
+ native_max_value=900,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.TEMP_INCREMENT_SHORT,
+ translation_key=PinecilNumber.TEMP_INCREMENT_SHORT,
+ value_fn=(lambda _, settings: settings.get("temp_increment_short")),
+ characteristic=CharSetting.TEMP_INCREMENT_SHORT,
+ raw_value_fn=lambda value: value,
+ mode=NumberMode.BOX,
+ native_min_value=1,
+ native_max_value=50,
+ native_step=1,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ ),
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.TEMP_INCREMENT_LONG,
+ translation_key=PinecilNumber.TEMP_INCREMENT_LONG,
+ value_fn=(lambda _, settings: settings.get("temp_increment_long")),
+ characteristic=CharSetting.TEMP_INCREMENT_LONG,
+ raw_value_fn=lambda value: value,
+ mode=NumberMode.BOX,
+ native_min_value=5,
+ native_max_value=90,
+ native_step=5,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ ),
)
@@ -74,23 +349,56 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
entity_description: IronOSNumberEntityDescription
+ def __init__(
+ self,
+ coordinator: IronOSCoordinators,
+ entity_description: IronOSNumberEntityDescription,
+ ) -> None:
+ """Initialize the number entity."""
+ super().__init__(
+ coordinator.live_data, entity_description, entity_description.characteristic
+ )
+
+ self.settings = coordinator.settings
+
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
+ if raw_value_fn := self.entity_description.raw_value_fn:
+ value = raw_value_fn(value)
try:
- await self.coordinator.device.write(self.entity_description.set_key, value)
+ await self.coordinator.device.write(
+ self.entity_description.characteristic, value
+ )
except CommunicationError as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="submit_setting_failed",
) from e
- self.async_write_ha_state()
+ await self.settings.async_request_refresh()
@property
def native_value(self) -> float | int | None:
"""Return sensor state."""
- return self.entity_description.value_fn(self.coordinator.data)
+ return self.entity_description.value_fn(
+ self.coordinator.data, self.settings.data
+ )
@property
def native_max_value(self) -> float:
"""Return sensor state."""
- return self.entity_description.max_value_fn(self.coordinator.data)
+
+ if self.entity_description.max_value_fn is not None:
+ return self.entity_description.max_value_fn(self.coordinator.data)
+
+ return self.entity_description.native_max_value or DEFAULT_MAX_VALUE
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.settings.async_add_listener(
+ self._handle_coordinator_update, self.entity_description.characteristic
+ )
+ )
+ await self.settings.async_request_refresh()
diff --git a/homeassistant/components/iron_os/sensor.py b/homeassistant/components/iron_os/sensor.py
index 095ffd254df..05d56db26d3 100644
--- a/homeassistant/components/iron_os/sensor.py
+++ b/homeassistant/components/iron_os/sensor.py
@@ -107,6 +107,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
native_unit_of_measurement=OHM,
value_fn=lambda data: data.tip_resistance,
entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
),
IronOSSensorEntityDescription(
key=PinecilSensor.UPTIME,
@@ -137,10 +138,10 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
IronOSSensorEntityDescription(
key=PinecilSensor.TIP_VOLTAGE,
translation_key=PinecilSensor.TIP_VOLTAGE,
- native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
+ native_unit_of_measurement=UnitOfElectricPotential.MICROVOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
- suggested_display_precision=3,
+ suggested_display_precision=0,
value_fn=lambda data: data.tip_voltage,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -180,7 +181,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors from a config entry."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.live_data
async_add_entities(
IronOSSensorEntity(coordinator, description)
diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json
index 75584fe191c..c474b704677 100644
--- a/homeassistant/components/iron_os/strings.json
+++ b/homeassistant/components/iron_os/strings.json
@@ -5,10 +5,13 @@
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "address": "Ensure your device is powered on and within Bluetooth range before continuing"
}
},
"bluetooth_confirm": {
- "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
+ "description": "Do you want to set up {name}?\n\n*Ensure your device is powered on and within Bluetooth range before continuing*"
}
},
"abort": {
@@ -20,6 +23,60 @@
"number": {
"setpoint_temperature": {
"name": "Setpoint temperature"
+ },
+ "sleep_temperature": {
+ "name": "Sleep temperature"
+ },
+ "sleep_timeout": {
+ "name": "Sleep timeout"
+ },
+ "qc_max_voltage": {
+ "name": "Quick Charge voltage"
+ },
+ "pd_timeout": {
+ "name": "Power Delivery timeout"
+ },
+ "boost_temp": {
+ "name": "Boost temperature"
+ },
+ "shutdown_timeout": {
+ "name": "Shutdown timeout"
+ },
+ "display_brightness": {
+ "name": "Display brightness"
+ },
+ "power_limit": {
+ "name": "Power limit"
+ },
+ "calibration_offset": {
+ "name": "Calibration offset"
+ },
+ "hall_sensitivity": {
+ "name": "Hall effect sensitivity"
+ },
+ "min_voltage_per_cell": {
+ "name": "Min. voltage per cell"
+ },
+ "accel_sensitivity": {
+ "name": "Motion sensitivity"
+ },
+ "keep_awake_pulse_power": {
+ "name": "Keep-awake pulse intensity"
+ },
+ "keep_awake_pulse_delay": {
+ "name": "Keep-awake pulse delay"
+ },
+ "keep_awake_pulse_duration": {
+ "name": "Keep-awake pulse duration"
+ },
+ "voltage_div": {
+ "name": "Voltage divider"
+ },
+ "temp_increment_short": {
+ "name": "Short-press temperature step"
+ },
+ "temp_increment_long": {
+ "name": "Long-press temperature step"
}
},
"sensor": {
diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py
index 786ba86f730..b431d321f24 100644
--- a/homeassistant/components/iron_os/update.py
+++ b/homeassistant/components/iron_os/update.py
@@ -15,6 +15,8 @@ from . import IRON_OS_KEY, IronOSConfigEntry, IronOSLiveDataCoordinator
from .coordinator import IronOSFirmwareUpdateCoordinator
from .entity import IronOSBaseEntity
+PARALLEL_UPDATES = 0
+
UPDATE_DESCRIPTION = UpdateEntityDescription(
key="firmware",
device_class=UpdateDeviceClass.FIRMWARE,
@@ -28,7 +30,7 @@ async def async_setup_entry(
) -> None:
"""Set up IronOS update platform."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data.live_data
async_add_entities(
[IronOSUpdate(coordinator, hass.data[IRON_OS_KEY], UPDATE_DESCRIPTION)]
diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py
index 7aa1adfe4c9..779a5d5c55f 100644
--- a/homeassistant/components/ista_ecotrend/sensor.py
+++ b/homeassistant/components/ista_ecotrend/sensor.py
@@ -71,7 +71,6 @@ SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = (
translation_key=IstaSensorEntity.HEATING,
suggested_display_precision=0,
consumption_type=IstaConsumptionType.HEATING,
- native_unit_of_measurement="units",
state_class=SensorStateClass.TOTAL,
),
IstaSensorEntityDescription(
diff --git a/homeassistant/components/ista_ecotrend/strings.json b/homeassistant/components/ista_ecotrend/strings.json
index f76cf5286cb..e7c37461b19 100644
--- a/homeassistant/components/ista_ecotrend/strings.json
+++ b/homeassistant/components/ista_ecotrend/strings.json
@@ -14,14 +14,23 @@
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
- }
+ },
+ "data_description": {
+ "email": "Enter the email address associated with your ista EcoTrend account",
+ "password": "Enter the password for your ista EcoTrend account"
+ },
+ "description": "Connect your **ista EcoTrend** account to Home Assistant to access your monthly heating and water usage data."
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
- "description": "Please reenter the password for: {email}",
+ "description": "Re-enter your password for `{email}` to reconnect your ista EcoTrend account to Home Assistant.",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "[%key:component::ista_ecotrend::config::step::user::data_description::email%]",
+ "password": "[%key:component::ista_ecotrend::config::step::user::data_description::password%]"
}
}
}
@@ -29,7 +38,8 @@
"entity": {
"sensor": {
"heating": {
- "name": "Heating"
+ "name": "Heating",
+ "unit_of_measurement": "units"
},
"heating_cost": {
"name": "Heating cost"
diff --git a/homeassistant/components/itach/manifest.json b/homeassistant/components/itach/manifest.json
index 2928620b952..68b34b4321e 100644
--- a/homeassistant/components/itach/manifest.json
+++ b/homeassistant/components/itach/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/itach",
"iot_class": "assumed_state",
+ "quality_scale": "legacy",
"requirements": ["pyitachip2ir==0.0.7"]
}
diff --git a/homeassistant/components/itunes/manifest.json b/homeassistant/components/itunes/manifest.json
index f1135dbf847..a12271d04d7 100644
--- a/homeassistant/components/itunes/manifest.json
+++ b/homeassistant/components/itunes/manifest.json
@@ -3,5 +3,6 @@
"name": "Apple iTunes",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/itunes",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py
index 24aeecab7e5..5c519f661ee 100644
--- a/homeassistant/components/jellyfin/sensor.py
+++ b/homeassistant/components/jellyfin/sensor.py
@@ -36,7 +36,6 @@ SENSOR_TYPES: tuple[JellyfinSensorEntityDescription, ...] = (
key="watching",
translation_key="watching",
value_fn=_count_now_playing,
- native_unit_of_measurement="clients",
),
)
diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json
index f2afa0c8ad5..a9816b1fb78 100644
--- a/homeassistant/components/jellyfin/strings.json
+++ b/homeassistant/components/jellyfin/strings.json
@@ -29,7 +29,8 @@
"entity": {
"sensor": {
"watching": {
- "name": "Active clients"
+ "name": "Active clients",
+ "unit_of_measurement": "clients"
}
}
},
diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py
index ad5ac8e2137..1d2a6e45c0a 100644
--- a/homeassistant/components/jewish_calendar/entity.py
+++ b/homeassistant/components/jewish_calendar/entity.py
@@ -44,6 +44,7 @@ class JewishCalendarEntity(Entity):
data = config_entry.runtime_data
self._location = data.location
self._hebrew = data.language == "hebrew"
+ self._language = data.language
self._candle_lighting_offset = data.candle_lighting_offset
self._havdalah_offset = data.havdalah_offset
self._diaspora = data.diaspora
diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json
index 2642f6c81e9..aca45320002 100644
--- a/homeassistant/components/jewish_calendar/manifest.json
+++ b/homeassistant/components/jewish_calendar/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/jewish_calendar",
"iot_class": "calculated",
"loggers": ["hdate"],
- "quality_scale": "silver",
- "requirements": ["hdate==0.10.9"],
+ "requirements": ["hdate==0.11.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py
index c32647af07c..d3e70eb411c 100644
--- a/homeassistant/components/jewish_calendar/sensor.py
+++ b/homeassistant/components/jewish_calendar/sensor.py
@@ -275,15 +275,18 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
# Compute the weekly portion based on the upcoming shabbat.
return after_tzais_date.upcoming_shabbat.parasha
if self.entity_description.key == "holiday":
- self._attrs = {
- "id": after_shkia_date.holiday_name,
- "type": after_shkia_date.holiday_type.name,
- "type_id": after_shkia_date.holiday_type.value,
- }
- self._attr_options = [
- h.description.hebrew.long if self._hebrew else h.description.english
- for h in htables.HOLIDAYS
- ]
+ _id = _type = _type_id = ""
+ _holiday_type = after_shkia_date.holiday_type
+ if isinstance(_holiday_type, list):
+ _id = ", ".join(after_shkia_date.holiday_name)
+ _type = ", ".join([_htype.name for _htype in _holiday_type])
+ _type_id = ", ".join([str(_htype.value) for _htype in _holiday_type])
+ else:
+ _id = after_shkia_date.holiday_name
+ _type = _holiday_type.name
+ _type_id = _holiday_type.value
+ self._attrs = {"id": _id, "type": _type, "type_id": _type_id}
+ self._attr_options = htables.get_all_holidays(self._language)
return after_shkia_date.holiday_description
if self.entity_description.key == "omer_count":
diff --git a/homeassistant/components/joaoapps_join/manifest.json b/homeassistant/components/joaoapps_join/manifest.json
index 36d54ec6d55..55a908bf090 100644
--- a/homeassistant/components/joaoapps_join/manifest.json
+++ b/homeassistant/components/joaoapps_join/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/joaoapps_join",
"iot_class": "cloud_push",
"loggers": ["pyjoin"],
+ "quality_scale": "legacy",
"requirements": ["python-join-api==0.0.9"]
}
diff --git a/homeassistant/components/kaiterra/manifest.json b/homeassistant/components/kaiterra/manifest.json
index 12ac1559fd7..88651565cd0 100644
--- a/homeassistant/components/kaiterra/manifest.json
+++ b/homeassistant/components/kaiterra/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kaiterra",
"iot_class": "cloud_polling",
"loggers": ["kaiterra_async_client"],
+ "quality_scale": "legacy",
"requirements": ["kaiterra-async-client==1.0.0"]
}
diff --git a/homeassistant/components/kankun/manifest.json b/homeassistant/components/kankun/manifest.json
index c15a87eacaa..473209508ac 100644
--- a/homeassistant/components/kankun/manifest.json
+++ b/homeassistant/components/kankun/manifest.json
@@ -3,5 +3,6 @@
"name": "Kankun",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/kankun",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/keba/manifest.json b/homeassistant/components/keba/manifest.json
index 42f2762ef3d..d86ce053187 100644
--- a/homeassistant/components/keba/manifest.json
+++ b/homeassistant/components/keba/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/keba",
"iot_class": "local_polling",
"loggers": ["keba_kecontact"],
+ "quality_scale": "legacy",
"requirements": ["keba-kecontact==1.1.0"]
}
diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json
index 29e398994f4..1bbce2ff35d 100644
--- a/homeassistant/components/kef/manifest.json
+++ b/homeassistant/components/kef/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kef",
"iot_class": "local_polling",
"loggers": ["aiokef", "tenacity"],
+ "quality_scale": "legacy",
"requirements": ["aiokef==0.2.16", "getmac==0.9.4"]
}
diff --git a/homeassistant/components/keyboard/manifest.json b/homeassistant/components/keyboard/manifest.json
index ea6d0aa20c2..e4a6606fb80 100644
--- a/homeassistant/components/keyboard/manifest.json
+++ b/homeassistant/components/keyboard/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/keyboard",
"iot_class": "local_push",
"loggers": ["pykeyboard"],
+ "quality_scale": "legacy",
"requirements": ["pyuserinput==0.1.11"]
}
diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json
index bb84b32defc..b405f36bb23 100644
--- a/homeassistant/components/keyboard_remote/manifest.json
+++ b/homeassistant/components/keyboard_remote/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aionotify", "evdev"],
+ "quality_scale": "legacy",
"requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"]
}
diff --git a/homeassistant/components/kira/manifest.json b/homeassistant/components/kira/manifest.json
index c8a476b07c9..60901d13f4e 100644
--- a/homeassistant/components/kira/manifest.json
+++ b/homeassistant/components/kira/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kira",
"iot_class": "local_push",
"loggers": ["pykira"],
+ "quality_scale": "legacy",
"requirements": ["pykira==0.1.1"]
}
diff --git a/homeassistant/components/kiwi/manifest.json b/homeassistant/components/kiwi/manifest.json
index 60b0d1fd28b..74a27776128 100644
--- a/homeassistant/components/kiwi/manifest.json
+++ b/homeassistant/components/kiwi/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kiwi",
"iot_class": "cloud_polling",
"loggers": ["kiwiki"],
+ "quality_scale": "legacy",
"requirements": ["kiwiki-client==0.1.1"]
}
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index fe6f3ad8892..ea654c358e7 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -29,7 +29,6 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.storage import STORAGE_DIR
@@ -55,6 +54,7 @@ from .const import (
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_LOG_SIZE,
+ CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE,
@@ -102,20 +102,6 @@ _KNX_YAML_CONFIG: Final = "knx_yaml_config"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
- # deprecated since 2021.12
- cv.deprecated(CONF_KNX_STATE_UPDATER),
- cv.deprecated(CONF_KNX_RATE_LIMIT),
- cv.deprecated(CONF_KNX_ROUTING),
- cv.deprecated(CONF_KNX_TUNNELING),
- cv.deprecated(CONF_KNX_INDIVIDUAL_ADDRESS),
- cv.deprecated(CONF_KNX_MCAST_GRP),
- cv.deprecated(CONF_KNX_MCAST_PORT),
- cv.deprecated("event_filter"),
- # deprecated since 2021.4
- cv.deprecated("config_file"),
- # deprecated since 2021.2
- cv.deprecated("fire_event"),
- cv.deprecated("fire_event_filter"),
vol.Schema(
{
**EventSchema.SCHEMA,
@@ -367,6 +353,7 @@ class KNXModule:
if _conn_type == CONF_KNX_TUNNELING_TCP:
return ConnectionConfig(
connection_type=ConnectionType.TUNNELING_TCP,
+ individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA),
gateway_ip=self.entry.data[CONF_HOST],
gateway_port=self.entry.data[CONF_PORT],
auto_reconnect=True,
@@ -379,6 +366,7 @@ class KNXModule:
if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE:
return ConnectionConfig(
connection_type=ConnectionType.TUNNELING_TCP_SECURE,
+ individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA),
gateway_ip=self.entry.data[CONF_HOST],
gateway_port=self.entry.data[CONF_PORT],
secure_config=SecureConfig(
diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py
index 7a9dfc34546..a946ded0359 100644
--- a/homeassistant/components/knx/const.py
+++ b/homeassistant/components/knx/const.py
@@ -104,7 +104,7 @@ class KNXConfigEntryData(TypedDict, total=False):
route_back: bool # not required
host: str # only required for tunnelling
port: int # only required for tunnelling
- tunnel_endpoint_ia: str | None
+ tunnel_endpoint_ia: str | None # tunnelling only - not required (use get())
# KNX secure
user_id: int | None # not required
user_password: str | None # not required
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index 39e3dced0d5..aed7f3ed455 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -9,7 +9,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["xknx", "xknxproject"],
- "quality_scale": "platinum",
"requirements": [
"xknx==3.3.0",
"xknxproject==3.8.1",
diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py
index bf2fc55e5c9..9311046e410 100644
--- a/homeassistant/components/knx/schema.py
+++ b/homeassistant/components/knx/schema.py
@@ -222,9 +222,6 @@ class BinarySensorSchema(KNXPlatformSchema):
DEFAULT_NAME = "KNX Binary Sensor"
ENTITY_SCHEMA = vol.All(
- # deprecated since September 2020
- cv.deprecated("significant_bit"),
- cv.deprecated("automation"),
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
@@ -358,10 +355,6 @@ class ClimateSchema(KNXPlatformSchema):
DEFAULT_FAN_SPEED_MODE = "percent"
ENTITY_SCHEMA = vol.All(
- # deprecated since September 2020
- cv.deprecated("setpoint_shift_step", replacement_key=CONF_TEMPERATURE_STEP),
- # deprecated since 2021.6
- cv.deprecated("create_temperature_sensors"),
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
@@ -969,8 +962,6 @@ class WeatherSchema(KNXPlatformSchema):
DEFAULT_NAME = "KNX Weather Station"
ENTITY_SCHEMA = vol.All(
- # deprecated since 2021.6
- cv.deprecated("create_sensors"),
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json
index 8d8692f6b7a..08b921f316b 100644
--- a/homeassistant/components/knx/strings.json
+++ b/homeassistant/components/knx/strings.json
@@ -294,19 +294,24 @@
"name": "Connection type"
},
"telegrams_incoming": {
- "name": "Incoming telegrams"
+ "name": "Incoming telegrams",
+ "unit_of_measurement": "[%key:component::knx::entity::sensor::telegram_count::unit_of_measurement%]"
},
"telegrams_incoming_error": {
- "name": "Incoming telegram errors"
+ "name": "Incoming telegram errors",
+ "unit_of_measurement": "errors"
},
"telegrams_outgoing": {
- "name": "Outgoing telegrams"
+ "name": "Outgoing telegrams",
+ "unit_of_measurement": "[%key:component::knx::entity::sensor::telegram_count::unit_of_measurement%]"
},
"telegrams_outgoing_error": {
- "name": "Outgoing telegram errors"
+ "name": "Outgoing telegram errors",
+ "unit_of_measurement": "[%key:component::knx::entity::sensor::telegrams_incoming_error::unit_of_measurement%]"
},
"telegram_count": {
- "name": "Telegrams"
+ "name": "Telegrams",
+ "unit_of_measurement": "telegrams"
}
}
},
diff --git a/homeassistant/components/kwb/manifest.json b/homeassistant/components/kwb/manifest.json
index 36d3a0af2d7..6a11e08555f 100644
--- a/homeassistant/components/kwb/manifest.json
+++ b/homeassistant/components/kwb/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/kwb",
"iot_class": "local_polling",
"loggers": ["pykwb"],
+ "quality_scale": "legacy",
"requirements": ["pykwb==0.0.8"]
}
diff --git a/homeassistant/components/lacrosse/manifest.json b/homeassistant/components/lacrosse/manifest.json
index 0c7cf8b6dc6..b4023b533ca 100644
--- a/homeassistant/components/lacrosse/manifest.json
+++ b/homeassistant/components/lacrosse/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/lacrosse",
"iot_class": "local_polling",
"loggers": ["pylacrosse"],
+ "quality_scale": "legacy",
"requirements": ["pylacrosse==0.4"]
}
diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py
index da513bc8cff..a69b97242f3 100644
--- a/homeassistant/components/lamarzocco/__init__.py
+++ b/homeassistant/components/lamarzocco/__init__.py
@@ -10,7 +10,6 @@ from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.components.bluetooth import async_discovered_service_info
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
@@ -23,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
-from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.httpx_client import create_async_httpx_client
from .const import CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
@@ -47,11 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
assert entry.unique_id
serial = entry.unique_id
-
+ client = create_async_httpx_client(hass)
cloud_client = LaMarzoccoCloudClient(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
- client=get_async_client(hass),
+ client=client,
)
# initialize local API
@@ -61,7 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
local_client = LaMarzoccoLocalClient(
host=host,
local_bearer=entry.data[CONF_TOKEN],
- client=get_async_client(hass),
+ client=client,
)
# initialize Bluetooth
@@ -125,7 +124,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ async def update_listener(
+ hass: HomeAssistant, entry: LaMarzoccoConfigEntry
+ ) -> None:
await hass.config_entries.async_reload(entry.entry_id)
entry.async_on_unload(entry.add_update_listener(update_listener))
@@ -133,12 +134,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, entry: LaMarzoccoConfigEntry
+) -> bool:
"""Migrate config entry."""
if entry.version > 2:
# guard against downgrade from a future version
diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py
index ae79e21897f..dabf01d817d 100644
--- a/homeassistant/components/lamarzocco/button.py
+++ b/homeassistant/components/lamarzocco/button.py
@@ -16,6 +16,7 @@ from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+PARALLEL_UPDATES = 1
BACKFLUSH_ENABLED_DURATION = 15
diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py
index 04e705edbdc..05dfcbc5196 100644
--- a/homeassistant/components/lamarzocco/config_flow.py
+++ b/homeassistant/components/lamarzocco/config_flow.py
@@ -6,6 +6,7 @@ from collections.abc import Mapping
import logging
from typing import Any
+from httpx import AsyncClient
from pylamarzocco.client_cloud import LaMarzoccoCloudClient
from pylamarzocco.client_local import LaMarzoccoLocalClient
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
@@ -20,12 +21,12 @@ from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
- ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import (
+ CONF_ADDRESS,
CONF_HOST,
CONF_MAC,
CONF_MODEL,
@@ -36,7 +37,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.httpx_client import create_async_httpx_client
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
@@ -45,6 +46,7 @@ from homeassistant.helpers.selector import (
)
from .const import CONF_USE_BLUETOOTH, DOMAIN
+from .coordinator import LaMarzoccoConfigEntry
CONF_MACHINE = "machine"
@@ -56,6 +58,8 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 2
+ _client: AsyncClient
+
def __init__(self) -> None:
"""Initialize the config flow."""
self._config: dict[str, Any] = {}
@@ -78,10 +82,12 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
**user_input,
**self._discovered,
}
+ self._client = create_async_httpx_client(self.hass)
cloud_client = LaMarzoccoCloudClient(
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
+ client=self._client,
)
try:
self._fleet = await cloud_client.get_customer_fleet()
@@ -125,6 +131,12 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
self._config = data
return await self.async_step_machine_selection()
+ placeholders: dict[str, str] | None = None
+ if self._discovered:
+ self.context["title_placeholders"] = placeholders = {
+ CONF_NAME: self._discovered[CONF_MACHINE]
+ }
+
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
@@ -134,6 +146,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
}
),
errors=errors,
+ description_placeholders=placeholders,
)
async def async_step_machine_selection(
@@ -155,7 +168,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
# validate local connection if host is provided
if user_input.get(CONF_HOST):
if not await LaMarzoccoLocalClient.validate_connection(
- client=get_async_client(self.hass),
+ client=self._client,
host=user_input[CONF_HOST],
token=selected_device.communication_key,
):
@@ -277,7 +290,13 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
serial = discovery_info.hostname.upper()
await self.async_set_unique_id(serial)
- self._abort_if_unique_id_configured()
+ self._abort_if_unique_id_configured(
+ updates={
+ CONF_HOST: discovery_info.ip,
+ CONF_ADDRESS: discovery_info.macaddress,
+ }
+ )
+ self._async_abort_entries_match({CONF_ADDRESS: discovery_info.macaddress})
_LOGGER.debug(
"Discovered La Marzocco machine %s through DHCP at address %s",
@@ -287,6 +306,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered[CONF_MACHINE] = serial
self._discovered[CONF_HOST] = discovery_info.ip
+ self._discovered[CONF_ADDRESS] = discovery_info.macaddress
return await self.async_step_user()
@@ -339,7 +359,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: LaMarzoccoConfigEntry,
) -> LmOptionsFlowHandler:
"""Create the options flow."""
return LmOptionsFlowHandler()
diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py
index 05fee98c599..46a8e05745e 100644
--- a/homeassistant/components/lamarzocco/coordinator.py
+++ b/homeassistant/components/lamarzocco/coordinator.py
@@ -13,6 +13,7 @@ from pylamarzocco.client_cloud import LaMarzoccoCloudClient
from pylamarzocco.client_local import LaMarzoccoLocalClient
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.lm_machine import LaMarzoccoMachine
+from websockets.protocol import State
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL, CONF_NAME, EVENT_HOMEASSISTANT_STOP
@@ -85,7 +86,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
if (
self._local_client is not None
and self._local_client.websocket is not None
- and self._local_client.websocket.open
+ and self._local_client.websocket.state is State.OPEN
):
self._local_client.terminating = True
await self._local_client.websocket.close()
@@ -126,9 +127,12 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
try:
await func(*args, **kwargs)
except AuthFail as ex:
- msg = "Authentication failed."
- _LOGGER.debug(msg, exc_info=True)
- raise ConfigEntryAuthFailed(msg) from ex
+ _LOGGER.debug("Authentication failed", exc_info=True)
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="authentication_failed"
+ ) from ex
except RequestNotSuccessful as ex:
_LOGGER.debug(ex, exc_info=True)
- raise UpdateFailed(f"Querying API failed. Error: {ex}") from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="api_error"
+ ) from ex
diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py
index 1ea84302a17..5542906d887 100644
--- a/homeassistant/components/lamarzocco/entity.py
+++ b/homeassistant/components/lamarzocco/entity.py
@@ -6,7 +6,12 @@ from dataclasses import dataclass
from pylamarzocco.const import FirmwareType
from pylamarzocco.lm_machine import LaMarzoccoMachine
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.const import CONF_ADDRESS, CONF_MAC
+from homeassistant.helpers.device_registry import (
+ CONNECTION_BLUETOOTH,
+ CONNECTION_NETWORK_MAC,
+ DeviceInfo,
+)
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -47,6 +52,17 @@ class LaMarzoccoBaseEntity(
serial_number=device.serial_number,
sw_version=device.firmware[FirmwareType.MACHINE].current_version,
)
+ connections: set[tuple[str, str]] = set()
+ if coordinator.config_entry.data.get(CONF_ADDRESS):
+ connections.add(
+ (CONNECTION_NETWORK_MAC, coordinator.config_entry.data[CONF_ADDRESS])
+ )
+ if coordinator.config_entry.data.get(CONF_MAC):
+ connections.add(
+ (CONNECTION_BLUETOOTH, coordinator.config_entry.data[CONF_MAC])
+ )
+ if connections:
+ self._attr_device_info.update(DeviceInfo(connections=connections))
class LaMarzoccoEntity(LaMarzoccoBaseEntity):
diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json
index 6b226051118..43b1c7deb47 100644
--- a/homeassistant/components/lamarzocco/manifest.json
+++ b/homeassistant/components/lamarzocco/manifest.json
@@ -19,6 +19,9 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"dhcp": [
+ {
+ "registered_devices": true
+ },
{
"hostname": "gs[0-9][0-9][0-9][0-9][0-9][0-9]"
},
@@ -33,5 +36,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["pylamarzocco"],
- "requirements": ["pylamarzocco==1.2.3"]
+ "requirements": ["pylamarzocco==1.2.12"]
}
diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py
index 825c5d6deb0..f32607fd73b 100644
--- a/homeassistant/components/lamarzocco/number.py
+++ b/homeassistant/components/lamarzocco/number.py
@@ -35,6 +35,8 @@ from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoNumberEntityDescription(
diff --git a/homeassistant/components/lamarzocco/quality_scale.yaml b/homeassistant/components/lamarzocco/quality_scale.yaml
new file mode 100644
index 00000000000..3677bd8d6b8
--- /dev/null
+++ b/homeassistant/components/lamarzocco/quality_scale.yaml
@@ -0,0 +1,87 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ No explicit event subscriptions.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ Handled by coordinator.
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery:
+ status: done
+ comment: |
+ DHCP & Bluetooth discovery.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues: done
+ stale-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: done
+ comment: |
+ Uses `httpx` session.
+ strict-typing: done
diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py
index 1889ba38d6b..637ef935979 100644
--- a/homeassistant/components/lamarzocco/select.py
+++ b/homeassistant/components/lamarzocco/select.py
@@ -19,6 +19,8 @@ from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+PARALLEL_UPDATES = 1
+
STEAM_LEVEL_HA_TO_LM = {
"1": SteamLevel.LEVEL_1,
"2": SteamLevel.LEVEL_2,
diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json
index 959dda265a9..666eb7f4a84 100644
--- a/homeassistant/components/lamarzocco/strings.json
+++ b/homeassistant/components/lamarzocco/strings.json
@@ -1,6 +1,5 @@
{
"config": {
- "flow_title": "La Marzocco Espresso {host}",
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
@@ -26,7 +25,10 @@
"bluetooth_selection": {
"description": "Select your device from available Bluetooth devices.",
"data": {
- "mac": "Bluetooth device"
+ "mac": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "mac": "Select the Bluetooth device that is your machine"
}
},
"machine_selection": {
@@ -36,7 +38,8 @@
"machine": "Machine"
},
"data_description": {
- "host": "Local IP address of the machine"
+ "host": "Local IP address of the machine",
+ "machine": "Select the machine you want to integrate"
}
},
"reauth_confirm": {
@@ -64,8 +67,10 @@
"step": {
"init": {
"data": {
- "title": "Update Configuration",
"use_bluetooth": "Use Bluetooth"
+ },
+ "data_description": {
+ "use_bluetooth": "Should the integration try to use Bluetooth to control the machine?"
}
}
}
@@ -196,6 +201,12 @@
}
},
"exceptions": {
+ "api_error": {
+ "message": "Error while communicating with the API"
+ },
+ "authentication_failed": {
+ "message": "Authentication failed"
+ },
"auto_on_off_error": {
"message": "Error while setting auto on/off to {state} for {id}"
},
diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py
index f7690885f05..4dc701c4c29 100644
--- a/homeassistant/components/lamarzocco/switch.py
+++ b/homeassistant/components/lamarzocco/switch.py
@@ -19,6 +19,8 @@ from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoBaseEntity, LaMarzoccoEntity, LaMarzoccoEntityDescription
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoSwitchEntityDescription(
@@ -108,7 +110,7 @@ class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_off_error",
- translation_placeholders={"name": self.entity_description.key},
+ translation_placeholders={"key": self.entity_description.key},
) from exc
self.async_write_ha_state()
diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py
index 371ff679bae..ca182909042 100644
--- a/homeassistant/components/lamarzocco/update.py
+++ b/homeassistant/components/lamarzocco/update.py
@@ -21,6 +21,8 @@ from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoUpdateEntityDescription(
diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json
index 92ccd29c916..b0c6f8fd96e 100644
--- a/homeassistant/components/lametric/manifest.json
+++ b/homeassistant/components/lametric/manifest.json
@@ -13,7 +13,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["demetriek"],
- "quality_scale": "platinum",
"requirements": ["demetriek==0.4.0"],
"ssdp": [
{
diff --git a/homeassistant/components/lannouncer/manifest.json b/homeassistant/components/lannouncer/manifest.json
index c04d9e87655..9d0942bd14f 100644
--- a/homeassistant/components/lannouncer/manifest.json
+++ b/homeassistant/components/lannouncer/manifest.json
@@ -3,5 +3,6 @@
"name": "LANnouncer",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/lannouncer",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py
index a8d3fe175ef..657524f0ef5 100644
--- a/homeassistant/components/lg_thinq/__init__.py
+++ b/homeassistant/components/lg_thinq/__init__.py
@@ -95,6 +95,7 @@ async def async_setup_coordinators(
raise ConfigEntryNotReady(exc.message) from exc
if not bridge_list:
+ _LOGGER.warning("No devices registered with the correct profile")
return
# Setup coordinator per device.
diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py
index 9ead57ab7b0..5cf9ccbd442 100644
--- a/homeassistant/components/lg_thinq/climate.py
+++ b/homeassistant/components/lg_thinq/climate.py
@@ -12,7 +12,6 @@ from thinqconnect.integration import ExtendedProperty
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
- FAN_OFF,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
@@ -37,7 +36,7 @@ class ThinQClimateEntityDescription(ClimateEntityDescription):
step: float | None = None
-DEVIE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = {
+DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = {
DeviceType.AIR_CONDITIONER: (
ThinQClimateEntityDescription(
key=ExtendedProperty.CLIMATE_AIR_CONDITIONER,
@@ -86,7 +85,7 @@ async def async_setup_entry(
entities: list[ThinQClimateEntity] = []
for coordinator in entry.runtime_data.coordinators.values():
if (
- descriptions := DEVIE_TYPE_CLIMATE_MAP.get(
+ descriptions := DEVICE_TYPE_CLIMATE_MAP.get(
coordinator.api.device.device_type
)
) is not None:
@@ -149,10 +148,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
super()._update_status()
# Update fan, hvac and preset mode.
+ if self.supported_features & ClimateEntityFeature.FAN_MODE:
+ self._attr_fan_mode = self.data.fan_mode
if self.data.is_on:
- if self.supported_features & ClimateEntityFeature.FAN_MODE:
- self._attr_fan_mode = self.data.fan_mode
-
hvac_mode = self._requested_hvac_mode or self.data.hvac_mode
if hvac_mode in STR_TO_HVAC:
self._attr_hvac_mode = STR_TO_HVAC.get(hvac_mode)
@@ -160,9 +158,6 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
elif hvac_mode in THINQ_PRESET_MODE:
self._attr_preset_mode = hvac_mode
else:
- if self.supported_features & ClimateEntityFeature.FAN_MODE:
- self._attr_fan_mode = FAN_OFF
-
self._attr_hvac_mode = HVACMode.OFF
self._attr_preset_mode = None
@@ -170,6 +165,7 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_current_humidity = self.data.humidity
self._attr_current_temperature = self.data.current_temp
+ # Update min, max and step.
if (max_temp := self.entity_description.max_temp) is not None or (
max_temp := self.data.max
) is not None:
@@ -184,26 +180,18 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_target_temperature_step = step
# Update target temperatures.
- if (
- self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
- and self.hvac_mode == HVACMode.AUTO
- ):
- self._attr_target_temperature = None
- self._attr_target_temperature_high = self.data.target_temp_high
- self._attr_target_temperature_low = self.data.target_temp_low
- else:
- self._attr_target_temperature = self.data.target_temp
- self._attr_target_temperature_high = None
- self._attr_target_temperature_low = None
+ self._attr_target_temperature = self.data.target_temp
+ self._attr_target_temperature_high = self.data.target_temp_high
+ self._attr_target_temperature_low = self.data.target_temp_low
_LOGGER.debug(
- "[%s:%s] update status: %s/%s -> %s/%s, hvac:%s, unit:%s, step:%s",
+ "[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s",
self.coordinator.device_name,
self.property_id,
- self.data.current_temp,
- self.data.target_temp,
self.current_temperature,
self.target_temperature,
+ self.target_temperature_low,
+ self.target_temperature_high,
self.hvac_mode,
self.temperature_unit,
self.target_temperature_step,
diff --git a/homeassistant/components/lg_thinq/config_flow.py b/homeassistant/components/lg_thinq/config_flow.py
index cdb41916688..3bbcf3cd226 100644
--- a/homeassistant/components/lg_thinq/config_flow.py
+++ b/homeassistant/components/lg_thinq/config_flow.py
@@ -6,7 +6,7 @@ import logging
from typing import Any
import uuid
-from thinqconnect import ThinQApi, ThinQAPIException
+from thinqconnect import ThinQApi, ThinQAPIErrorCodes, ThinQAPIException
from thinqconnect.country import Country
import voluptuous as vol
@@ -26,6 +26,13 @@ from .const import (
)
SUPPORTED_COUNTRIES = [country.value for country in Country]
+THINQ_ERRORS = {
+ ThinQAPIErrorCodes.INVALID_TOKEN: "invalid_token",
+ ThinQAPIErrorCodes.NOT_ACCEPTABLE_TERMS: "not_acceptable_terms",
+ ThinQAPIErrorCodes.NOT_ALLOWED_API_AGAIN: "not_allowed_api_again",
+ ThinQAPIErrorCodes.NOT_SUPPORTED_COUNTRY: "not_supported_country",
+ ThinQAPIErrorCodes.EXCEEDED_API_CALLS: "exceeded_api_calls",
+}
_LOGGER = logging.getLogger(__name__)
@@ -83,8 +90,9 @@ class ThinQFlowHandler(ConfigFlow, domain=DOMAIN):
try:
return await self._validate_and_create_entry(access_token, country_code)
- except ThinQAPIException:
- errors["base"] = "token_unauthorized"
+ except ThinQAPIException as exc:
+ errors["base"] = THINQ_ERRORS.get(exc.code, "token_unauthorized")
+ _LOGGER.error("Failed to validate access_token %s", exc)
return self.async_show_form(
step_id="user",
diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py
index 0ba859b1228..9f317dc21d9 100644
--- a/homeassistant/components/lg_thinq/coordinator.py
+++ b/homeassistant/components/lg_thinq/coordinator.py
@@ -77,5 +77,9 @@ async def async_setup_device_coordinator(
coordinator = DeviceDataUpdateCoordinator(hass, ha_bridge)
await coordinator.async_refresh()
- _LOGGER.debug("Setup device's coordinator: %s", coordinator.device_name)
+ _LOGGER.debug(
+ "Setup device's coordinator: %s, model:%s",
+ coordinator.device_name,
+ coordinator.api.device.model_name,
+ )
return coordinator
diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py
index f31b535dcaf..7856506559b 100644
--- a/homeassistant/components/lg_thinq/entity.py
+++ b/homeassistant/components/lg_thinq/entity.py
@@ -51,7 +51,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, coordinator.unique_id)},
manufacturer=COMPANY,
- model=coordinator.api.device.model_name,
+ model=f"{coordinator.api.device.model_name} ({self.coordinator.api.device.device_type})",
name=coordinator.device_name,
)
self._attr_unique_id = f"{coordinator.unique_id}_{self.property_id}"
diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json
index 665a5a9e179..daab1353098 100644
--- a/homeassistant/components/lg_thinq/manifest.json
+++ b/homeassistant/components/lg_thinq/manifest.json
@@ -3,9 +3,8 @@
"name": "LG ThinQ",
"codeowners": ["@LG-ThinQ-Integration"],
"config_flow": true,
- "dependencies": [],
- "documentation": "https://www.home-assistant.io/integrations/lg_thinq/",
+ "documentation": "https://www.home-assistant.io/integrations/lg_thinq",
"iot_class": "cloud_push",
"loggers": ["thinqconnect"],
- "requirements": ["thinqconnect==1.0.0"]
+ "requirements": ["thinqconnect==1.0.1"]
}
diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py
index 30d1302e458..8759869aad3 100644
--- a/homeassistant/components/lg_thinq/mqtt.py
+++ b/homeassistant/components/lg_thinq/mqtt.py
@@ -167,7 +167,6 @@ class ThinQMQTT:
async def async_handle_device_event(self, message: dict) -> None:
"""Handle received mqtt message."""
- _LOGGER.debug("async_handle_device_event: message=%s", message)
unique_id = (
f"{message["deviceId"]}_{list(message["report"].keys())[0]}"
if message["deviceType"] == DeviceType.WASHTOWER
@@ -178,6 +177,12 @@ class ThinQMQTT:
_LOGGER.error("Failed to handle device event: No device")
return
+ _LOGGER.debug(
+ "async_handle_device_event: %s, model:%s, message=%s",
+ coordinator.device_name,
+ coordinator.api.device.model_name,
+ message,
+ )
push_type = message.get("pushType")
if push_type == DEVICE_STATUS_MESSAGE:
diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json
index 277e3db3df0..a776dde2054 100644
--- a/homeassistant/components/lg_thinq/strings.json
+++ b/homeassistant/components/lg_thinq/strings.json
@@ -5,6 +5,12 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
+ "invalid_token": "The token is not valid.",
+ "not_acceptable_terms": "The service terms are not accepted.",
+ "not_allowed_api_again": "The user does NOT have permission on the API call.",
+ "not_supported_country": "The country is not supported.",
+ "exceeded_api_calls": "The number of API calls has been exceeded.",
+ "exceeded_user_api_calls": "The number of User API calls has been exceeded.",
"token_unauthorized": "The token is invalid or unauthorized."
},
"step": {
diff --git a/homeassistant/components/lifx_cloud/manifest.json b/homeassistant/components/lifx_cloud/manifest.json
index 7799de85b8d..61e5d66c821 100644
--- a/homeassistant/components/lifx_cloud/manifest.json
+++ b/homeassistant/components/lifx_cloud/manifest.json
@@ -3,5 +3,6 @@
"name": "LIFX Cloud",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/lifx_cloud",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/lightwave/manifest.json b/homeassistant/components/lightwave/manifest.json
index d242195a71c..75b39b18c26 100644
--- a/homeassistant/components/lightwave/manifest.json
+++ b/homeassistant/components/lightwave/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/lightwave",
"iot_class": "assumed_state",
"loggers": ["lightwave"],
+ "quality_scale": "legacy",
"requirements": ["lightwave==0.24"]
}
diff --git a/homeassistant/components/limitlessled/manifest.json b/homeassistant/components/limitlessled/manifest.json
index 3495ac2c981..c2a921c6e24 100644
--- a/homeassistant/components/limitlessled/manifest.json
+++ b/homeassistant/components/limitlessled/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/limitlessled",
"iot_class": "assumed_state",
"loggers": ["limitlessled"],
+ "quality_scale": "legacy",
"requirements": ["limitlessled==1.1.3"]
}
diff --git a/homeassistant/components/linkplay/button.py b/homeassistant/components/linkplay/button.py
new file mode 100644
index 00000000000..1c93ebcdc3e
--- /dev/null
+++ b/homeassistant/components/linkplay/button.py
@@ -0,0 +1,82 @@
+"""Support for LinkPlay buttons."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+import logging
+from typing import Any
+
+from linkplay.bridge import LinkPlayBridge
+
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from . import LinkPlayConfigEntry
+from .entity import LinkPlayBaseEntity, exception_wrap
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class LinkPlayButtonEntityDescription(ButtonEntityDescription):
+ """Class describing LinkPlay button entities."""
+
+ remote_function: Callable[[LinkPlayBridge], Coroutine[Any, Any, None]]
+
+
+BUTTON_TYPES: tuple[LinkPlayButtonEntityDescription, ...] = (
+ LinkPlayButtonEntityDescription(
+ key="timesync",
+ translation_key="timesync",
+ remote_function=lambda linkplay_bridge: linkplay_bridge.device.timesync(),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ LinkPlayButtonEntityDescription(
+ key="restart",
+ device_class=ButtonDeviceClass.RESTART,
+ remote_function=lambda linkplay_bridge: linkplay_bridge.device.reboot(),
+ entity_category=EntityCategory.CONFIG,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: LinkPlayConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the LinkPlay buttons from config entry."""
+
+ # add entities
+ async_add_entities(
+ LinkPlayButton(config_entry.runtime_data.bridge, description)
+ for description in BUTTON_TYPES
+ )
+
+
+class LinkPlayButton(LinkPlayBaseEntity, ButtonEntity):
+ """Representation of LinkPlay button."""
+
+ entity_description: LinkPlayButtonEntityDescription
+
+ def __init__(
+ self,
+ bridge: LinkPlayBridge,
+ description: LinkPlayButtonEntityDescription,
+ ) -> None:
+ """Initialize LinkPlay button."""
+ super().__init__(bridge)
+ self.entity_description = description
+ self._attr_unique_id = f"{bridge.device.uuid}-{description.key}"
+
+ @exception_wrap
+ async def async_press(self) -> None:
+ """Press the button."""
+ await self.entity_description.remote_function(self._bridge)
diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py
index a776365e38f..e10450cf255 100644
--- a/homeassistant/components/linkplay/const.py
+++ b/homeassistant/components/linkplay/const.py
@@ -8,5 +8,5 @@ from homeassistant.util.hass_dict import HassKey
DOMAIN = "linkplay"
CONTROLLER = "controller"
CONTROLLER_KEY: HassKey[LinkPlayController] = HassKey(CONTROLLER)
-PLATFORMS = [Platform.MEDIA_PLAYER]
+PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
DATA_SESSION = "session"
diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py
new file mode 100644
index 00000000000..00e2f39b233
--- /dev/null
+++ b/homeassistant/components/linkplay/entity.py
@@ -0,0 +1,57 @@
+"""BaseEntity to support multiple LinkPlay platforms."""
+
+from collections.abc import Callable, Coroutine
+from typing import Any, Concatenate
+
+from linkplay.bridge import LinkPlayBridge
+
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN, LinkPlayRequestException
+from .utils import MANUFACTURER_GENERIC, get_info_from_project
+
+
+def exception_wrap[_LinkPlayEntityT: LinkPlayBaseEntity, **_P, _R](
+ func: Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]],
+) -> Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]]:
+ """Define a wrapper to catch exceptions and raise HomeAssistant errors."""
+
+ async def _wrap(self: _LinkPlayEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
+ try:
+ return await func(self, *args, **kwargs)
+ except LinkPlayRequestException as err:
+ raise HomeAssistantError(
+ f"Exception occurred when communicating with API {func}: {err}"
+ ) from err
+
+ return _wrap
+
+
+class LinkPlayBaseEntity(Entity):
+ """Representation of a LinkPlay base entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, bridge: LinkPlayBridge) -> None:
+ """Initialize the LinkPlay media player."""
+
+ self._bridge = bridge
+
+ manufacturer, model = get_info_from_project(bridge.device.properties["project"])
+ model_id = None
+ if model != MANUFACTURER_GENERIC:
+ model_id = bridge.device.properties["project"]
+
+ self._attr_device_info = dr.DeviceInfo(
+ configuration_url=bridge.endpoint,
+ connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])},
+ hw_version=bridge.device.properties["hardware"],
+ identifiers={(DOMAIN, bridge.device.uuid)},
+ manufacturer=manufacturer,
+ model=model,
+ model_id=model_id,
+ name=bridge.device.name,
+ sw_version=bridge.device.properties["firmware"],
+ )
diff --git a/homeassistant/components/linkplay/icons.json b/homeassistant/components/linkplay/icons.json
index ee76344dc39..c0fe86d9ac7 100644
--- a/homeassistant/components/linkplay/icons.json
+++ b/homeassistant/components/linkplay/icons.json
@@ -1,4 +1,11 @@
{
+ "entity": {
+ "button": {
+ "timesync": {
+ "default": "mdi:clock"
+ }
+ }
+ },
"services": {
"play_preset": {
"service": "mdi:play-box-outline"
diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py
index c29c2978522..456fbf23289 100644
--- a/homeassistant/components/linkplay/media_player.py
+++ b/homeassistant/components/linkplay/media_player.py
@@ -2,9 +2,8 @@
from __future__ import annotations
-from collections.abc import Callable, Coroutine
import logging
-from typing import Any, Concatenate
+from typing import Any
from linkplay.bridge import LinkPlayBridge
from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus
@@ -28,7 +27,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
- device_registry as dr,
entity_platform,
entity_registry as er,
)
@@ -37,7 +35,7 @@ from homeassistant.util.dt import utcnow
from . import LinkPlayConfigEntry, LinkPlayData
from .const import CONTROLLER_KEY, DOMAIN
-from .utils import MANUFACTURER_GENERIC, get_info_from_project
+from .entity import LinkPlayBaseEntity, exception_wrap
_LOGGER = logging.getLogger(__name__)
STATE_MAP: dict[PlayingStatus, MediaPlayerState] = {
@@ -145,58 +143,24 @@ async def async_setup_entry(
async_add_entities([LinkPlayMediaPlayerEntity(entry.runtime_data.bridge)])
-def exception_wrap[_LinkPlayEntityT: LinkPlayMediaPlayerEntity, **_P, _R](
- func: Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]],
-) -> Callable[Concatenate[_LinkPlayEntityT, _P], Coroutine[Any, Any, _R]]:
- """Define a wrapper to catch exceptions and raise HomeAssistant errors."""
-
- async def _wrap(self: _LinkPlayEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
- try:
- return await func(self, *args, **kwargs)
- except LinkPlayRequestException as err:
- raise HomeAssistantError(
- f"Exception occurred when communicating with API {func}: {err}"
- ) from err
-
- return _wrap
-
-
-class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
+class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity):
"""Representation of a LinkPlay media player."""
_attr_sound_mode_list = list(EQUALIZER_MAP.values())
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_media_content_type = MediaType.MUSIC
- _attr_has_entity_name = True
_attr_name = None
def __init__(self, bridge: LinkPlayBridge) -> None:
"""Initialize the LinkPlay media player."""
- self._bridge = bridge
+ super().__init__(bridge)
self._attr_unique_id = bridge.device.uuid
self._attr_source_list = [
SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support
]
- manufacturer, model = get_info_from_project(bridge.device.properties["project"])
- model_id = None
- if model != MANUFACTURER_GENERIC:
- model_id = bridge.device.properties["project"]
-
- self._attr_device_info = dr.DeviceInfo(
- configuration_url=bridge.endpoint,
- connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])},
- hw_version=bridge.device.properties["hardware"],
- identifiers={(DOMAIN, bridge.device.uuid)},
- manufacturer=manufacturer,
- model=model,
- model_id=model_id,
- name=bridge.device.name,
- sw_version=bridge.device.properties["firmware"],
- )
-
@exception_wrap
async def async_update(self) -> None:
"""Update the state of the media player."""
diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json
index f3495b293e0..31b4649e131 100644
--- a/homeassistant/components/linkplay/strings.json
+++ b/homeassistant/components/linkplay/strings.json
@@ -35,6 +35,13 @@
}
}
},
+ "entity": {
+ "button": {
+ "timesync": {
+ "name": "Sync time"
+ }
+ }
+ },
"exceptions": {
"invalid_grouping_entity": {
"message": "Entity with id {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay mediaplayer?"
diff --git a/homeassistant/components/linksys_smart/manifest.json b/homeassistant/components/linksys_smart/manifest.json
index 6200da5866d..4f099f81277 100644
--- a/homeassistant/components/linksys_smart/manifest.json
+++ b/homeassistant/components/linksys_smart/manifest.json
@@ -3,5 +3,6 @@
"name": "Linksys Smart Wi-Fi",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/linksys_smart",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/linode/manifest.json b/homeassistant/components/linode/manifest.json
index bedd6c2d172..975747de86d 100644
--- a/homeassistant/components/linode/manifest.json
+++ b/homeassistant/components/linode/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/linode",
"iot_class": "cloud_polling",
"loggers": ["linode"],
+ "quality_scale": "legacy",
"requirements": ["linode-api==4.1.9b1"]
}
diff --git a/homeassistant/components/linux_battery/manifest.json b/homeassistant/components/linux_battery/manifest.json
index 12b49c18aee..39bd331e3a4 100644
--- a/homeassistant/components/linux_battery/manifest.json
+++ b/homeassistant/components/linux_battery/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/linux_battery",
"iot_class": "local_polling",
"loggers": ["batinfo"],
+ "quality_scale": "legacy",
"requirements": ["batinfo==0.4.2"]
}
diff --git a/homeassistant/components/lirc/manifest.json b/homeassistant/components/lirc/manifest.json
index 3cc5d453721..64dbee06390 100644
--- a/homeassistant/components/lirc/manifest.json
+++ b/homeassistant/components/lirc/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/lirc",
"iot_class": "local_push",
"loggers": ["lirc"],
+ "quality_scale": "legacy",
"requirements": ["python-lirc==1.2.3"]
}
diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json
index 1df907029a9..cd2e5fda11a 100644
--- a/homeassistant/components/litejet/manifest.json
+++ b/homeassistant/components/litejet/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pylitejet"],
- "quality_scale": "platinum",
"requirements": ["pylitejet==0.6.3"],
"single_config_entry": true
}
diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py
index 26e36e68efa..fc9e381a1c3 100644
--- a/homeassistant/components/livisi/__init__.py
+++ b/homeassistant/components/livisi/__init__.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Final
from aiohttp import ClientConnectorError
-from aiolivisi import AioLivisi
+from livisi.aiolivisi import AioLivisi
from homeassistant import core
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py
index 56fe63d351f..5d70936fc53 100644
--- a/homeassistant/components/livisi/climate.py
+++ b/homeassistant/components/livisi/climate.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
-from aiolivisi.const import CAPABILITY_CONFIG
+from livisi.const import CAPABILITY_CONFIG
from homeassistant.components.climate import (
ClimateEntity,
diff --git a/homeassistant/components/livisi/config_flow.py b/homeassistant/components/livisi/config_flow.py
index 7317aec0abc..ce14c0e44e9 100644
--- a/homeassistant/components/livisi/config_flow.py
+++ b/homeassistant/components/livisi/config_flow.py
@@ -6,7 +6,8 @@ from contextlib import suppress
from typing import Any
from aiohttp import ClientConnectorError
-from aiolivisi import AioLivisi, errors as livisi_errors
+from livisi import errors as livisi_errors
+from livisi.aiolivisi import AioLivisi
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py
index 7cb5757310f..b8b282c2829 100644
--- a/homeassistant/components/livisi/coordinator.py
+++ b/homeassistant/components/livisi/coordinator.py
@@ -6,8 +6,9 @@ from datetime import timedelta
from typing import Any
from aiohttp import ClientConnectorError
-from aiolivisi import AioLivisi, LivisiEvent, Websocket
-from aiolivisi.errors import TokenExpiredException
+from livisi import LivisiEvent, Websocket
+from livisi.aiolivisi import AioLivisi
+from livisi.errors import TokenExpiredException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD
diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py
index 3160b8f288a..af588b0e360 100644
--- a/homeassistant/components/livisi/entity.py
+++ b/homeassistant/components/livisi/entity.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
-from aiolivisi.const import CAPABILITY_MAP
+from livisi.const import CAPABILITY_MAP
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback
diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json
index e6f46324ed8..1077cacf2c4 100644
--- a/homeassistant/components/livisi/manifest.json
+++ b/homeassistant/components/livisi/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/livisi",
"iot_class": "local_polling",
- "requirements": ["aiolivisi==0.0.19"]
+ "requirements": ["livisi==0.0.24"]
}
diff --git a/homeassistant/components/llamalab_automate/manifest.json b/homeassistant/components/llamalab_automate/manifest.json
index 861b919f24b..4343d617e93 100644
--- a/homeassistant/components/llamalab_automate/manifest.json
+++ b/homeassistant/components/llamalab_automate/manifest.json
@@ -3,5 +3,6 @@
"name": "LlamaLab Automate",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/llamalab_automate",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py
index fad87145e00..9363d388637 100644
--- a/homeassistant/components/lock/__init__.py
+++ b/homeassistant/components/lock/__init__.py
@@ -31,7 +31,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
@@ -67,10 +66,6 @@ class LockEntityFeature(IntFlag):
OPEN = 1
-# The SUPPORT_OPEN constant is deprecated as of Home Assistant 2022.5.
-# Please use the LockEntityFeature enum instead.
-_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(LockEntityFeature.OPEN, "2025.1")
-
PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT}
# mypy: disallow-any-generics
diff --git a/homeassistant/components/logentries/manifest.json b/homeassistant/components/logentries/manifest.json
index ecf2d8a227c..e63e83aff00 100644
--- a/homeassistant/components/logentries/manifest.json
+++ b/homeassistant/components/logentries/manifest.json
@@ -3,5 +3,6 @@
"name": "Logentries",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/logentries",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/london_air/manifest.json b/homeassistant/components/london_air/manifest.json
index 60eed8d83bd..653a951ae56 100644
--- a/homeassistant/components/london_air/manifest.json
+++ b/homeassistant/components/london_air/manifest.json
@@ -3,5 +3,6 @@
"name": "London Air",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/london_air",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py
index 532f4333ba9..447ed4461f3 100644
--- a/homeassistant/components/london_underground/const.py
+++ b/homeassistant/components/london_underground/const.py
@@ -24,4 +24,10 @@ TUBE_LINES = [
"Piccadilly",
"Victoria",
"Waterloo & City",
+ "Liberty",
+ "Lioness",
+ "Mildmay",
+ "Suffragette",
+ "Weaver",
+ "Windrush",
]
diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json
index eafc63c6ae7..94b993097c0 100644
--- a/homeassistant/components/london_underground/manifest.json
+++ b/homeassistant/components/london_underground/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/london_underground",
"iot_class": "cloud_polling",
"loggers": ["london_tube_status"],
+ "quality_scale": "legacy",
"requirements": ["london-tube-status==0.5"]
}
diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json
index 597aad30648..a8df2c63df4 100644
--- a/homeassistant/components/luci/manifest.json
+++ b/homeassistant/components/luci/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/luci",
"iot_class": "local_polling",
"loggers": ["openwrt_luci_rpc"],
+ "quality_scale": "legacy",
"requirements": ["openwrt-luci-rpc==1.1.17"]
}
diff --git a/homeassistant/components/luftdaten/manifest.json b/homeassistant/components/luftdaten/manifest.json
index 96927bdd4a8..bafffe4d6ae 100644
--- a/homeassistant/components/luftdaten/manifest.json
+++ b/homeassistant/components/luftdaten/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["luftdaten"],
- "quality_scale": "gold",
"requirements": ["luftdaten==0.7.4"]
}
diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json
index e96778f0a31..ec278615743 100644
--- a/homeassistant/components/lutron_caseta/manifest.json
+++ b/homeassistant/components/lutron_caseta/manifest.json
@@ -9,7 +9,7 @@
},
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
- "requirements": ["pylutron-caseta==0.21.1"],
+ "requirements": ["pylutron-caseta==0.22.0"],
"zeroconf": [
{
"type": "_lutron._tcp.local.",
diff --git a/homeassistant/components/lw12wifi/manifest.json b/homeassistant/components/lw12wifi/manifest.json
index d8b2290b234..683498f2056 100644
--- a/homeassistant/components/lw12wifi/manifest.json
+++ b/homeassistant/components/lw12wifi/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/lw12wifi",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["lw12==0.9.2"]
}
diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json
index 8bed909ace2..cca69969f70 100644
--- a/homeassistant/components/lyric/manifest.json
+++ b/homeassistant/components/lyric/manifest.json
@@ -21,6 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/lyric",
"iot_class": "cloud_polling",
"loggers": ["aiolyric"],
- "quality_scale": "silver",
"requirements": ["aiolyric==2.0.1"]
}
diff --git a/homeassistant/components/manual_mqtt/manifest.json b/homeassistant/components/manual_mqtt/manifest.json
index d4adcaf3bc9..bf2fccb62ae 100644
--- a/homeassistant/components/manual_mqtt/manifest.json
+++ b/homeassistant/components/manual_mqtt/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/manual_mqtt",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/marytts/manifest.json b/homeassistant/components/marytts/manifest.json
index bbf23327547..814d3c64925 100644
--- a/homeassistant/components/marytts/manifest.json
+++ b/homeassistant/components/marytts/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/marytts",
"iot_class": "local_push",
"loggers": ["speak2mary"],
+ "quality_scale": "legacy",
"requirements": ["speak2mary==1.4.0"]
}
diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py
index 12acfc04743..a7a1d40fcc4 100644
--- a/homeassistant/components/mastodon/sensor.py
+++ b/homeassistant/components/mastodon/sensor.py
@@ -35,21 +35,18 @@ ENTITY_DESCRIPTIONS = (
MastodonSensorEntityDescription(
key="followers",
translation_key="followers",
- native_unit_of_measurement="accounts",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.get(ACCOUNT_FOLLOWERS_COUNT),
),
MastodonSensorEntityDescription(
key="following",
translation_key="following",
- native_unit_of_measurement="accounts",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.get(ACCOUNT_FOLLOWING_COUNT),
),
MastodonSensorEntityDescription(
key="posts",
translation_key="posts",
- native_unit_of_measurement="posts",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.get(ACCOUNT_STATUSES_COUNT),
),
diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json
index fd4dd890b37..c6aefefca06 100644
--- a/homeassistant/components/mastodon/strings.json
+++ b/homeassistant/components/mastodon/strings.json
@@ -9,7 +9,10 @@
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
- "base_url": "The URL of your Mastodon instance e.g. https://mastodon.social."
+ "base_url": "The URL of your Mastodon instance e.g. https://mastodon.social.",
+ "client_id": "The client key for the application created within your Mastodon account.",
+ "client_secret": "The client secret for the application created within your Mastodon account.",
+ "access_token": "The access token for the application created within your Mastodon account."
}
}
},
@@ -39,13 +42,16 @@
"entity": {
"sensor": {
"followers": {
- "name": "Followers"
+ "name": "Followers",
+ "unit_of_measurement": "accounts"
},
"following": {
- "name": "Following"
+ "name": "Following",
+ "unit_of_measurement": "[%key:component::mastodon::entity::sensor::followers::unit_of_measurement%]"
},
"posts": {
- "name": "Posts"
+ "name": "Posts",
+ "unit_of_measurement": "posts"
}
}
}
diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json
index 43c151c7c23..e06eed1176f 100644
--- a/homeassistant/components/matrix/manifest.json
+++ b/homeassistant/components/matrix/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/matrix",
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
+ "quality_scale": "legacy",
"requirements": ["matrix-nio==0.25.2", "Pillow==11.0.0"]
}
diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py
index 475e4a44538..0ccd3e065ff 100644
--- a/homeassistant/components/matter/adapter.py
+++ b/homeassistant/components/matter/adapter.py
@@ -45,6 +45,7 @@ class MatterAdapter:
self.hass = hass
self.config_entry = config_entry
self.platform_handlers: dict[Platform, AddEntitiesCallback] = {}
+ self.discovered_entities: set[str] = set()
def register_platform_handler(
self, platform: Platform, add_entities: AddEntitiesCallback
@@ -54,23 +55,19 @@ class MatterAdapter:
async def setup_nodes(self) -> None:
"""Set up all existing nodes and subscribe to new nodes."""
- initialized_nodes: set[int] = set()
for node in self.matter_client.get_nodes():
- initialized_nodes.add(node.node_id)
self._setup_node(node)
def node_added_callback(event: EventType, node: MatterNode) -> None:
"""Handle node added event."""
- initialized_nodes.add(node.node_id)
self._setup_node(node)
def node_updated_callback(event: EventType, node: MatterNode) -> None:
"""Handle node updated event."""
- if node.node_id in initialized_nodes:
- return
if not node.available:
return
- initialized_nodes.add(node.node_id)
+ # We always run the discovery logic again,
+ # because the firmware version could have been changed or features added.
self._setup_node(node)
def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None:
@@ -237,11 +234,20 @@ class MatterAdapter:
self._create_device_registry(endpoint)
# run platform discovery from device type instances
for entity_info in async_discover_entities(endpoint):
+ discovery_key = (
+ f"{entity_info.platform}_{endpoint.node.node_id}_{endpoint.endpoint_id}_"
+ f"{entity_info.primary_attribute.cluster_id}_"
+ f"{entity_info.primary_attribute.attribute_id}_"
+ f"{entity_info.entity_description.key}"
+ )
+ if discovery_key in self.discovered_entities:
+ continue
LOGGER.debug(
"Creating %s entity for %s",
entity_info.platform,
entity_info.primary_attribute,
)
+ self.discovered_entities.add(discovery_key)
new_entity = entity_info.entity_class(
self.matter_client, endpoint, entity_info
)
diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py
index 875b063dc88..6882078a712 100644
--- a/homeassistant/components/matter/binary_sensor.py
+++ b/homeassistant/components/matter/binary_sensor.py
@@ -159,6 +159,7 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.DoorLock.Attributes.DoorState,),
+ featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py
index 918b334061b..153124a4f7e 100644
--- a/homeassistant/components/matter/button.py
+++ b/homeassistant/components/matter/button.py
@@ -69,6 +69,7 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterCommandButton,
required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,),
value_contains=clusters.Identify.Commands.Identify.command_id,
+ allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.BUTTON,
diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py
index a0e160a6c01..8018d5e09ed 100644
--- a/homeassistant/components/matter/const.py
+++ b/homeassistant/components/matter/const.py
@@ -13,3 +13,5 @@ LOGGER = logging.getLogger(__package__)
# prefixes to identify device identifier id types
ID_TYPE_DEVICE_ID = "deviceid"
ID_TYPE_SERIAL = "serial"
+
+FEATUREMAP_ATTRIBUTE_ID = 65532
diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py
index 5b07f9a069f..3b9fb0b8a94 100644
--- a/homeassistant/components/matter/discovery.py
+++ b/homeassistant/components/matter/discovery.py
@@ -13,6 +13,7 @@ from homeassistant.core import callback
from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS
from .button import DISCOVERY_SCHEMAS as BUTTON_SCHEMAS
from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS
+from .const import FEATUREMAP_ATTRIBUTE_ID
from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS
from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS
from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS
@@ -121,12 +122,24 @@ def async_discover_entities(
continue
# check for required value in (primary) attribute
+ primary_attribute = schema.required_attributes[0]
+ primary_value = endpoint.get_attribute_value(None, primary_attribute)
if schema.value_contains is not None and (
- (primary_attribute := next((x for x in schema.required_attributes), None))
- is None
- or (value := endpoint.get_attribute_value(None, primary_attribute)) is None
- or not isinstance(value, list)
- or schema.value_contains not in value
+ isinstance(primary_value, list)
+ and schema.value_contains not in primary_value
+ ):
+ continue
+
+ # check for required value in cluster featuremap
+ if schema.featuremap_contains is not None and (
+ not bool(
+ int(
+ endpoint.get_attribute_value(
+ primary_attribute.cluster_id, FEATUREMAP_ATTRIBUTE_ID
+ )
+ )
+ & schema.featuremap_contains
+ )
):
continue
@@ -147,6 +160,7 @@ def async_discover_entities(
attributes_to_watch=attributes_to_watch,
entity_description=schema.entity_description,
entity_class=schema.entity_class,
+ discovery_schema=schema,
)
# prevent re-discovery of the primary attribute if not allowed
diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py
index 7c378fe465e..50a0f2b1fee 100644
--- a/homeassistant/components/matter/entity.py
+++ b/homeassistant/components/matter/entity.py
@@ -16,9 +16,10 @@ from propcache import cached_property
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity, EntityDescription
+import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import UndefinedType
-from .const import DOMAIN, ID_TYPE_DEVICE_ID
+from .const import DOMAIN, FEATUREMAP_ATTRIBUTE_ID, ID_TYPE_DEVICE_ID
from .helpers import get_device_id
if TYPE_CHECKING:
@@ -140,6 +141,19 @@ class MatterEntity(Entity):
node_filter=self._endpoint.node.node_id,
)
)
+ # subscribe to FeatureMap attribute (as that can dynamically change)
+ self._unsubscribes.append(
+ self.matter_client.subscribe_events(
+ callback=self._on_featuremap_update,
+ event_filter=EventType.ATTRIBUTE_UPDATED,
+ node_filter=self._endpoint.node.node_id,
+ attr_path_filter=create_attribute_path(
+ endpoint=self._endpoint.endpoint_id,
+ cluster_id=self._entity_info.primary_attribute.cluster_id,
+ attribute_id=FEATUREMAP_ATTRIBUTE_ID,
+ ),
+ )
+ )
@cached_property
def name(self) -> str | UndefinedType | None:
@@ -159,6 +173,29 @@ class MatterEntity(Entity):
self._update_from_device()
self.async_write_ha_state()
+ @callback
+ def _on_featuremap_update(
+ self, event: EventType, data: tuple[int, str, int] | None
+ ) -> None:
+ """Handle FeatureMap attribute updates."""
+ if data is None:
+ return
+ new_value = data[2]
+ # handle edge case where a Feature is removed from a cluster
+ if (
+ self._entity_info.discovery_schema.featuremap_contains is not None
+ and not bool(
+ new_value & self._entity_info.discovery_schema.featuremap_contains
+ )
+ ):
+ # this entity is no longer supported by the device
+ ent_reg = er.async_get(self.hass)
+ ent_reg.async_remove(self.entity_id)
+
+ return
+ # all other cases, just update the entity
+ self._on_matter_event(event, data)
+
@callback
def _update_from_device(self) -> None:
"""Update data from Matter device."""
diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py
index c5e10554fe7..d69d0fd3dab 100644
--- a/homeassistant/components/matter/lock.py
+++ b/homeassistant/components/matter/lock.py
@@ -206,6 +206,5 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterLock,
required_attributes=(clusters.DoorLock.Attributes.LockState,),
- optional_attributes=(clusters.DoorLock.Attributes.DoorState,),
),
]
diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py
index f04c0f7e107..a00963c825a 100644
--- a/homeassistant/components/matter/models.py
+++ b/homeassistant/components/matter/models.py
@@ -51,6 +51,9 @@ class MatterEntityInfo:
# entity class to use to instantiate the entity
entity_class: type
+ # the original discovery schema used to create this entity
+ discovery_schema: MatterDiscoverySchema
+
@property
def primary_attribute(self) -> type[ClusterAttributeDescriptor]:
"""Return Primary Attribute belonging to the entity."""
@@ -113,6 +116,10 @@ class MatterDiscoverySchema:
# NOTE: only works for list values
value_contains: Any | None = None
+ # [optional] the primary attribute's cluster featuremap must contain this value
+ # for example for the DoorSensor on a DoorLock Cluster
+ featuremap_contains: int | None = None
+
# [optional] bool to specify if this primary value may be discovered
# by multiple platforms
allow_multi: bool = False
diff --git a/homeassistant/components/maxcube/manifest.json b/homeassistant/components/maxcube/manifest.json
index 6421686d2cf..d57ccacc5b1 100644
--- a/homeassistant/components/maxcube/manifest.json
+++ b/homeassistant/components/maxcube/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/maxcube",
"iot_class": "local_polling",
"loggers": ["maxcube"],
+ "quality_scale": "legacy",
"requirements": ["maxcube-api==0.4.3"]
}
diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json
index 75a83a9f468..fcd39e11a10 100644
--- a/homeassistant/components/mazda/manifest.json
+++ b/homeassistant/components/mazda/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mazda",
"integration_type": "system",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": []
}
diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py
index 2f90ceaf97a..2addd23284e 100644
--- a/homeassistant/components/mealie/config_flow.py
+++ b/homeassistant/components/mealie/config_flow.py
@@ -38,6 +38,10 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
) -> tuple[dict[str, str], str | None]:
"""Check connection to the Mealie API."""
assert self.host is not None
+
+ if "/hassio/ingress/" in self.host:
+ return {"base": "ingress_url"}, None
+
client = MealieClient(
self.host,
token=api_token,
diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json
index f594f1398e3..c555fcbc3d6 100644
--- a/homeassistant/components/mealie/manifest.json
+++ b/homeassistant/components/mealie/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/mealie",
"integration_type": "service",
"iot_class": "local_polling",
- "requirements": ["aiomealie==0.9.3"]
+ "requirements": ["aiomealie==0.9.4"]
}
diff --git a/homeassistant/components/mealie/sensor.py b/homeassistant/components/mealie/sensor.py
index b4baac34ebe..141a28ecdab 100644
--- a/homeassistant/components/mealie/sensor.py
+++ b/homeassistant/components/mealie/sensor.py
@@ -28,31 +28,26 @@ class MealieStatisticsSensorEntityDescription(SensorEntityDescription):
SENSOR_TYPES: tuple[MealieStatisticsSensorEntityDescription, ...] = (
MealieStatisticsSensorEntityDescription(
key="recipes",
- native_unit_of_measurement="recipes",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_recipes,
),
MealieStatisticsSensorEntityDescription(
key="users",
- native_unit_of_measurement="users",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_users,
),
MealieStatisticsSensorEntityDescription(
key="categories",
- native_unit_of_measurement="categories",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_categories,
),
MealieStatisticsSensorEntityDescription(
key="tags",
- native_unit_of_measurement="tags",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_tags,
),
MealieStatisticsSensorEntityDescription(
key="tools",
- native_unit_of_measurement="tools",
state_class=SensorStateClass.TOTAL,
value_fn=lambda statistics: statistics.total_tools,
),
diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json
index b59399815ea..830d43d8f93 100644
--- a/homeassistant/components/mealie/strings.json
+++ b/homeassistant/components/mealie/strings.json
@@ -8,7 +8,7 @@
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
- "host": "The URL of your Mealie instance."
+ "host": "The URL of your Mealie instance, for example, http://192.168.1.123:1234"
}
},
"reauth_confirm": {
@@ -29,6 +29,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "ingress_url": "Ingress URLs are only used for accessing the Mealie UI. Use your Home Assistant IP address and the network port within the configuration tab of the Mealie add-on.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"mealie_version": "Minimum required version is v1.0.0. Please upgrade Mealie and then retry."
},
@@ -56,19 +57,24 @@
},
"sensor": {
"recipes": {
- "name": "Recipes"
+ "name": "Recipes",
+ "unit_of_measurement": "recipes"
},
"users": {
- "name": "Users"
+ "name": "Users",
+ "unit_of_measurement": "users"
},
"categories": {
- "name": "Categories"
+ "name": "Categories",
+ "unit_of_measurement": "categories"
},
"tags": {
- "name": "Tags"
+ "name": "Tags",
+ "unit_of_measurement": "tags"
},
"tools": {
- "name": "Tools"
+ "name": "Tools",
+ "unit_of_measurement": "tools"
}
}
},
diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py
index b8bb5f98cd0..79fa9d6fb9a 100644
--- a/homeassistant/components/media_extractor/__init__.py
+++ b/homeassistant/components/media_extractor/__init__.py
@@ -16,10 +16,9 @@ from homeassistant.components.media_player import (
MEDIA_PLAYER_PLAY_MEDIA_SCHEMA,
SERVICE_PLAY_MEDIA,
)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import (
- DOMAIN as HOMEASSISTANT_DOMAIN,
HomeAssistant,
ServiceCall,
ServiceResponse,
@@ -27,7 +26,6 @@ from homeassistant.core import (
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -43,19 +41,7 @@ _LOGGER = logging.getLogger(__name__)
CONF_CUSTOMIZE_ENTITIES = "customize"
CONF_DEFAULT_STREAM_QUERY = "default_query"
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Optional(CONF_DEFAULT_STREAM_QUERY): cv.string,
- vol.Optional(CONF_CUSTOMIZE_ENTITIES): vol.Schema(
- {cv.entity_id: vol.Schema({cv.string: cv.string})}
- ),
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -67,29 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the media extractor service."""
- if DOMAIN in config:
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2024.12.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Media extractor",
- },
- )
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- )
- )
-
async def extract_media_url(call: ServiceCall) -> ServiceResponse:
"""Extract media url."""
diff --git a/homeassistant/components/media_extractor/config_flow.py b/homeassistant/components/media_extractor/config_flow.py
index b91942d7b13..cb2166c35f1 100644
--- a/homeassistant/components/media_extractor/config_flow.py
+++ b/homeassistant/components/media_extractor/config_flow.py
@@ -24,7 +24,3 @@ class MediaExtractorConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="Media extractor", data={})
return self.async_show_form(step_id="user", data_schema=vol.Schema({}))
-
- async def async_step_import(self, import_data: None) -> ConfigFlowResult:
- """Handle import."""
- return self.async_create_entry(title="Media extractor", data={})
diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json
index ebfa79d7190..866215839bf 100644
--- a/homeassistant/components/media_extractor/manifest.json
+++ b/homeassistant/components/media_extractor/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
- "requirements": ["yt-dlp[default]==2024.11.04"],
+ "requirements": ["yt-dlp[default]==2024.11.18"],
"single_config_entry": true
}
diff --git a/homeassistant/components/mediaroom/manifest.json b/homeassistant/components/mediaroom/manifest.json
index 4cd7b11c22f..060a40b036a 100644
--- a/homeassistant/components/mediaroom/manifest.json
+++ b/homeassistant/components/mediaroom/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mediaroom",
"iot_class": "local_polling",
"loggers": ["pymediaroom"],
+ "quality_scale": "legacy",
"requirements": ["pymediaroom==0.6.5.4"]
}
diff --git a/homeassistant/components/melissa/manifest.json b/homeassistant/components/melissa/manifest.json
index 60d1d7f145f..a583c3b88fa 100644
--- a/homeassistant/components/melissa/manifest.json
+++ b/homeassistant/components/melissa/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/melissa",
"iot_class": "cloud_polling",
"loggers": ["melissa"],
+ "quality_scale": "legacy",
"requirements": ["py-melissa-climate==2.1.4"]
}
diff --git a/homeassistant/components/meraki/manifest.json b/homeassistant/components/meraki/manifest.json
index 4fb7d27d4bb..5b8690ae52d 100644
--- a/homeassistant/components/meraki/manifest.json
+++ b/homeassistant/components/meraki/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/meraki",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/message_bird/manifest.json b/homeassistant/components/message_bird/manifest.json
index d5118dc3486..3b3c56029c5 100644
--- a/homeassistant/components/message_bird/manifest.json
+++ b/homeassistant/components/message_bird/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/message_bird",
"iot_class": "cloud_push",
"loggers": ["messagebird"],
+ "quality_scale": "legacy",
"requirements": ["messagebird==1.2.0"]
}
diff --git a/homeassistant/components/met_eireann/manifest.json b/homeassistant/components/met_eireann/manifest.json
index 72afc6977dd..7b913df4d3c 100644
--- a/homeassistant/components/met_eireann/manifest.json
+++ b/homeassistant/components/met_eireann/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/met_eireann",
"iot_class": "cloud_polling",
"loggers": ["meteireann"],
- "requirements": ["PyMetEireann==2021.8.0"]
+ "requirements": ["PyMetEireann==2024.11.0"]
}
diff --git a/homeassistant/components/meteoalarm/manifest.json b/homeassistant/components/meteoalarm/manifest.json
index 4de91f6a431..58b6a63ed1d 100644
--- a/homeassistant/components/meteoalarm/manifest.json
+++ b/homeassistant/components/meteoalarm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/meteoalarm",
"iot_class": "cloud_polling",
"loggers": ["meteoalertapi"],
+ "quality_scale": "legacy",
"requirements": ["meteoalertapi==0.3.1"]
}
diff --git a/homeassistant/components/mfi/manifest.json b/homeassistant/components/mfi/manifest.json
index b569009d400..3024fe145c5 100644
--- a/homeassistant/components/mfi/manifest.json
+++ b/homeassistant/components/mfi/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mfi",
"iot_class": "local_polling",
"loggers": ["mficlient"],
+ "quality_scale": "legacy",
"requirements": ["mficlient==0.5.0"]
}
diff --git a/homeassistant/components/microbees/manifest.json b/homeassistant/components/microbees/manifest.json
index 91b7d66d80f..be28bf881d2 100644
--- a/homeassistant/components/microbees/manifest.json
+++ b/homeassistant/components/microbees/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/microbees",
"iot_class": "cloud_polling",
- "requirements": ["microBeesPy==0.3.2"]
+ "requirements": ["microBeesPy==0.3.5"]
}
diff --git a/homeassistant/components/microsoft/manifest.json b/homeassistant/components/microsoft/manifest.json
index dba2f58ba98..3d8f0629cec 100644
--- a/homeassistant/components/microsoft/manifest.json
+++ b/homeassistant/components/microsoft/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/microsoft",
"iot_class": "cloud_push",
"loggers": ["pycsspeechtts"],
+ "quality_scale": "legacy",
"requirements": ["pycsspeechtts==1.0.8"]
}
diff --git a/homeassistant/components/microsoft_face/manifest.json b/homeassistant/components/microsoft_face/manifest.json
index 0ef18a12271..e13d1c76ccb 100644
--- a/homeassistant/components/microsoft_face/manifest.json
+++ b/homeassistant/components/microsoft_face/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["camera"],
"documentation": "https://www.home-assistant.io/integrations/microsoft_face",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/microsoft_face_detect/manifest.json b/homeassistant/components/microsoft_face_detect/manifest.json
index 1b72ce92c95..f3f9f0fa095 100644
--- a/homeassistant/components/microsoft_face_detect/manifest.json
+++ b/homeassistant/components/microsoft_face_detect/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["microsoft_face"],
"documentation": "https://www.home-assistant.io/integrations/microsoft_face_detect",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/microsoft_face_identify/manifest.json b/homeassistant/components/microsoft_face_identify/manifest.json
index 63418ac2a0b..b3964ee1254 100644
--- a/homeassistant/components/microsoft_face_identify/manifest.json
+++ b/homeassistant/components/microsoft_face_identify/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["microsoft_face"],
"documentation": "https://www.home-assistant.io/integrations/microsoft_face_identify",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json
index 8e098f98a15..d6ade4853c9 100644
--- a/homeassistant/components/minecraft_server/manifest.json
+++ b/homeassistant/components/minecraft_server/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/minecraft_server",
"iot_class": "local_polling",
"loggers": ["dnspython", "mcstatus"],
- "quality_scale": "platinum",
"requirements": ["mcstatus==11.1.1"]
}
diff --git a/homeassistant/components/minio/manifest.json b/homeassistant/components/minio/manifest.json
index 5fee7893841..3ab6b82bb86 100644
--- a/homeassistant/components/minio/manifest.json
+++ b/homeassistant/components/minio/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/minio",
"iot_class": "cloud_push",
"loggers": ["minio"],
+ "quality_scale": "legacy",
"requirements": ["minio==7.1.12"]
}
diff --git a/homeassistant/components/mochad/manifest.json b/homeassistant/components/mochad/manifest.json
index e4680cc6ff5..96795789c8c 100644
--- a/homeassistant/components/mochad/manifest.json
+++ b/homeassistant/components/mochad/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mochad",
"iot_class": "local_polling",
"loggers": ["pbr", "pymochad"],
+ "quality_scale": "legacy",
"requirements": ["pymochad==0.2.0"]
}
diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json
index 4482801482f..7cba4692eb6 100644
--- a/homeassistant/components/modbus/manifest.json
+++ b/homeassistant/components/modbus/manifest.json
@@ -5,6 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/modbus",
"iot_class": "local_polling",
"loggers": ["pymodbus"],
- "quality_scale": "silver",
"requirements": ["pymodbus==3.6.9"]
}
diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py
index d85b4e0e67f..18d91f8dd3b 100644
--- a/homeassistant/components/modbus/modbus.py
+++ b/homeassistant/components/modbus/modbus.py
@@ -158,8 +158,6 @@ async def async_modbus_setup(
async def async_stop_modbus(event: Event) -> None:
"""Stop Modbus service."""
-
- async_dispatcher_send(hass, SIGNAL_STOP_ENTITY)
for client in hub_collect.values():
await client.async_close()
diff --git a/homeassistant/components/mold_indicator/strings.json b/homeassistant/components/mold_indicator/strings.json
index e19fed690b2..74614bba139 100644
--- a/homeassistant/components/mold_indicator/strings.json
+++ b/homeassistant/components/mold_indicator/strings.json
@@ -9,7 +9,7 @@
},
"step": {
"user": {
- "description": "Add Mold indicator helper",
+ "description": "Create Mold indicator helper",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"indoor_humidity_sensor": "Indoor humidity sensor",
diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py
index 223d7b05ffe..caac551f986 100644
--- a/homeassistant/components/monzo/coordinator.py
+++ b/homeassistant/components/monzo/coordinator.py
@@ -3,13 +3,14 @@
from dataclasses import dataclass
from datetime import timedelta
import logging
+from pprint import pformat
from typing import Any
-from monzopy import AuthorisationExpiredError
+from monzopy import AuthorisationExpiredError, InvalidMonzoAPIResponseError
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import AuthenticatedMonzoAPI
from .const import DOMAIN
@@ -45,5 +46,16 @@ class MonzoCoordinator(DataUpdateCoordinator[MonzoData]):
pots = await self.api.user_account.pots()
except AuthorisationExpiredError as err:
raise ConfigEntryAuthFailed from err
+ except InvalidMonzoAPIResponseError as err:
+ message = "Invalid Monzo API response."
+ if err.missing_key:
+ _LOGGER.debug(
+ "%s\nMissing key: %s\nResponse:\n%s",
+ message,
+ err.missing_key,
+ pformat(err.response),
+ )
+ message += " Enabling debug logging for details."
+ raise UpdateFailed(message) from err
return MonzoData(accounts, pots)
diff --git a/homeassistant/components/motionblinds_ble/manifest.json b/homeassistant/components/motionblinds_ble/manifest.json
index ce7e7a6bb8b..70cddce30a1 100644
--- a/homeassistant/components/motionblinds_ble/manifest.json
+++ b/homeassistant/components/motionblinds_ble/manifest.json
@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "assumed_state",
"loggers": ["motionblindsble"],
- "requirements": ["motionblindsble==0.1.2"]
+ "requirements": ["motionblindsble==0.1.3"]
}
diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py
index 907b1a1dd11..bcad8747c39 100644
--- a/homeassistant/components/mqtt/__init__.py
+++ b/homeassistant/components/mqtt/__init__.py
@@ -225,77 +225,27 @@ async def async_check_config_schema(
) from exc
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Load a config entry."""
- conf: dict[str, Any]
- mqtt_data: MqttData
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the actions and websocket API for the MQTT component."""
- async def _setup_client(
- client_available: asyncio.Future[bool],
- ) -> tuple[MqttData, dict[str, Any]]:
- """Set up the MQTT client."""
- # Fetch configuration
- conf = dict(entry.data)
- hass_config = await conf_util.async_hass_config_yaml(hass)
- mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, [])
- await async_create_certificate_temp_files(hass, conf)
- client = MQTT(hass, entry, conf)
- if DOMAIN in hass.data:
- mqtt_data = hass.data[DATA_MQTT]
- mqtt_data.config = mqtt_yaml
- mqtt_data.client = client
- else:
- # Initial setup
- websocket_api.async_register_command(hass, websocket_subscribe)
- websocket_api.async_register_command(hass, websocket_mqtt_info)
- hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client)
- await client.async_start(mqtt_data)
-
- # Restore saved subscriptions
- if mqtt_data.subscriptions_to_restore:
- mqtt_data.client.async_restore_tracked_subscriptions(
- mqtt_data.subscriptions_to_restore
- )
- mqtt_data.subscriptions_to_restore = set()
- mqtt_data.reload_dispatchers.append(
- entry.add_update_listener(_async_config_entry_updated)
- )
-
- return (mqtt_data, conf)
-
- client_available: asyncio.Future[bool]
- if DATA_MQTT_AVAILABLE not in hass.data:
- client_available = hass.data[DATA_MQTT_AVAILABLE] = hass.loop.create_future()
- else:
- client_available = hass.data[DATA_MQTT_AVAILABLE]
-
- mqtt_data, conf = await _setup_client(client_available)
- platforms_used = platforms_from_config(mqtt_data.config)
- platforms_used.update(
- entry.domain
- for entry in er.async_entries_for_config_entry(
- er.async_get(hass), entry.entry_id
- )
- )
- integration = async_get_loaded_integration(hass, DOMAIN)
- # Preload platforms we know we are going to use so
- # discovery can setup each platform synchronously
- # and avoid creating a flood of tasks at startup
- # while waiting for the the imports to complete
- if not integration.platforms_are_loaded(platforms_used):
- with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS):
- await integration.async_get_platforms(platforms_used)
-
- # Wait to connect until the platforms are loaded so
- # we can be sure discovery does not have to wait for
- # each platform to load when we get the flood of retained
- # messages on connect
- await mqtt_data.client.async_connect(client_available)
+ websocket_api.async_register_command(hass, websocket_subscribe)
+ websocket_api.async_register_command(hass, websocket_mqtt_info)
async def async_publish_service(call: ServiceCall) -> None:
"""Handle MQTT publish service calls."""
msg_topic: str | None = call.data.get(ATTR_TOPIC)
msg_topic_template: str | None = call.data.get(ATTR_TOPIC_TEMPLATE)
+
+ if not mqtt_config_entry_enabled(hass):
+ raise ServiceValidationError(
+ translation_key="mqtt_not_setup_cannot_publish",
+ translation_domain=DOMAIN,
+ translation_placeholders={
+ "topic": str(msg_topic or msg_topic_template)
+ },
+ )
+
+ mqtt_data = hass.data[DATA_MQTT]
payload: PublishPayloadType = call.data.get(ATTR_PAYLOAD)
evaluate_payload: bool = call.data.get(ATTR_EVALUATE_PAYLOAD, False)
payload_template: str | None = call.data.get(ATTR_PAYLOAD_TEMPLATE)
@@ -402,6 +352,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
}
),
)
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Load a config entry."""
+ conf: dict[str, Any]
+ mqtt_data: MqttData
+
+ async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
+ """Set up the MQTT client."""
+ # Fetch configuration
+ conf = dict(entry.data)
+ hass_config = await conf_util.async_hass_config_yaml(hass)
+ mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, [])
+ await async_create_certificate_temp_files(hass, conf)
+ client = MQTT(hass, entry, conf)
+ if DOMAIN in hass.data:
+ mqtt_data = hass.data[DATA_MQTT]
+ mqtt_data.config = mqtt_yaml
+ mqtt_data.client = client
+ else:
+ # Initial setup
+ hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client)
+ await client.async_start(mqtt_data)
+
+ # Restore saved subscriptions
+ if mqtt_data.subscriptions_to_restore:
+ mqtt_data.client.async_restore_tracked_subscriptions(
+ mqtt_data.subscriptions_to_restore
+ )
+ mqtt_data.subscriptions_to_restore = set()
+ mqtt_data.reload_dispatchers.append(
+ entry.add_update_listener(_async_config_entry_updated)
+ )
+
+ return (mqtt_data, conf)
+
+ client_available: asyncio.Future[bool]
+ if DATA_MQTT_AVAILABLE not in hass.data:
+ client_available = hass.data[DATA_MQTT_AVAILABLE] = hass.loop.create_future()
+ else:
+ client_available = hass.data[DATA_MQTT_AVAILABLE]
+
+ mqtt_data, conf = await _setup_client()
+ platforms_used = platforms_from_config(mqtt_data.config)
+ platforms_used.update(
+ entry.domain
+ for entry in er.async_entries_for_config_entry(
+ er.async_get(hass), entry.entry_id
+ )
+ )
+ integration = async_get_loaded_integration(hass, DOMAIN)
+ # Preload platforms we know we are going to use so
+ # discovery can setup each platform synchronously
+ # and avoid creating a flood of tasks at startup
+ # while waiting for the the imports to complete
+ if not integration.platforms_are_loaded(platforms_used):
+ with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS):
+ await integration.async_get_platforms(platforms_used)
+
+ # Wait to connect until the platforms are loaded so
+ # we can be sure discovery does not have to wait for
+ # each platform to load when we get the flood of retained
+ # messages on connect
+ await mqtt_data.client.async_connect(client_available)
# setup platforms and discovery
async def _reload_config(call: ServiceCall) -> None:
@@ -557,10 +572,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
mqtt_data = hass.data[DATA_MQTT]
mqtt_client = mqtt_data.client
- # Unload publish and dump services.
- hass.services.async_remove(DOMAIN, SERVICE_PUBLISH)
- hass.services.async_remove(DOMAIN, SERVICE_DUMP)
-
# Stop the discovery
await discovery.async_stop(hass)
# Unload the platforms
diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py
index 76bac8540a4..613f665c302 100644
--- a/homeassistant/components/mqtt/alarm_control_panel.py
+++ b/homeassistant/components/mqtt/alarm_control_panel.py
@@ -35,6 +35,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
_SUPPORTED_FEATURES = {
"arm_home": AlarmControlPanelEntityFeature.ARM_HOME,
"arm_away": AlarmControlPanelEntityFeature.ARM_AWAY,
diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py
index 7f89a78991a..b49dc7aa24c 100644
--- a/homeassistant/components/mqtt/binary_sensor.py
+++ b/homeassistant/components/mqtt/binary_sensor.py
@@ -43,6 +43,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Binary sensor"
CONF_OFF_DELAY = "off_delay"
DEFAULT_PAYLOAD_OFF = "OFF"
diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py
index 2aac51890c1..8e5446b532e 100644
--- a/homeassistant/components/mqtt/button.py
+++ b/homeassistant/components/mqtt/button.py
@@ -20,6 +20,8 @@ from .models import MqttCommandTemplate
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
+PARALLEL_UPDATES = 0
+
CONF_PAYLOAD_PRESS = "payload_press"
DEFAULT_NAME = "MQTT Button"
DEFAULT_PAYLOAD_PRESS = "PRESS"
diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py
index ca622defb25..88fabad0446 100644
--- a/homeassistant/components/mqtt/camera.py
+++ b/homeassistant/components/mqtt/camera.py
@@ -27,6 +27,8 @@ from .util import valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_IMAGE_ENCODING = "image_encoding"
DEFAULT_NAME = "MQTT Camera"
diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py
index a626e0e5b28..1dcd0928434 100644
--- a/homeassistant/components/mqtt/client.py
+++ b/homeassistant/components/mqtt/client.py
@@ -776,7 +776,11 @@ class MQTT:
else:
del self._wildcard_subscriptions[subscription]
except (KeyError, ValueError) as exc:
- raise HomeAssistantError("Can't remove subscription twice") from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="mqtt_not_setup_cannot_unsubscribe_twice",
+ translation_placeholders={"topic": topic},
+ ) from exc
@callback
def _async_queue_subscriptions(
@@ -822,7 +826,11 @@ class MQTT:
) -> Callable[[], None]:
"""Set up a subscription to a topic with the provided qos."""
if not isinstance(topic, str):
- raise HomeAssistantError("Topic needs to be a string!")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="mqtt_topic_not_a_string",
+ translation_placeholders={"topic": topic},
+ )
if job_type is None:
job_type = get_hassjob_callable_job_type(msg_callback)
@@ -1213,7 +1221,11 @@ class MQTT:
import paho.mqtt.client as mqtt
raise HomeAssistantError(
- f"Error talking to MQTT: {mqtt.error_string(result_code)}"
+ translation_domain=DOMAIN,
+ translation_key="mqtt_broker_error",
+ translation_placeholders={
+ "error_message": mqtt.error_string(result_code)
+ },
)
# Create the mid event if not created, either _mqtt_handle_mid or
diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py
index dd3efa4054b..2419e3f32ac 100644
--- a/homeassistant/components/mqtt/climate.py
+++ b/homeassistant/components/mqtt/climate.py
@@ -91,6 +91,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT HVAC"
CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template"
diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py
index 69306a1c383..34d43ad87f3 100644
--- a/homeassistant/components/mqtt/config_flow.py
+++ b/homeassistant/components/mqtt/config_flow.py
@@ -331,7 +331,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
break
else:
raise AddonError(
- f"Failed to correctly start {addon_manager.addon_name} add-on"
+ translation_domain=DOMAIN,
+ translation_key="addon_start_failed",
+ translation_placeholders={"addon": addon_manager.addon_name},
)
async def async_step_user(
diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py
index 0b495663803..c7d041848f0 100644
--- a/homeassistant/components/mqtt/cover.py
+++ b/homeassistant/components/mqtt/cover.py
@@ -69,6 +69,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_GET_POSITION_TOPIC = "position_topic"
CONF_GET_POSITION_TEMPLATE = "position_template"
CONF_SET_POSITION_TOPIC = "set_position_topic"
diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py
index b87db40ccf7..bdf543e046a 100644
--- a/homeassistant/components/mqtt/device_tracker.py
+++ b/homeassistant/components/mqtt/device_tracker.py
@@ -36,6 +36,8 @@ from .util import valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_PAYLOAD_HOME = "payload_home"
CONF_PAYLOAD_NOT_HOME = "payload_not_home"
CONF_SOURCE_TYPE = "source_type"
diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py
index 80faf879587..8665ac26961 100644
--- a/homeassistant/components/mqtt/device_trigger.py
+++ b/homeassistant/components/mqtt/device_trigger.py
@@ -148,7 +148,10 @@ class Trigger:
def async_remove() -> None:
"""Remove trigger."""
if instance not in self.trigger_instances:
- raise HomeAssistantError("Can't remove trigger twice")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="mqtt_trigger_cannot_remove_twice",
+ )
if instance.remove:
instance.remove()
diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py
index 46b2c9e1d42..c73e1975a68 100644
--- a/homeassistant/components/mqtt/entity.py
+++ b/homeassistant/components/mqtt/entity.py
@@ -1185,6 +1185,33 @@ def device_info_from_specifications(
return info
+@callback
+def ensure_via_device_exists(
+ hass: HomeAssistant, device_info: DeviceInfo | None, config_entry: ConfigEntry
+) -> None:
+ """Ensure the via device is in the device registry."""
+ if (
+ device_info is None
+ or CONF_VIA_DEVICE not in device_info
+ or (device_registry := dr.async_get(hass)).async_get_device(
+ identifiers={device_info["via_device"]}
+ )
+ ):
+ return
+
+ # Ensure the via device exists in the device registry
+ _LOGGER.debug(
+ "Device identifier %s via_device reference from device_info %s "
+ "not found in the Device Registry, creating new entry",
+ device_info["via_device"],
+ device_info,
+ )
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={device_info["via_device"]},
+ )
+
+
class MqttEntityDeviceInfo(Entity):
"""Mixin used for mqtt platforms that support the device registry."""
@@ -1203,6 +1230,7 @@ class MqttEntityDeviceInfo(Entity):
device_info = self.device_info
if device_info is not None:
+ ensure_via_device_exists(self.hass, device_info, self._config_entry)
device_registry.async_get_or_create(
config_entry_id=config_entry_id, **device_info
)
@@ -1256,6 +1284,7 @@ class MqttEntity(
self, hass, discovery_data, self.discovery_update
)
MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry)
+ ensure_via_device_exists(self.hass, self.device_info, self._config_entry)
def _init_entity_id(self) -> None:
"""Set entity_id from object_id if defined in config."""
@@ -1490,6 +1519,8 @@ def update_device(
config_entry_id = config_entry.entry_id
device_info = device_info_from_specifications(config[CONF_DEVICE])
+ ensure_via_device_exists(hass, device_info, config_entry)
+
if config_entry_id is not None and device_info is not None:
update_device_info = cast(dict[str, Any], device_info)
update_device_info["config_entry_id"] = config_entry_id
diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py
index 3f67891ca5e..d9812aaaf48 100644
--- a/homeassistant/components/mqtt/event.py
+++ b/homeassistant/components/mqtt/event.py
@@ -38,6 +38,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_EVENT_TYPES = "event_types"
MQTT_EVENT_ATTRIBUTES_BLOCKED = frozenset(
diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py
index 70187ee9eb1..b3c0f22789c 100644
--- a/homeassistant/components/mqtt/fan.py
+++ b/homeassistant/components/mqtt/fan.py
@@ -57,6 +57,8 @@ from .models import (
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic, valid_subscribe_topic
+PARALLEL_UPDATES = 0
+
CONF_DIRECTION_STATE_TOPIC = "direction_state_topic"
CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic"
CONF_DIRECTION_VALUE_TEMPLATE = "direction_value_template"
diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py
index 304d293de79..5d1af03ad24 100644
--- a/homeassistant/components/mqtt/humidifier.py
+++ b/homeassistant/components/mqtt/humidifier.py
@@ -59,6 +59,8 @@ from .models import (
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic, valid_subscribe_topic
+PARALLEL_UPDATES = 0
+
CONF_AVAILABLE_MODES_LIST = "modes"
CONF_DEVICE_CLASS = "device_class"
CONF_MODE_COMMAND_TEMPLATE = "mode_command_template"
diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py
index 6ecdee06489..4b7b2d783d2 100644
--- a/homeassistant/components/mqtt/image.py
+++ b/homeassistant/components/mqtt/image.py
@@ -37,6 +37,8 @@ from .util import valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_CONTENT_TYPE = "content_type"
CONF_IMAGE_ENCODING = "image_encoding"
CONF_IMAGE_TOPIC = "image_topic"
diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py
index 11afe4220c4..87577c4b4d9 100644
--- a/homeassistant/components/mqtt/lawn_mower.py
+++ b/homeassistant/components/mqtt/lawn_mower.py
@@ -38,6 +38,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_ACTIVITY_STATE_TOPIC = "activity_state_topic"
CONF_ACTIVITY_VALUE_TEMPLATE = "activity_value_template"
CONF_DOCK_COMMAND_TOPIC = "dock_command_topic"
diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py
index a1ba955181d..328f80cb5ea 100644
--- a/homeassistant/components/mqtt/light/__init__.py
+++ b/homeassistant/components/mqtt/light/__init__.py
@@ -30,6 +30,8 @@ from .schema_template import (
MqttLightTemplate,
)
+PARALLEL_UPDATES = 0
+
def validate_mqtt_light_discovery(config_value: dict[str, Any]) -> ConfigType:
"""Validate MQTT light schema for discovery."""
diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py
index e58d15b659d..2113dbbd5ba 100644
--- a/homeassistant/components/mqtt/lock.py
+++ b/homeassistant/components/mqtt/lock.py
@@ -45,6 +45,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_CODE_FORMAT = "code_format"
CONF_PAYLOAD_LOCK = "payload_lock"
diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json
index 25e98c01aaf..081449b142a 100644
--- a/homeassistant/components/mqtt/manifest.json
+++ b/homeassistant/components/mqtt/manifest.json
@@ -7,7 +7,6 @@
"dependencies": ["file_upload", "http"],
"documentation": "https://www.home-assistant.io/integrations/mqtt",
"iot_class": "local_push",
- "quality_scale": "platinum",
"requirements": ["paho-mqtt==1.6.1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py
index 4a5ccc02774..84442e75e73 100644
--- a/homeassistant/components/mqtt/notify.py
+++ b/homeassistant/components/mqtt/notify.py
@@ -20,6 +20,8 @@ from .models import MqttCommandTemplate
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT notify"
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py
index 895334f2e1e..a9bf1829b63 100644
--- a/homeassistant/components/mqtt/number.py
+++ b/homeassistant/components/mqtt/number.py
@@ -50,6 +50,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_MIN = "min"
CONF_MAX = "max"
CONF_STEP = "step"
diff --git a/homeassistant/components/mqtt/quality_scale.yaml b/homeassistant/components/mqtt/quality_scale.yaml
new file mode 100644
index 00000000000..d459f0420f1
--- /dev/null
+++ b/homeassistant/components/mqtt/quality_scale.yaml
@@ -0,0 +1,125 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: done
+ comment: >
+ Entities are updated through dispatchers, and these are
+ cleaned up when the integration unloads.
+ entity-unique-id:
+ status: exempt
+ comment: >
+ This is user configurable, but not required.
+ It is required though when a user wants to use device based discovery.
+ has-entity-name: done
+ runtime-data:
+ status: exempt
+ comment: >
+ Runtime data is not used, as the mqtt entry data is only used to set up the
+ MQTT broker, this happens during integration setup,
+ and only one config entry is allowed.
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable:
+ status: done
+ comment: |
+ Only supported for entities the user has assigned a unique_id.
+ action-exceptions: done
+ reauthentication-flow: done
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters: done
+
+ # Gold
+ entity-translations:
+ status: exempt
+ comment: >
+ This is not possible because the integrations generates entities
+ based on a user supplied config or discovery.
+ entity-device-class:
+ status: done
+ comment: An entity device class can be configured by the user for each entity.
+ devices:
+ status: done
+ comment: >
+ A device context can be configured by the user for each entity.
+ It is not required though, except when using device based discovery.
+ entity-category:
+ status: done
+ comment: An entity category can be configured by the user for each entity.
+ entity-disabled-by-default:
+ status: done
+ comment: >
+ The user can configure this through YAML or discover
+ entities that are disabled by default.
+ discovery:
+ status: done
+ comment: >
+ When the Mosquitto MQTT broker add on is installed,
+ a MQTT config flow allows an automatic setup from its discovered settings.
+ stale-devices:
+ status: exempt
+ comment: >
+ This is is only supported for entities that are configured through MQTT discovery.
+ Users must manually cleanup stale entities that were set up though YAML.
+ diagnostics: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: >
+ This is not possible because the integrations generates entities
+ based on a user supplied config or discovery.
+ reconfiguration-flow:
+ status: exempt
+ comment: >
+ This integration is reconfigured via options flow.
+ dynamic-devices:
+ status: done
+ comment: |
+ MQTT allow to dynamically create and remove devices through MQTT discovery.
+ discovery-update-info:
+ status: done
+ comment: >
+ If the Mosquitto broker add-on is used to set up MQTT from discovery,
+ and the broker add-on is re-installed,
+ MQTT will automatically update from the new brokers credentials.
+ repair-issues:
+ status: done
+ comment: >
+ This integration uses repair-issues when entities are set up through YAML.
+ To avoid user panic, discovery deprecation issues are logged only.
+ It is the responsibility of the maintainer or the service or device to
+ correct the discovery messages. Extra options are allowed
+ in MQTT messages to avoid breaking issues.
+ docs-use-cases: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting: done
+ docs-examples: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ This integration does not use web sessions.
+ strict-typing: done
diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py
index dad596d9c4f..314bd716ee0 100644
--- a/homeassistant/components/mqtt/scene.py
+++ b/homeassistant/components/mqtt/scene.py
@@ -21,6 +21,8 @@ from .entity import MqttEntity, async_setup_entity_entry_helper
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Scene"
DEFAULT_RETAIN = False
diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py
index 37d3287988f..55d56ecd774 100644
--- a/homeassistant/components/mqtt/select.py
+++ b/homeassistant/components/mqtt/select.py
@@ -37,6 +37,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Select"
MQTT_SELECT_ATTRIBUTES_BLOCKED = frozenset(
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
index 17ea0ab1f5b..bacbf4d323e 100644
--- a/homeassistant/components/mqtt/sensor.py
+++ b/homeassistant/components/mqtt/sensor.py
@@ -47,6 +47,8 @@ from .util import check_state_too_long
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_EXPIRE_AFTER = "expire_after"
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py
index 1937b60fde0..22f64053d23 100644
--- a/homeassistant/components/mqtt/siren.py
+++ b/homeassistant/components/mqtt/siren.py
@@ -55,6 +55,8 @@ from .models import (
)
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Siren"
DEFAULT_PAYLOAD_ON = "ON"
DEFAULT_PAYLOAD_OFF = "OFF"
diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json
index 8ab31e37857..4d23007e51b 100644
--- a/homeassistant/components/mqtt/strings.json
+++ b/homeassistant/components/mqtt/strings.json
@@ -61,6 +61,7 @@
"client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.",
"client_cert": "The client certificate to authenticate against your MQTT broker.",
"client_key": "The private key file that belongs to your client certificate.",
+ "keepalive": "A value less than 90 seconds is advised.",
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
"protocol": "The MQTT protocol your broker operates at. For example 3.1.1.",
"set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT brokers certificate.",
@@ -172,6 +173,7 @@
"client_id": "[%key:component::mqtt::config::step::broker::data_description::client_id%]",
"client_cert": "[%key:component::mqtt::config::step::broker::data_description::client_cert%]",
"client_key": "[%key:component::mqtt::config::step::broker::data_description::client_key%]",
+ "keepalive": "[%key:component::mqtt::config::step::broker::data_description::keepalive%]",
"tls_insecure": "[%key:component::mqtt::config::step::broker::data_description::tls_insecure%]",
"protocol": "[%key:component::mqtt::config::step::broker::data_description::protocol%]",
"set_ca_cert": "[%key:component::mqtt::config::step::broker::data_description::set_ca_cert%]",
@@ -287,6 +289,9 @@
}
},
"exceptions": {
+ "addon_start_failed": {
+ "message": "Failed to correctly start {addon} add-on."
+ },
"command_template_error": {
"message": "Parsing template `{command_template}` for entity `{entity_id}` failed with error: {error}."
},
@@ -296,11 +301,23 @@
"invalid_publish_topic": {
"message": "Unable to publish: topic template `{topic_template}` produced an invalid topic `{topic}` after rendering ({error})"
},
+ "mqtt_broker_error": {
+ "message": "Error talking to MQTT: {error_message}."
+ },
"mqtt_not_setup_cannot_subscribe": {
"message": "Cannot subscribe to topic \"{topic}\", make sure MQTT is set up correctly."
},
"mqtt_not_setup_cannot_publish": {
"message": "Cannot publish to topic \"{topic}\", make sure MQTT is set up correctly."
+ },
+ "mqtt_not_setup_cannot_unsubscribe_twice": {
+ "message": "Cannot unsubscribe topic \"{topic}\" twice."
+ },
+ "mqtt_topic_not_a_string": {
+ "message": "Topic needs to be a string! Got: {topic}."
+ },
+ "mqtt_trigger_cannot_remove_twice": {
+ "message": "Can't remove trigger twice."
}
}
}
diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py
index a73c4fe53f8..c90174e8a01 100644
--- a/homeassistant/components/mqtt/switch.py
+++ b/homeassistant/components/mqtt/switch.py
@@ -43,6 +43,8 @@ from .models import (
)
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Switch"
DEFAULT_PAYLOAD_ON = "ON"
DEFAULT_PAYLOAD_OFF = "OFF"
diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py
index edfecfbc038..b4ed33a7730 100644
--- a/homeassistant/components/mqtt/text.py
+++ b/homeassistant/components/mqtt/text.py
@@ -40,6 +40,8 @@ from .util import check_state_too_long
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_MAX = "max"
CONF_MIN = "min"
CONF_PATTERN = "pattern"
diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py
index 8878ff63127..99b4e5cb821 100644
--- a/homeassistant/components/mqtt/update.py
+++ b/homeassistant/components/mqtt/update.py
@@ -32,6 +32,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Update"
CONF_DISPLAY_PRECISION = "display_precision"
diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py
index 86b32aa281b..ac6dca3cbbc 100644
--- a/homeassistant/components/mqtt/vacuum.py
+++ b/homeassistant/components/mqtt/vacuum.py
@@ -39,6 +39,8 @@ from .models import ReceiveMessage
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic
+PARALLEL_UPDATES = 0
+
BATTERY = "battery_level"
FAN_SPEED = "fan_speed"
STATE = "state"
diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py
index 00d3d7d79bd..50c5960f801 100644
--- a/homeassistant/components/mqtt/valve.py
+++ b/homeassistant/components/mqtt/valve.py
@@ -63,6 +63,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
CONF_REPORTS_POSITION = "reports_position"
DEFAULT_NAME = "MQTT Valve"
diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py
index b98d73e0bfe..4c1d3fa8a53 100644
--- a/homeassistant/components/mqtt/water_heater.py
+++ b/homeassistant/components/mqtt/water_heater.py
@@ -72,6 +72,8 @@ from .util import valid_publish_topic, valid_subscribe_topic
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
DEFAULT_NAME = "MQTT Water Heater"
MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED = frozenset(
diff --git a/homeassistant/components/mqtt_eventstream/manifest.json b/homeassistant/components/mqtt_eventstream/manifest.json
index 978b11de994..95e97ebb5fa 100644
--- a/homeassistant/components/mqtt_eventstream/manifest.json
+++ b/homeassistant/components/mqtt_eventstream/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/mqtt_eventstream",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/mqtt_json/manifest.json b/homeassistant/components/mqtt_json/manifest.json
index 24ed99979cc..ccaa4996fea 100644
--- a/homeassistant/components/mqtt_json/manifest.json
+++ b/homeassistant/components/mqtt_json/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/mqtt_json",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/mqtt_room/manifest.json b/homeassistant/components/mqtt_room/manifest.json
index efc5e375cfd..858a1cbb98c 100644
--- a/homeassistant/components/mqtt_room/manifest.json
+++ b/homeassistant/components/mqtt_room/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/mqtt_room",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/mqtt_statestream/manifest.json b/homeassistant/components/mqtt_statestream/manifest.json
index 134cd80d383..c3c278a08bb 100644
--- a/homeassistant/components/mqtt_statestream/manifest.json
+++ b/homeassistant/components/mqtt_statestream/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/mqtt_statestream",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/msteams/manifest.json b/homeassistant/components/msteams/manifest.json
index e4b40140441..3ded77c2176 100644
--- a/homeassistant/components/msteams/manifest.json
+++ b/homeassistant/components/msteams/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/msteams",
"iot_class": "cloud_push",
"loggers": ["pymsteams"],
+ "quality_scale": "legacy",
"requirements": ["pymsteams==0.1.12"]
}
diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py
index 9f0fc1aad27..22de510ebe3 100644
--- a/homeassistant/components/music_assistant/__init__.py
+++ b/homeassistant/components/music_assistant/__init__.py
@@ -28,13 +28,13 @@ from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from music_assistant_models.event import MassEvent
-type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData]
-
PLATFORMS = [Platform.MEDIA_PLAYER]
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30
+type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData]
+
@dataclass
class MusicAssistantEntryData:
@@ -47,7 +47,7 @@ class MusicAssistantEntryData:
async def async_setup_entry(
hass: HomeAssistant, entry: MusicAssistantConfigEntry
) -> bool:
- """Set up from a config entry."""
+ """Set up Music Assistant from a config entry."""
http_session = async_get_clientsession(hass, verify_ssl=False)
mass_url = entry.data[CONF_URL]
mass = MusicAssistantClient(mass_url, http_session)
@@ -97,6 +97,7 @@ async def async_setup_entry(
listen_task.cancel()
raise ConfigEntryNotReady("Music Assistant client not ready") from err
+ # store the listen task and mass client in the entry data
entry.runtime_data = MusicAssistantEntryData(mass, listen_task)
# If the listen task is already failed, we need to raise ConfigEntryNotReady
diff --git a/homeassistant/components/music_assistant/icons.json b/homeassistant/components/music_assistant/icons.json
new file mode 100644
index 00000000000..7533dbb6dad
--- /dev/null
+++ b/homeassistant/components/music_assistant/icons.json
@@ -0,0 +1,7 @@
+{
+ "services": {
+ "play_media": { "service": "mdi:play" },
+ "play_announcement": { "service": "mdi:bullhorn" },
+ "transfer_queue": { "service": "mdi:transfer" }
+ }
+}
diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json
index 65e6652407f..f5cdcf50673 100644
--- a/homeassistant/components/music_assistant/manifest.json
+++ b/homeassistant/components/music_assistant/manifest.json
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
"loggers": ["music_assistant"],
- "requirements": ["music-assistant-client==1.0.5"],
+ "requirements": ["music-assistant-client==1.0.8"],
"zeroconf": ["_mass._tcp.local."]
}
diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py
new file mode 100644
index 00000000000..e65d6d4a975
--- /dev/null
+++ b/homeassistant/components/music_assistant/media_browser.py
@@ -0,0 +1,351 @@
+"""Media Source Implementation."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from music_assistant_models.media_items import MediaItemType
+
+from homeassistant.components import media_source
+from homeassistant.components.media_player import (
+ BrowseError,
+ BrowseMedia,
+ MediaClass,
+ MediaType,
+)
+from homeassistant.core import HomeAssistant
+
+from .const import DEFAULT_NAME, DOMAIN
+
+if TYPE_CHECKING:
+ from music_assistant_client import MusicAssistantClient
+
+MEDIA_TYPE_RADIO = "radio"
+
+PLAYABLE_MEDIA_TYPES = [
+ MediaType.PLAYLIST,
+ MediaType.ALBUM,
+ MediaType.ARTIST,
+ MEDIA_TYPE_RADIO,
+ MediaType.TRACK,
+]
+
+LIBRARY_ARTISTS = "artists"
+LIBRARY_ALBUMS = "albums"
+LIBRARY_TRACKS = "tracks"
+LIBRARY_PLAYLISTS = "playlists"
+LIBRARY_RADIO = "radio"
+
+
+LIBRARY_TITLE_MAP = {
+ LIBRARY_ARTISTS: "Artists",
+ LIBRARY_ALBUMS: "Albums",
+ LIBRARY_TRACKS: "Tracks",
+ LIBRARY_PLAYLISTS: "Playlists",
+ LIBRARY_RADIO: "Radio stations",
+}
+
+LIBRARY_MEDIA_CLASS_MAP = {
+ LIBRARY_ARTISTS: MediaClass.ARTIST,
+ LIBRARY_ALBUMS: MediaClass.ALBUM,
+ LIBRARY_TRACKS: MediaClass.TRACK,
+ LIBRARY_PLAYLISTS: MediaClass.PLAYLIST,
+ LIBRARY_RADIO: MediaClass.MUSIC, # radio is not accepted by HA
+}
+
+MEDIA_CONTENT_TYPE_FLAC = "audio/flac"
+THUMB_SIZE = 200
+
+
+def media_source_filter(item: BrowseMedia) -> bool:
+ """Filter media sources."""
+ return item.media_content_type.startswith("audio/")
+
+
+async def async_browse_media(
+ hass: HomeAssistant,
+ mass: MusicAssistantClient,
+ media_content_id: str | None,
+ media_content_type: str | None,
+) -> BrowseMedia:
+ """Browse media."""
+ if media_content_id is None:
+ return await build_main_listing(hass)
+
+ assert media_content_type is not None
+
+ if media_source.is_media_source_id(media_content_id):
+ return await media_source.async_browse_media(
+ hass, media_content_id, content_filter=media_source_filter
+ )
+
+ if media_content_id == LIBRARY_ARTISTS:
+ return await build_artists_listing(mass)
+ if media_content_id == LIBRARY_ALBUMS:
+ return await build_albums_listing(mass)
+ if media_content_id == LIBRARY_TRACKS:
+ return await build_tracks_listing(mass)
+ if media_content_id == LIBRARY_PLAYLISTS:
+ return await build_playlists_listing(mass)
+ if media_content_id == LIBRARY_RADIO:
+ return await build_radio_listing(mass)
+ if "artist" in media_content_id:
+ return await build_artist_items_listing(mass, media_content_id)
+ if "album" in media_content_id:
+ return await build_album_items_listing(mass, media_content_id)
+ if "playlist" in media_content_id:
+ return await build_playlist_items_listing(mass, media_content_id)
+
+ raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}")
+
+
+async def build_main_listing(hass: HomeAssistant) -> BrowseMedia:
+ """Build main browse listing."""
+ children: list[BrowseMedia] = []
+ for library, media_class in LIBRARY_MEDIA_CLASS_MAP.items():
+ child_source = BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=library,
+ media_content_type=DOMAIN,
+ title=LIBRARY_TITLE_MAP[library],
+ children_media_class=media_class,
+ can_play=False,
+ can_expand=True,
+ )
+ children.append(child_source)
+
+ try:
+ item = await media_source.async_browse_media(
+ hass, None, content_filter=media_source_filter
+ )
+ # If domain is None, it's overview of available sources
+ if item.domain is None and item.children is not None:
+ children.extend(item.children)
+ else:
+ children.append(item)
+ except media_source.BrowseError:
+ pass
+
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id="",
+ media_content_type=DOMAIN,
+ title=DEFAULT_NAME,
+ can_play=False,
+ can_expand=True,
+ children=children,
+ )
+
+
+async def build_playlists_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Playlists browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_PLAYLISTS]
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_PLAYLISTS,
+ media_content_type=MediaType.PLAYLIST,
+ title=LIBRARY_TITLE_MAP[LIBRARY_PLAYLISTS],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=sorted(
+ [
+ build_item(mass, item, can_expand=True)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for item in await mass.music.get_library_playlists(limit=500)
+ if item.available
+ ],
+ key=lambda x: x.title,
+ ),
+ )
+
+
+async def build_playlist_items_listing(
+ mass: MusicAssistantClient, identifier: str
+) -> BrowseMedia:
+ """Build Playlist items browse listing."""
+ playlist = await mass.music.get_item_by_uri(identifier)
+
+ return BrowseMedia(
+ media_class=MediaClass.PLAYLIST,
+ media_content_id=playlist.uri,
+ media_content_type=MediaType.PLAYLIST,
+ title=playlist.name,
+ can_play=True,
+ can_expand=True,
+ children_media_class=MediaClass.TRACK,
+ children=[
+ build_item(mass, item, can_expand=False)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for item in await mass.music.get_playlist_tracks(
+ playlist.item_id, playlist.provider
+ )
+ if item.available
+ ],
+ )
+
+
+async def build_artists_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Albums browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ARTISTS]
+
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_ARTISTS,
+ media_content_type=MediaType.ARTIST,
+ title=LIBRARY_TITLE_MAP[LIBRARY_ARTISTS],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=sorted(
+ [
+ build_item(mass, artist, can_expand=True)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for artist in await mass.music.get_library_artists(limit=500)
+ if artist.available
+ ],
+ key=lambda x: x.title,
+ ),
+ )
+
+
+async def build_artist_items_listing(
+ mass: MusicAssistantClient, identifier: str
+) -> BrowseMedia:
+ """Build Artist items browse listing."""
+ artist = await mass.music.get_item_by_uri(identifier)
+ albums = await mass.music.get_artist_albums(artist.item_id, artist.provider)
+
+ return BrowseMedia(
+ media_class=MediaType.ARTIST,
+ media_content_id=artist.uri,
+ media_content_type=MediaType.ARTIST,
+ title=artist.name,
+ can_play=True,
+ can_expand=True,
+ children_media_class=MediaClass.ALBUM,
+ children=[
+ build_item(mass, album, can_expand=True)
+ for album in albums
+ if album.available
+ ],
+ )
+
+
+async def build_albums_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Albums browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_ALBUMS]
+
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_ALBUMS,
+ media_content_type=MediaType.ALBUM,
+ title=LIBRARY_TITLE_MAP[LIBRARY_ALBUMS],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=sorted(
+ [
+ build_item(mass, album, can_expand=True)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for album in await mass.music.get_library_albums(limit=500)
+ if album.available
+ ],
+ key=lambda x: x.title,
+ ),
+ )
+
+
+async def build_album_items_listing(
+ mass: MusicAssistantClient, identifier: str
+) -> BrowseMedia:
+ """Build Album items browse listing."""
+ album = await mass.music.get_item_by_uri(identifier)
+ tracks = await mass.music.get_album_tracks(album.item_id, album.provider)
+
+ return BrowseMedia(
+ media_class=MediaType.ALBUM,
+ media_content_id=album.uri,
+ media_content_type=MediaType.ALBUM,
+ title=album.name,
+ can_play=True,
+ can_expand=True,
+ children_media_class=MediaClass.TRACK,
+ children=[
+ build_item(mass, track, False) for track in tracks if track.available
+ ],
+ )
+
+
+async def build_tracks_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Tracks browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_TRACKS]
+
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_TRACKS,
+ media_content_type=MediaType.TRACK,
+ title=LIBRARY_TITLE_MAP[LIBRARY_TRACKS],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=sorted(
+ [
+ build_item(mass, track, can_expand=False)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for track in await mass.music.get_library_tracks(limit=500)
+ if track.available
+ ],
+ key=lambda x: x.title,
+ ),
+ )
+
+
+async def build_radio_listing(mass: MusicAssistantClient) -> BrowseMedia:
+ """Build Radio browse listing."""
+ media_class = LIBRARY_MEDIA_CLASS_MAP[LIBRARY_RADIO]
+ return BrowseMedia(
+ media_class=MediaClass.DIRECTORY,
+ media_content_id=LIBRARY_RADIO,
+ media_content_type=DOMAIN,
+ title=LIBRARY_TITLE_MAP[LIBRARY_RADIO],
+ can_play=False,
+ can_expand=True,
+ children_media_class=media_class,
+ children=[
+ build_item(mass, track, can_expand=False, media_class=media_class)
+ # we only grab the first page here because the
+ # HA media browser does not support paging
+ for track in await mass.music.get_library_radios(limit=500)
+ if track.available
+ ],
+ )
+
+
+def build_item(
+ mass: MusicAssistantClient,
+ item: MediaItemType,
+ can_expand: bool = True,
+ media_class: Any = None,
+) -> BrowseMedia:
+ """Return BrowseMedia for MediaItem."""
+ if artists := getattr(item, "artists", None):
+ title = f"{artists[0].name} - {item.name}"
+ else:
+ title = item.name
+ img_url = mass.get_media_item_image_url(item)
+
+ return BrowseMedia(
+ media_class=media_class or item.media_type.value,
+ media_content_id=item.uri,
+ media_content_type=MediaType.MUSIC,
+ title=title,
+ can_play=True,
+ can_expand=can_expand,
+ thumbnail=img_url,
+ )
diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py
index f0f3675ee32..fdf3a0c0c48 100644
--- a/homeassistant/components/music_assistant/media_player.py
+++ b/homeassistant/components/music_assistant/media_player.py
@@ -13,15 +13,18 @@ from music_assistant_models.enums import (
EventType,
MediaType,
PlayerFeature,
+ PlayerState as MassPlayerState,
QueueOption,
RepeatMode as MassRepeatMode,
)
from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
+import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
+ ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EXTRA,
BrowseMedia,
MediaPlayerDeviceClass,
@@ -37,12 +40,17 @@ from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_platform import (
+ AddEntitiesCallback,
+ async_get_current_platform,
+)
from homeassistant.util.dt import utc_from_timestamp
from . import MusicAssistantConfigEntry
from .const import ATTR_ACTIVE_QUEUE, ATTR_MASS_PLAYER_TYPE, DOMAIN
from .entity import MusicAssistantEntity
+from .media_browser import async_browse_media
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
@@ -78,6 +86,9 @@ QUEUE_OPTION_MAP = {
MediaPlayerEnqueue.REPLACE: QueueOption.REPLACE,
}
+SERVICE_PLAY_MEDIA_ADVANCED = "play_media"
+SERVICE_PLAY_ANNOUNCEMENT = "play_announcement"
+SERVICE_TRANSFER_QUEUE = "transfer_queue"
ATTR_RADIO_MODE = "radio_mode"
ATTR_MEDIA_ID = "media_id"
ATTR_MEDIA_TYPE = "media_type"
@@ -137,6 +148,38 @@ async def async_setup_entry(
async_add_entities(mass_players)
+ # add platform service for play_media with advanced options
+ platform = async_get_current_platform()
+ platform.async_register_entity_service(
+ SERVICE_PLAY_MEDIA_ADVANCED,
+ {
+ vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
+ vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption),
+ vol.Optional(ATTR_ARTIST): cv.string,
+ vol.Optional(ATTR_ALBUM): cv.string,
+ vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool),
+ },
+ "_async_handle_play_media",
+ )
+ platform.async_register_entity_service(
+ SERVICE_PLAY_ANNOUNCEMENT,
+ {
+ vol.Required(ATTR_URL): cv.string,
+ vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
+ vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
+ },
+ "_async_handle_play_announcement",
+ )
+ platform.async_register_entity_service(
+ SERVICE_TRANSFER_QUEUE,
+ {
+ vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id,
+ vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool),
+ },
+ "_async_handle_transfer_queue",
+ )
+
class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Representation of MediaPlayerEntity from Music Assistant Player."""
@@ -150,8 +193,10 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
super().__init__(mass, player_id)
self._attr_icon = self.player.icon.replace("mdi-", "mdi:")
self._attr_supported_features = SUPPORTED_FEATURES
- if PlayerFeature.SYNC in self.player.supported_features:
+ if PlayerFeature.SET_MEMBERS in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING
+ if PlayerFeature.VOLUME_MUTE in self.player.supported_features:
+ self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
self._prev_time: float = 0
@@ -219,7 +264,9 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
)
)
]
- self._attr_group_members = group_members_entity_ids
+ # NOTE: we sort the group_members for now,
+ # until the MA API returns them sorted (group_childs is now a set)
+ self._attr_group_members = sorted(group_members_entity_ids)
self._attr_volume_level = (
player.volume_level / 100 if player.volume_level is not None else None
)
@@ -353,24 +400,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player."""
player_ids: list[str] = []
+ entity_registry = er.async_get(self.hass)
for child_entity_id in group_members:
# resolve HA entity_id to MA player_id
- if (hass_state := self.hass.states.get(child_entity_id)) is None:
- continue
- if (mass_player_id := hass_state.attributes.get("mass_player_id")) is None:
- continue
- player_ids.append(mass_player_id)
- await self.mass.players.player_command_sync_many(self.player_id, player_ids)
+ if not (entity_reg_entry := entity_registry.async_get(child_entity_id)):
+ raise HomeAssistantError(f"Entity {child_entity_id} not found")
+ # unique id is the MA player_id
+ player_ids.append(entity_reg_entry.unique_id)
+ await self.mass.players.player_command_group_many(self.player_id, player_ids)
@catch_musicassistant_error
async def async_unjoin_player(self) -> None:
"""Remove this player from any group."""
- await self.mass.players.player_command_unsync(self.player_id)
+ await self.mass.players.player_command_ungroup(self.player_id)
@catch_musicassistant_error
async def _async_handle_play_media(
self,
media_id: list[str],
+ artist: str | None = None,
+ album: str | None = None,
enqueue: MediaPlayerEnqueue | QueueOption | None = None,
radio_mode: bool | None = None,
media_type: str | None = None,
@@ -397,6 +446,14 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
elif await asyncio.to_thread(os.path.isfile, media_id_str):
media_uris.append(media_id_str)
continue
+ # last resort: search for media item by name/search
+ if item := await self.mass.music.get_item_by_name(
+ name=media_id_str,
+ artist=artist,
+ album=album,
+ media_type=MediaType(media_type) if media_type else None,
+ ):
+ media_uris.append(item.uri)
if not media_uris:
raise HomeAssistantError(
@@ -430,16 +487,43 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self.player_id, url, use_pre_announce, announce_volume
)
+ @catch_musicassistant_error
+ async def _async_handle_transfer_queue(
+ self, source_player: str | None = None, auto_play: bool | None = None
+ ) -> None:
+ """Transfer the current queue to another player."""
+ if not source_player:
+ # no source player given; try to find a playing player(queue)
+ for queue in self.mass.player_queues:
+ if queue.state == MassPlayerState.PLAYING:
+ source_queue_id = queue.queue_id
+ break
+ else:
+ raise HomeAssistantError(
+ "Source player not specified and no playing player found."
+ )
+ else:
+ # resolve HA entity_id to MA player_id
+ entity_registry = er.async_get(self.hass)
+ if (entity := entity_registry.async_get(source_player)) is None:
+ raise HomeAssistantError("Source player not available.")
+ source_queue_id = entity.unique_id # unique_id is the MA player_id
+ target_queue_id = self.player_id
+ await self.mass.player_queues.transfer_queue(
+ source_queue_id, target_queue_id, auto_play
+ )
+
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
- return await media_source.async_browse_media(
+ return await async_browse_media(
self.hass,
+ self.mass,
media_content_id,
- content_filter=lambda item: item.media_content_type.startswith("audio/"),
+ media_content_type,
)
def _update_media_image_url(
diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml
new file mode 100644
index 00000000000..00f895c4ef6
--- /dev/null
+++ b/homeassistant/components/music_assistant/services.yaml
@@ -0,0 +1,90 @@
+# Descriptions for Music Assistant custom services
+
+play_media:
+ target:
+ entity:
+ domain: media_player
+ integration: music_assistant
+ supported_features:
+ - media_player.MediaPlayerEntityFeature.PLAY_MEDIA
+ fields:
+ media_id:
+ required: true
+ example: "spotify://playlist/aabbccddeeff"
+ selector:
+ object:
+ media_type:
+ example: "playlist"
+ selector:
+ select:
+ translation_key: media_type
+ options:
+ - artist
+ - album
+ - playlist
+ - track
+ - radio
+ artist:
+ example: "Queen"
+ selector:
+ text:
+ album:
+ example: "News of the world"
+ selector:
+ text:
+ enqueue:
+ selector:
+ select:
+ options:
+ - "play"
+ - "replace"
+ - "next"
+ - "replace_next"
+ - "add"
+ translation_key: enqueue
+ radio_mode:
+ advanced: true
+ selector:
+ boolean:
+
+play_announcement:
+ target:
+ entity:
+ domain: media_player
+ integration: music_assistant
+ supported_features:
+ - media_player.MediaPlayerEntityFeature.PLAY_MEDIA
+ - media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE
+ fields:
+ url:
+ required: true
+ example: "http://someremotesite.com/doorbell.mp3"
+ selector:
+ text:
+ use_pre_announce:
+ example: "true"
+ selector:
+ boolean:
+ announce_volume:
+ example: 75
+ selector:
+ number:
+ min: 1
+ max: 100
+ step: 1
+
+transfer_queue:
+ target:
+ entity:
+ domain: media_player
+ integration: music_assistant
+ fields:
+ source_player:
+ selector:
+ entity:
+ domain: media_player
+ integration: music_assistant
+ auto_play:
+ example: "true"
+ selector:
+ boolean:
diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json
index f15b0b1b306..cce7f9607c2 100644
--- a/homeassistant/components/music_assistant/strings.json
+++ b/homeassistant/components/music_assistant/strings.json
@@ -37,6 +37,70 @@
"description": "Check if there are updates available for the Music Assistant Server and/or integration."
}
},
+ "services": {
+ "play_media": {
+ "name": "Play media",
+ "description": "Play media on a Music Assistant player with more fine-grained control options.",
+ "fields": {
+ "media_id": {
+ "name": "Media ID(s)",
+ "description": "URI or name of the item you want to play. Specify a list if you want to play/enqueue multiple items."
+ },
+ "media_type": {
+ "name": "Media type",
+ "description": "The type of the content to play. Such as artist, album, track or playlist. Will be auto-determined if omitted."
+ },
+ "enqueue": {
+ "name": "Enqueue",
+ "description": "If the content should be played now or added to the queue."
+ },
+ "artist": {
+ "name": "Artist name",
+ "description": "When specifying a track or album by name in the Media ID field, you can optionally restrict results by this artist name."
+ },
+ "album": {
+ "name": "Album name",
+ "description": "When specifying a track by name in the Media ID field, you can optionally restrict results by this album name."
+ },
+ "radio_mode": {
+ "name": "Enable radio mode",
+ "description": "Enable radio mode to auto-generate a playlist based on the selection."
+ }
+ }
+ },
+ "play_announcement": {
+ "name": "Play announcement",
+ "description": "Play announcement on a Music Assistant player with more fine-grained control options.",
+ "fields": {
+ "url": {
+ "name": "URL",
+ "description": "URL to the notification sound."
+ },
+ "use_pre_announce": {
+ "name": "Use pre-announce",
+ "description": "Use pre-announcement sound for the announcement. Omit to use the player default."
+ },
+ "announce_volume": {
+ "name": "Announce volume",
+ "description": "Use a forced volume level for the announcement. Omit to use player default."
+ }
+ }
+ },
+ "transfer_queue": {
+ "name": "Transfer queue",
+ "description": "Transfer the player's queue to another player.",
+ "fields": {
+ "source_player": {
+ "name": "Source media player",
+ "description": "The source media player which has the queue you want to transfer. When omitted, the first playing player will be used."
+ },
+ "auto_play": {
+ "name": "Auto play",
+ "description": "Start playing the queue on the target player. Omit to use the default behavior."
+ }
+ }
+ }
+ },
"selector": {
"enqueue": {
"options": {
@@ -46,6 +110,15 @@
"replace": "Play now and clear queue",
"replace_next": "Play next and clear queue"
}
+ },
+ "media_type": {
+ "options": {
+ "artist": "Artist",
+ "album": "Album",
+ "track": "Track",
+ "playlist": "Playlist",
+ "radio": "Radio"
+ }
}
}
}
diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json
index f73d4612c2e..2c4e6a7e735 100644
--- a/homeassistant/components/mvglive/manifest.json
+++ b/homeassistant/components/mvglive/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/mvglive",
"iot_class": "cloud_polling",
"loggers": ["MVGLive"],
+ "quality_scale": "legacy",
"requirements": ["PyMVGLive==1.1.4"]
}
diff --git a/homeassistant/components/mycroft/manifest.json b/homeassistant/components/mycroft/manifest.json
index 9b8731f0701..568bb8b1784 100644
--- a/homeassistant/components/mycroft/manifest.json
+++ b/homeassistant/components/mycroft/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/mycroft",
"iot_class": "local_push",
"loggers": ["mycroftapi"],
+ "quality_scale": "legacy",
"requirements": ["mycroftapi==2.0"]
}
diff --git a/homeassistant/components/mythicbeastsdns/manifest.json b/homeassistant/components/mythicbeastsdns/manifest.json
index ed0b96575c9..a4381c312bc 100644
--- a/homeassistant/components/mythicbeastsdns/manifest.json
+++ b/homeassistant/components/mythicbeastsdns/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mythicbeastsdns",
"iot_class": "cloud_push",
"loggers": ["mbddns"],
+ "quality_scale": "legacy",
"requirements": ["mbddns==0.1.2"]
}
diff --git a/homeassistant/components/nad/manifest.json b/homeassistant/components/nad/manifest.json
index 2e2d44341af..64c7855af2d 100644
--- a/homeassistant/components/nad/manifest.json
+++ b/homeassistant/components/nad/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nad",
"iot_class": "local_polling",
"loggers": ["nad_receiver"],
+ "quality_scale": "legacy",
"requirements": ["nad-receiver==0.3.0"]
}
diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json
index 7b37d1f7ede..c3a559de50b 100644
--- a/homeassistant/components/nam/manifest.json
+++ b/homeassistant/components/nam/manifest.json
@@ -7,8 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["nettigo_air_monitor"],
- "quality_scale": "platinum",
- "requirements": ["nettigo-air-monitor==3.3.0"],
+ "requirements": ["nettigo-air-monitor==4.0.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/namecheapdns/manifest.json b/homeassistant/components/namecheapdns/manifest.json
index fc9aa3cc033..f97f6568192 100644
--- a/homeassistant/components/namecheapdns/manifest.json
+++ b/homeassistant/components/namecheapdns/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/namecheapdns",
"iot_class": "cloud_push",
+ "quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1"]
}
diff --git a/homeassistant/components/nasweb/manifest.json b/homeassistant/components/nasweb/manifest.json
index e7e06419dad..8a4ecdbee84 100644
--- a/homeassistant/components/nasweb/manifest.json
+++ b/homeassistant/components/nasweb/manifest.json
@@ -5,10 +5,7 @@
"config_flow": true,
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/nasweb",
- "homekit": {},
"integration_type": "hub",
"iot_class": "local_push",
- "requirements": ["webio-api==0.1.8"],
- "ssdp": [],
- "zeroconf": []
+ "requirements": ["webio-api==0.1.11"]
}
diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json
index aa8d0f4adf4..8a8a20c453b 100644
--- a/homeassistant/components/nederlandse_spoorwegen/manifest.json
+++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@YarmoM"],
"documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["nsapi==3.0.5"]
}
diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json
index c3bb4239048..3d97e3290e0 100644
--- a/homeassistant/components/ness_alarm/manifest.json
+++ b/homeassistant/components/ness_alarm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ness_alarm",
"iot_class": "local_push",
"loggers": ["nessclient"],
+ "quality_scale": "legacy",
"requirements": ["nessclient==1.1.2"]
}
diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py
index e89969cbe16..0bd2891914f 100644
--- a/homeassistant/components/nest/__init__.py
+++ b/homeassistant/components/nest/__init__.py
@@ -59,9 +59,7 @@ from .const import (
CONF_SUBSCRIBER_ID,
CONF_SUBSCRIBER_ID_IMPORTED,
CONF_SUBSCRIPTION_NAME,
- DATA_DEVICE_MANAGER,
DATA_SDM,
- DATA_SUBSCRIBER,
DOMAIN,
)
from .events import EVENT_NAME_MAP, NEST_EVENT
@@ -72,6 +70,7 @@ from .media_source import (
async_get_media_source_devices,
async_get_transcoder,
)
+from .types import NestConfigEntry, NestData
_LOGGER = logging.getLogger(__name__)
@@ -113,11 +112,8 @@ THUMBNAIL_SIZE_PX = 175
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Nest components with dispatch between old/new flows."""
- hass.data[DOMAIN] = {}
-
hass.http.register_view(NestEventMediaView(hass))
hass.http.register_view(NestEventMediaThumbnailView(hass))
-
return True
@@ -128,12 +124,12 @@ class SignalUpdateCallback:
self,
hass: HomeAssistant,
config_reload_cb: Callable[[], Awaitable[None]],
- config_entry_id: str,
+ config_entry: NestConfigEntry,
) -> None:
"""Initialize EventCallback."""
self._hass = hass
self._config_reload_cb = config_reload_cb
- self._config_entry_id = config_entry_id
+ self._config_entry = config_entry
async def async_handle_event(self, event_message: EventMessage) -> None:
"""Process an incoming EventMessage."""
@@ -181,17 +177,17 @@ class SignalUpdateCallback:
message["zones"] = image_event.zones
self._hass.bus.async_fire(NEST_EVENT, message)
- def _supported_traits(self, device_id: str) -> list[TraitType]:
- if not (
- device_manager := self._hass.data[DOMAIN]
- .get(self._config_entry_id, {})
- .get(DATA_DEVICE_MANAGER)
- ) or not (device := device_manager.devices.get(device_id)):
+ def _supported_traits(self, device_id: str) -> list[str]:
+ if (
+ not self._config_entry.runtime_data
+ or not (device_manager := self._config_entry.runtime_data.device_manager)
+ or not (device := device_manager.devices.get(device_id))
+ ):
return []
return list(device.traits)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool:
"""Set up Nest from a config entry with dispatch between old/new flows."""
if DATA_SDM not in entry.data:
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
@@ -215,7 +211,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_config_reload() -> None:
await hass.config_entries.async_reload(entry.entry_id)
- update_callback = SignalUpdateCallback(hass, async_config_reload, entry.entry_id)
+ update_callback = SignalUpdateCallback(hass, async_config_reload, entry)
subscriber.set_update_callback(update_callback.async_handle_event)
try:
await subscriber.start_async()
@@ -245,11 +241,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
)
-
- hass.data[DOMAIN][entry.entry_id] = {
- DATA_SUBSCRIBER: subscriber,
- DATA_DEVICE_MANAGER: device_manager,
- }
+ entry.runtime_data = NestData(
+ subscriber=subscriber,
+ device_manager=device_manager,
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -262,13 +257,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Legacy API
return True
_LOGGER.debug("Stopping nest subscriber")
- subscriber = hass.data[DOMAIN][entry.entry_id][DATA_SUBSCRIBER]
+ subscriber = entry.runtime_data.subscriber
subscriber.stop_async()
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py
index 281e6b0bb28..df02f17444f 100644
--- a/homeassistant/components/nest/camera.py
+++ b/homeassistant/components/nest/camera.py
@@ -17,28 +17,25 @@ from google_nest_sdm.camera_traits import (
WebRtcStream,
)
from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.exceptions import ApiException
from webrtc_models import RTCIceCandidateInit
from homeassistant.components.camera import (
Camera,
CameraEntityFeature,
- StreamType,
WebRTCAnswer,
WebRTCClientConfiguration,
WebRTCSendMessage,
)
from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow
-from .const import DATA_DEVICE_MANAGER, DOMAIN
from .device_info import NestDeviceInfo
+from .types import NestConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -54,15 +51,12 @@ BACKOFF_MULTIPLIER = 1.5
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the cameras."""
- device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
entities: list[NestCameraBaseEntity] = []
- for device in device_manager.devices.values():
+ for device in entry.runtime_data.device_manager.devices.values():
if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None:
continue
if StreamingProtocol.WEB_RTC in live_stream.supported_protocols:
@@ -254,11 +248,6 @@ class NestWebRTCEntity(NestCameraBaseEntity):
self._webrtc_sessions: dict[str, WebRtcStream] = {}
self._refresh_unsub: dict[str, Callable[[], None]] = {}
- @property
- def frontend_stream_type(self) -> StreamType | None:
- """Return the type of stream supported by this camera."""
- return StreamType.WEB_RTC
-
async def _async_refresh_stream(self, session_id: str) -> datetime.datetime | None:
"""Refresh stream to extend expiration time."""
if not (webrtc_stream := self._webrtc_sessions.get(session_id)):
diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py
index 03fb641d0e5..1e2727bfab7 100644
--- a/homeassistant/components/nest/climate.py
+++ b/homeassistant/components/nest/climate.py
@@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Any, cast
from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.device_traits import FanTrait, TemperatureTrait
from google_nest_sdm.exceptions import ApiException
from google_nest_sdm.thermostat_traits import (
@@ -28,14 +27,13 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_DEVICE_MANAGER, DOMAIN
from .device_info import NestDeviceInfo
+from .types import NestConfigEntry
# Mapping for sdm.devices.traits.ThermostatMode mode field
THERMOSTAT_MODE_MAP: dict[str, HVACMode] = {
@@ -78,17 +76,13 @@ MIN_TEMP_RANGE = 1.66667
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the client entities."""
- device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
-
async_add_entities(
ThermostatEntity(device)
- for device in device_manager.devices.values()
+ for device in entry.runtime_data.device_manager.devices.values()
if ThermostatHvacTrait.NAME in device.traits
)
diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py
index 0a828dcbf78..9950d1d5c2a 100644
--- a/homeassistant/components/nest/const.py
+++ b/homeassistant/components/nest/const.py
@@ -2,8 +2,6 @@
DOMAIN = "nest"
DATA_SDM = "sdm"
-DATA_SUBSCRIBER = "subscriber"
-DATA_DEVICE_MANAGER = "device_manager"
WEB_AUTH_DOMAIN = DOMAIN
INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed"
diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py
index 33793fe836b..facd429b139 100644
--- a/homeassistant/components/nest/device_info.py
+++ b/homeassistant/components/nest/device_info.py
@@ -7,11 +7,12 @@ from collections.abc import Mapping
from google_nest_sdm.device import Device
from google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from .const import CONNECTIVITY_TRAIT_OFFLINE, DATA_DEVICE_MANAGER, DOMAIN
+from .const import CONNECTIVITY_TRAIT_OFFLINE, DOMAIN
DEVICE_TYPE_MAP: dict[str, str] = {
"sdm.devices.types.CAMERA": "Camera",
@@ -81,14 +82,12 @@ class NestDeviceInfo:
@callback
def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]:
"""Return a mapping of all nest devices for all config entries."""
- devices = {}
- for entry_id in hass.data[DOMAIN]:
- if not (device_manager := hass.data[DOMAIN][entry_id].get(DATA_DEVICE_MANAGER)):
- continue
- devices.update(
- {device.name: device for device in device_manager.devices.values()}
- )
- return devices
+ return {
+ device.name: device
+ for config_entry in hass.config_entries.async_entries(DOMAIN)
+ if config_entry.state == ConfigEntryState.LOADED
+ for device in config_entry.runtime_data.device_manager.devices.values()
+ }
@callback
diff --git a/homeassistant/components/nest/diagnostics.py b/homeassistant/components/nest/diagnostics.py
index 57ce4291cc6..345e15b0593 100644
--- a/homeassistant/components/nest/diagnostics.py
+++ b/homeassistant/components/nest/diagnostics.py
@@ -5,46 +5,26 @@ from __future__ import annotations
from typing import Any
from google_nest_sdm import diagnostics
-from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.device_traits import InfoTrait
from homeassistant.components.camera import diagnostics as camera_diagnostics
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
-from .const import DATA_DEVICE_MANAGER, DATA_SDM, DOMAIN
+from .types import NestConfigEntry
REDACT_DEVICE_TRAITS = {InfoTrait.NAME}
-@callback
-def _async_get_nest_devices(
- hass: HomeAssistant, config_entry: ConfigEntry
-) -> dict[str, Device]:
- """Return dict of available devices."""
- if DATA_SDM not in config_entry.data:
- return {}
-
- if (
- config_entry.entry_id not in hass.data[DOMAIN]
- or DATA_DEVICE_MANAGER not in hass.data[DOMAIN][config_entry.entry_id]
- ):
- return {}
-
- device_manager: DeviceManager = hass.data[DOMAIN][config_entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
- return device_manager.devices
-
-
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: NestConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- nest_devices = _async_get_nest_devices(hass, config_entry)
- if not nest_devices:
+ if (
+ not hasattr(config_entry, "runtime_data")
+ or not config_entry.runtime_data
+ or not (nest_devices := config_entry.runtime_data.device_manager.devices)
+ ):
return {}
data: dict[str, Any] = {
**diagnostics.get_diagnostics(),
@@ -62,11 +42,11 @@ async def async_get_config_entry_diagnostics(
async def async_get_device_diagnostics(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: NestConfigEntry,
device: DeviceEntry,
) -> dict[str, Any]:
"""Return diagnostics for a device."""
- nest_devices = _async_get_nest_devices(hass, config_entry)
+ nest_devices = config_entry.runtime_data.device_manager.devices
nest_device_id = next(iter(device.identifiers))[1]
nest_device = nest_devices.get(nest_device_id)
return nest_device.get_diagnostics() if nest_device else {}
diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py
index a6d70fe86d5..1a2c0317496 100644
--- a/homeassistant/components/nest/event.py
+++ b/homeassistant/components/nest/event.py
@@ -4,7 +4,6 @@ from dataclasses import dataclass
import logging
from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.event import EventMessage, EventType
from google_nest_sdm.traits import TraitType
@@ -13,11 +12,9 @@ from homeassistant.components.event import (
EventEntity,
EventEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_DEVICE_MANAGER, DOMAIN
from .device_info import NestDeviceInfo
from .events import (
EVENT_CAMERA_MOTION,
@@ -26,6 +23,7 @@ from .events import (
EVENT_DOORBELL_CHIME,
EVENT_NAME_MAP,
)
+from .types import NestConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -68,16 +66,12 @@ ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the sensors."""
-
- device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
async_add_entities(
NestTraitEventEntity(desc, device)
- for device in device_manager.devices.values()
+ for device in entry.runtime_data.device_manager.devices.values()
for desc in ENTITY_DESCRIPTIONS
if any(trait in device.traits for trait in desc.trait_types)
)
diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json
index 44eaeeaf62d..07c34c51568 100644
--- a/homeassistant/components/nest/manifest.json
+++ b/homeassistant/components/nest/manifest.json
@@ -19,6 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/nest",
"iot_class": "cloud_push",
"loggers": ["google_nest_sdm"],
- "quality_scale": "platinum",
"requirements": ["google-nest-sdm==6.1.5"]
}
diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py
index edd359619fd..02a0e305813 100644
--- a/homeassistant/components/nest/sensor.py
+++ b/homeassistant/components/nest/sensor.py
@@ -5,7 +5,6 @@ from __future__ import annotations
import logging
from google_nest_sdm.device import Device
-from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.device_traits import HumidityTrait, TemperatureTrait
from homeassistant.components.sensor import (
@@ -13,13 +12,12 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DATA_DEVICE_MANAGER, DOMAIN
from .device_info import NestDeviceInfo
+from .types import NestConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -33,15 +31,12 @@ DEVICE_TYPE_MAP = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the sensors."""
- device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
- DATA_DEVICE_MANAGER
- ]
entities: list[SensorEntity] = []
- for device in device_manager.devices.values():
+ for device in entry.runtime_data.device_manager.devices.values():
if TemperatureTrait.NAME in device.traits:
entities.append(TemperatureSensor(device))
if HumidityTrait.NAME in device.traits:
diff --git a/homeassistant/components/nest/types.py b/homeassistant/components/nest/types.py
new file mode 100644
index 00000000000..bd6cd5cd887
--- /dev/null
+++ b/homeassistant/components/nest/types.py
@@ -0,0 +1,19 @@
+"""Type definitions for Nest."""
+
+from dataclasses import dataclass
+
+from google_nest_sdm.device_manager import DeviceManager
+from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber
+
+from homeassistant.config_entries import ConfigEntry
+
+
+@dataclass
+class NestData:
+ """Data for the Nest integration."""
+
+ subscriber: GoogleNestSubscriber
+ device_manager: DeviceManager
+
+
+type NestConfigEntry = ConfigEntry[NestData]
diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json
index 99410ce033d..199073298ab 100644
--- a/homeassistant/components/netdata/manifest.json
+++ b/homeassistant/components/netdata/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/netdata",
"iot_class": "local_polling",
"loggers": ["netdata"],
+ "quality_scale": "legacy",
"requirements": ["netdata==1.1.0"]
}
diff --git a/homeassistant/components/netio/manifest.json b/homeassistant/components/netio/manifest.json
index 683df22e1ff..f2914b17dec 100644
--- a/homeassistant/components/netio/manifest.json
+++ b/homeassistant/components/netio/manifest.json
@@ -5,5 +5,6 @@
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/netio",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pynetio==0.1.9.1"]
}
diff --git a/homeassistant/components/neurio_energy/manifest.json b/homeassistant/components/neurio_energy/manifest.json
index 467825da012..3a524ac4b5f 100644
--- a/homeassistant/components/neurio_energy/manifest.json
+++ b/homeassistant/components/neurio_energy/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/neurio_energy",
"iot_class": "cloud_polling",
"loggers": ["neurio"],
+ "quality_scale": "legacy",
"requirements": ["neurio==0.3.1"]
}
diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json
index aec145b8806..d88ce0b898d 100644
--- a/homeassistant/components/nexia/strings.json
+++ b/homeassistant/components/nexia/strings.json
@@ -64,7 +64,7 @@
"services": {
"set_aircleaner_mode": {
"name": "Set air cleaner mode",
- "description": "The air cleaner mode.",
+ "description": "Sets the air cleaner mode.",
"fields": {
"aircleaner_mode": {
"name": "Air cleaner mode",
@@ -74,17 +74,17 @@
},
"set_humidify_setpoint": {
"name": "Set humidify set point",
- "description": "The humidification set point.",
+ "description": "Sets the target humidity.",
"fields": {
"humidity": {
- "name": "Humidify",
+ "name": "Humidity",
"description": "The humidification setpoint."
}
}
},
"set_hvac_run_mode": {
"name": "Set hvac run mode",
- "description": "The HVAC run mode.",
+ "description": "Sets the HVAC operation mode.",
"fields": {
"run_mode": {
"name": "Run mode",
diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json
index ab80c83357b..d10a1728a94 100644
--- a/homeassistant/components/nextdns/manifest.json
+++ b/homeassistant/components/nextdns/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["nextdns"],
- "quality_scale": "platinum",
"requirements": ["nextdns==4.0.0"]
}
diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py
index b390ac93e06..ef2b5140fa1 100644
--- a/homeassistant/components/nextdns/sensor.py
+++ b/homeassistant/components/nextdns/sensor.py
@@ -54,7 +54,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
coordinator_type=ATTR_STATUS,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="all_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.all_queries,
),
@@ -63,7 +62,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
coordinator_type=ATTR_STATUS,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="blocked_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.blocked_queries,
),
@@ -72,7 +70,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
coordinator_type=ATTR_STATUS,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="relayed_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.relayed_queries,
),
@@ -91,7 +88,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="doh_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.doh_queries,
),
@@ -101,7 +97,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="doh3_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.doh3_queries,
),
@@ -111,7 +106,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="dot_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.dot_queries,
),
@@ -121,7 +115,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="doq_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.doq_queries,
),
@@ -131,7 +124,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="tcp_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.tcp_queries,
),
@@ -141,7 +133,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="udp_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.udp_queries,
),
@@ -211,7 +202,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="encrypted_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.encrypted_queries,
),
@@ -221,7 +211,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="unencrypted_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.unencrypted_queries,
),
@@ -241,7 +230,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="ipv4_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.ipv4_queries,
),
@@ -251,7 +239,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="ipv6_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.ipv6_queries,
),
@@ -271,7 +258,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="validated_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.validated_queries,
),
@@ -281,7 +267,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="not_validated_queries",
- native_unit_of_measurement="queries",
state_class=SensorStateClass.TOTAL,
value=lambda data: data.not_validated_queries,
),
diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json
index 9dbc8061849..f2a5fa2816d 100644
--- a/homeassistant/components/nextdns/strings.json
+++ b/homeassistant/components/nextdns/strings.json
@@ -48,76 +48,91 @@
},
"sensor": {
"all_queries": {
- "name": "DNS queries"
+ "name": "DNS queries",
+ "unit_of_measurement": "queries"
},
"blocked_queries": {
- "name": "DNS queries blocked"
+ "name": "DNS queries blocked",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"blocked_queries_ratio": {
"name": "DNS queries blocked ratio"
},
"doh3_queries": {
- "name": "DNS-over-HTTP/3 queries"
+ "name": "DNS-over-HTTP/3 queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"doh3_queries_ratio": {
"name": "DNS-over-HTTP/3 queries ratio"
},
"doh_queries": {
- "name": "DNS-over-HTTPS queries"
+ "name": "DNS-over-HTTPS queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"doh_queries_ratio": {
"name": "DNS-over-HTTPS queries ratio"
},
"doq_queries": {
- "name": "DNS-over-QUIC queries"
+ "name": "DNS-over-QUIC queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"doq_queries_ratio": {
"name": "DNS-over-QUIC queries ratio"
},
"dot_queries": {
- "name": "DNS-over-TLS queries"
+ "name": "DNS-over-TLS queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"dot_queries_ratio": {
"name": "DNS-over-TLS queries ratio"
},
"encrypted_queries": {
- "name": "Encrypted queries"
+ "name": "Encrypted queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"encrypted_queries_ratio": {
"name": "Encrypted queries ratio"
},
"ipv4_queries": {
- "name": "IPv4 queries"
+ "name": "IPv4 queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"ipv6_queries": {
- "name": "IPv6 queries"
+ "name": "IPv6 queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"ipv6_queries_ratio": {
"name": "IPv6 queries ratio"
},
"not_validated_queries": {
- "name": "DNSSEC not validated queries"
+ "name": "DNSSEC not validated queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"relayed_queries": {
- "name": "DNS queries relayed"
+ "name": "DNS queries relayed",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"tcp_queries": {
- "name": "TCP queries"
+ "name": "TCP queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"tcp_queries_ratio": {
"name": "TCP queries ratio"
},
"udp_queries": {
- "name": "UDP queries"
+ "name": "UDP queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"udp_queries_ratio": {
"name": "UDP queries ratio"
},
"unencrypted_queries": {
- "name": "Unencrypted queries"
+ "name": "Unencrypted queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"validated_queries": {
- "name": "DNSSEC validated queries"
+ "name": "DNSSEC validated queries",
+ "unit_of_measurement": "[%key:component::nextdns::entity::sensor::all_queries::unit_of_measurement%]"
},
"validated_queries_ratio": {
"name": "DNSSEC validated queries ratio"
diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json
index b3e5597da73..407cdfcfd57 100644
--- a/homeassistant/components/nibe_heatpump/manifest.json
+++ b/homeassistant/components/nibe_heatpump/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nibe_heatpump",
"iot_class": "local_polling",
- "requirements": ["nibe==2.11.0"]
+ "requirements": ["nibe==2.13.0"]
}
diff --git a/homeassistant/components/nice_go/strings.json b/homeassistant/components/nice_go/strings.json
index 07dabf7d39f..224996e6408 100644
--- a/homeassistant/components/nice_go/strings.json
+++ b/homeassistant/components/nice_go/strings.json
@@ -6,12 +6,20 @@
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "[%key:component::nice_go::config::step::user::data_description::email%]",
+ "password": "[%key:component::nice_go::config::step::user::data_description::password%]"
}
},
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "The email address used to log in to the Nice G.O. app",
+ "password": "The password used to log in to the Nice G.O. app"
}
}
},
diff --git a/homeassistant/components/nightscout/manifest.json b/homeassistant/components/nightscout/manifest.json
index 3551b29ee0b..9b075a6df87 100644
--- a/homeassistant/components/nightscout/manifest.json
+++ b/homeassistant/components/nightscout/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nightscout",
"iot_class": "cloud_polling",
"loggers": ["py_nightscout"],
- "quality_scale": "platinum",
"requirements": ["py-nightscout==1.2.2"]
}
diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py
index 2cb5c70d1dd..bdbb8d6b85f 100644
--- a/homeassistant/components/niko_home_control/__init__.py
+++ b/homeassistant/components/niko_home_control/__init__.py
@@ -1 +1,83 @@
-"""The niko_home_control component."""
+"""The Niko home control integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from nclib.errors import NetcatError
+from nikohomecontrol import NikoHomeControl
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.util import Throttle
+
+PLATFORMS: list[Platform] = [Platform.LIGHT]
+
+type NikoHomeControlConfigEntry = ConfigEntry[NikoHomeControlData]
+
+
+_LOGGER = logging.getLogger(__name__)
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: NikoHomeControlConfigEntry
+) -> bool:
+ """Set Niko Home Control from a config entry."""
+ try:
+ controller = NikoHomeControl({"ip": entry.data[CONF_HOST], "port": 8000})
+ niko_data = NikoHomeControlData(hass, controller)
+ await niko_data.async_update()
+ except NetcatError as err:
+ raise ConfigEntryNotReady("cannot connect to controller.") from err
+ except OSError as err:
+ raise ConfigEntryNotReady(
+ "unknown error while connecting to controller."
+ ) from err
+
+ entry.runtime_data = niko_data
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, entry: NikoHomeControlConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+class NikoHomeControlData:
+ """The class for handling data retrieval."""
+
+ def __init__(self, hass, nhc):
+ """Set up Niko Home Control Data object."""
+ self.nhc = nhc
+ self.hass = hass
+ self.available = True
+ self.data = {}
+ self._system_info = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from the NikoHomeControl API."""
+ _LOGGER.debug("Fetching async state in bulk")
+ try:
+ self.data = await self.hass.async_add_executor_job(
+ self.nhc.list_actions_raw
+ )
+ self.available = True
+ except OSError as ex:
+ _LOGGER.error("Unable to retrieve data from Niko, %s", str(ex))
+ self.available = False
+
+ def get_state(self, aid):
+ """Find and filter state based on action id."""
+ for state in self.data:
+ if state["id"] == aid:
+ return state["value1"]
+ _LOGGER.error("Failed to retrieve state off unknown light")
+ return None
diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py
new file mode 100644
index 00000000000..9174a932534
--- /dev/null
+++ b/homeassistant/components/niko_home_control/config_flow.py
@@ -0,0 +1,66 @@
+"""Config flow for the Niko home control integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from nikohomecontrol import NikoHomeControlConnection
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST
+
+from .const import DOMAIN
+
+DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ }
+)
+
+
+def test_connection(host: str) -> str | None:
+ """Test if we can connect to the Niko Home Control controller."""
+ try:
+ NikoHomeControlConnection(host, 8000)
+ except Exception: # noqa: BLE001
+ return "cannot_connect"
+ return None
+
+
+class NikoHomeControlConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Niko Home Control."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ errors = {}
+
+ if user_input is not None:
+ self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+ error = test_connection(user_input[CONF_HOST])
+ if not error:
+ return self.async_create_entry(
+ title="Niko Home Control",
+ data=user_input,
+ )
+ errors["base"] = error
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
+ """Import a config entry."""
+ self._async_abort_entries_match({CONF_HOST: import_info[CONF_HOST]})
+ error = test_connection(import_info[CONF_HOST])
+
+ if not error:
+ return self.async_create_entry(
+ title="Niko Home Control",
+ data={CONF_HOST: import_info[CONF_HOST]},
+ )
+ return self.async_abort(reason=error)
diff --git a/homeassistant/components/niko_home_control/const.py b/homeassistant/components/niko_home_control/const.py
new file mode 100644
index 00000000000..202b031b9a2
--- /dev/null
+++ b/homeassistant/components/niko_home_control/const.py
@@ -0,0 +1,3 @@
+"""Constants for niko_home_control integration."""
+
+DOMAIN = "niko_home_control"
diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py
index b2d41f3a41e..f2bf302eab7 100644
--- a/homeassistant/components/niko_home_control/light.py
+++ b/homeassistant/components/niko_home_control/light.py
@@ -1,4 +1,4 @@
-"""Support for Niko Home Control."""
+"""Light platform Niko Home Control."""
from __future__ import annotations
@@ -6,7 +6,6 @@ from datetime import timedelta
import logging
from typing import Any
-import nikohomecontrol
import voluptuous as vol
from homeassistant.components.light import (
@@ -16,18 +15,22 @@ from homeassistant.components.light import (
LightEntity,
brightness_supported,
)
+from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import PlatformNotReady
+from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.helpers import issue_registry as ir
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle
+
+from . import NikoHomeControlConfigEntry
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
SCAN_INTERVAL = timedelta(seconds=30)
+# delete after 2025.7.0
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string})
@@ -38,20 +41,56 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Niko Home Control light platform."""
- host = config[CONF_HOST]
-
- try:
- nhc = nikohomecontrol.NikoHomeControl(
- {"ip": host, "port": 8000, "timeout": 20000}
+ # Start import flow
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_IMPORT}, data=config
+ )
+ if (
+ result.get("type") == FlowResultType.ABORT
+ and result.get("reason") != "already_configured"
+ ):
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"deprecated_yaml_import_issue_{result['reason']}",
+ breaks_in_ha_version="2025.7.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Niko Home Control",
+ },
)
- niko_data = NikoHomeControlData(hass, nhc)
- await niko_data.async_update()
- except OSError as err:
- _LOGGER.error("Unable to access %s (%s)", host, err)
- raise PlatformNotReady from err
+ return
+
+ ir.async_create_issue(
+ hass,
+ HOMEASSISTANT_DOMAIN,
+ f"deprecated_yaml_{DOMAIN}",
+ breaks_in_ha_version="2025.7.0",
+ is_fixable=False,
+ issue_domain=DOMAIN,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="deprecated_yaml",
+ translation_placeholders={
+ "domain": DOMAIN,
+ "integration_title": "Niko Home Control",
+ },
+ )
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: NikoHomeControlConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the Niko Home Control light entry."""
+ niko_data = entry.runtime_data
async_add_entities(
- [NikoHomeControlLight(light, niko_data) for light in nhc.list_actions()], True
+ NikoHomeControlLight(light, niko_data) for light in niko_data.nhc.list_actions()
)
@@ -88,36 +127,3 @@ class NikoHomeControlLight(LightEntity):
self._attr_is_on = state != 0
if brightness_supported(self.supported_color_modes):
self._attr_brightness = state * 2.55
-
-
-class NikoHomeControlData:
- """The class for handling data retrieval."""
-
- def __init__(self, hass, nhc):
- """Set up Niko Home Control Data object."""
- self._nhc = nhc
- self.hass = hass
- self.available = True
- self.data = {}
- self._system_info = None
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- async def async_update(self):
- """Get the latest data from the NikoHomeControl API."""
- _LOGGER.debug("Fetching async state in bulk")
- try:
- self.data = await self.hass.async_add_executor_job(
- self._nhc.list_actions_raw
- )
- self.available = True
- except OSError as ex:
- _LOGGER.error("Unable to retrieve data from Niko, %s", str(ex))
- self.available = False
-
- def get_state(self, aid):
- """Find and filter state based on action id."""
- for state in self.data:
- if state["id"] == aid:
- return state["value1"]
- _LOGGER.error("Failed to retrieve state off unknown light")
- return None
diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json
index 72f9dd2f6b3..194596d534f 100644
--- a/homeassistant/components/niko_home_control/manifest.json
+++ b/homeassistant/components/niko_home_control/manifest.json
@@ -1,7 +1,8 @@
{
"domain": "niko_home_control",
"name": "Niko Home Control",
- "codeowners": [],
+ "codeowners": ["@VandeurenGlenn"],
+ "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/niko_home_control",
"iot_class": "local_polling",
"loggers": ["nikohomecontrol"],
diff --git a/homeassistant/components/niko_home_control/strings.json b/homeassistant/components/niko_home_control/strings.json
new file mode 100644
index 00000000000..495dca94c0c
--- /dev/null
+++ b/homeassistant/components/niko_home_control/strings.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Set up your Niko Home Control instance.",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of the Niko Home Control controller."
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ },
+ "issues": {
+ "deprecated_yaml_import_issue_cannot_connect": {
+ "title": "YAML import failed due to a connection error",
+ "description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
+ }
+ }
+}
diff --git a/homeassistant/components/nilu/manifest.json b/homeassistant/components/nilu/manifest.json
index 1eabf9e726e..d99a918ef4f 100644
--- a/homeassistant/components/nilu/manifest.json
+++ b/homeassistant/components/nilu/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nilu",
"iot_class": "cloud_polling",
"loggers": ["niluclient"],
+ "quality_scale": "legacy",
"requirements": ["niluclient==0.1.2"]
}
diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py
index 397ced0f5d3..10d3008fd82 100644
--- a/homeassistant/components/nina/binary_sensor.py
+++ b/homeassistant/components/nina/binary_sensor.py
@@ -25,6 +25,7 @@ from .const import (
ATTR_SENT,
ATTR_SEVERITY,
ATTR_START,
+ ATTR_WEB,
CONF_MESSAGE_SLOTS,
CONF_REGIONS,
DOMAIN,
@@ -103,6 +104,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti
ATTR_SEVERITY: data.severity,
ATTR_RECOMMENDED_ACTIONS: data.recommended_actions,
ATTR_AFFECTED_AREAS: data.affected_areas,
+ ATTR_WEB: data.web,
ATTR_ID: data.id,
ATTR_SENT: data.sent,
ATTR_START: data.start,
diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py
index 1e755056079..47194c4c2de 100644
--- a/homeassistant/components/nina/const.py
+++ b/homeassistant/components/nina/const.py
@@ -27,6 +27,7 @@ ATTR_SENDER: str = "sender"
ATTR_SEVERITY: str = "severity"
ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions"
ATTR_AFFECTED_AREAS: str = "affected_areas"
+ATTR_WEB: str = "web"
ATTR_ID: str = "id"
ATTR_SENT: str = "sent"
ATTR_START: str = "start"
diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py
index c731c7a62d7..2d9548f3d12 100644
--- a/homeassistant/components/nina/coordinator.py
+++ b/homeassistant/components/nina/coordinator.py
@@ -27,6 +27,7 @@ class NinaWarningData:
severity: str
recommended_actions: str
affected_areas: str
+ web: str
sent: str
start: str
expires: str
@@ -127,6 +128,7 @@ class NINADataUpdateCoordinator(
raw_warn.severity,
" ".join([str(action) for action in raw_warn.recommended_actions]),
affected_areas_string,
+ raw_warn.web or "",
raw_warn.sent or "",
raw_warn.start or "",
raw_warn.expires or "",
diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json
index 53a54f26dcf..45212c0220b 100644
--- a/homeassistant/components/nina/manifest.json
+++ b/homeassistant/components/nina/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/nina",
"iot_class": "cloud_polling",
"loggers": ["pynina"],
- "requirements": ["PyNINA==0.3.3"],
+ "requirements": ["PyNINA==0.3.4"],
"single_config_entry": true
}
diff --git a/homeassistant/components/nissan_leaf/manifest.json b/homeassistant/components/nissan_leaf/manifest.json
index 9c3df39c69f..9ad8773ee44 100644
--- a/homeassistant/components/nissan_leaf/manifest.json
+++ b/homeassistant/components/nissan_leaf/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nissan_leaf",
"iot_class": "cloud_polling",
"loggers": ["pycarwings2"],
+ "quality_scale": "legacy",
"requirements": ["pycarwings2==2.14"]
}
diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json
index 24aadb6b4f0..e17d1227bed 100644
--- a/homeassistant/components/nmbs/manifest.json
+++ b/homeassistant/components/nmbs/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nmbs",
"iot_class": "cloud_polling",
"loggers": ["pyrail"],
+ "quality_scale": "legacy",
"requirements": ["pyrail==0.0.3"]
}
diff --git a/homeassistant/components/no_ip/manifest.json b/homeassistant/components/no_ip/manifest.json
index cf995e34b47..8e1e247143e 100644
--- a/homeassistant/components/no_ip/manifest.json
+++ b/homeassistant/components/no_ip/manifest.json
@@ -3,5 +3,6 @@
"name": "No-IP.com",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/no_ip",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/noaa_tides/manifest.json b/homeassistant/components/noaa_tides/manifest.json
index 85c6fbcb788..8cc81857770 100644
--- a/homeassistant/components/noaa_tides/manifest.json
+++ b/homeassistant/components/noaa_tides/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/noaa_tides",
"iot_class": "cloud_polling",
"loggers": ["noaa_coops"],
+ "quality_scale": "legacy",
"requirements": ["noaa-coops==0.1.9"]
}
diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py
index b688bf74a37..82db98e2148 100644
--- a/homeassistant/components/nordpool/__init__.py
+++ b/homeassistant/components/nordpool/__init__.py
@@ -4,9 +4,10 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.util import dt as dt_util
-from .const import PLATFORMS
+from .const import DOMAIN, PLATFORMS
from .coordinator import NordPoolDataUpdateCoordinator
type NordPoolConfigEntry = ConfigEntry[NordPoolDataUpdateCoordinator]
@@ -17,6 +18,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) ->
coordinator = NordPoolDataUpdateCoordinator(hass, entry)
await coordinator.fetch_data(dt_util.utcnow())
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="initial_update_failed",
+ translation_placeholders={"error": str(coordinator.last_exception)},
+ )
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json
index 59ba009eb90..96c22633c9e 100644
--- a/homeassistant/components/nordpool/strings.json
+++ b/homeassistant/components/nordpool/strings.json
@@ -12,12 +12,20 @@
"data": {
"currency": "Currency",
"areas": "Areas"
+ },
+ "data_description": {
+ "currency": "Select currency to display prices in, EUR is the base currency.",
+ "areas": "Areas to display prices for according to Nordpool market areas."
}
},
"reconfigure": {
"data": {
"currency": "[%key:component::nordpool::config::step::user::data::currency%]",
"areas": "[%key:component::nordpool::config::step::user::data::areas%]"
+ },
+ "data_description": {
+ "currency": "[%key:component::nordpool::config::step::user::data_description::currency%]",
+ "areas": "[%key:component::nordpool::config::step::user::data_description::areas%]"
}
}
}
@@ -61,5 +69,10 @@
"name": "Daily average"
}
}
+ },
+ "exceptions": {
+ "initial_update_failed": {
+ "message": "Initial update failed on startup with error {error}"
+ }
}
}
diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json
index 0c8f15b9b78..5ce6efd944c 100644
--- a/homeassistant/components/norway_air/manifest.json
+++ b/homeassistant/components/norway_air/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/norway_air",
"iot_class": "cloud_polling",
"loggers": ["metno"],
+ "quality_scale": "legacy",
"requirements": ["PyMetno==0.13.0"]
}
diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json
index b7d4ec1ad25..e832bfc248a 100644
--- a/homeassistant/components/notify/strings.json
+++ b/homeassistant/components/notify/strings.json
@@ -67,7 +67,7 @@
"fix_flow": {
"step": {
"confirm": {
- "description": "The {integration_title} `notify` actions(s) are migrated. A new `notify` entity is available now to replace each legacy `notify` action.\n\nUpdate any automations to use the new `notify.send_message` action exposed with this new entity. When this is done, fix this issue and restart Home Assistant.",
+ "description": "The {integration_title} `notify` action(s) are migrated. A new `notify` entity is available now to replace each legacy `notify` action.\n\nUpdate any automations to use the new `notify.send_message` action exposed with this new entity. When this is done, fix this issue and restart Home Assistant.",
"title": "Migrate legacy {integration_title} notify action for domain `{domain}`"
}
}
diff --git a/homeassistant/components/notify_events/manifest.json b/homeassistant/components/notify_events/manifest.json
index a2c01e1d718..e154ab85cae 100644
--- a/homeassistant/components/notify_events/manifest.json
+++ b/homeassistant/components/notify_events/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/notify_events",
"iot_class": "cloud_push",
"loggers": ["notify_events"],
+ "quality_scale": "legacy",
"requirements": ["notify-events==1.0.4"]
}
diff --git a/homeassistant/components/nsw_fuel_station/manifest.json b/homeassistant/components/nsw_fuel_station/manifest.json
index 5c105fd0281..3fccab39189 100644
--- a/homeassistant/components/nsw_fuel_station/manifest.json
+++ b/homeassistant/components/nsw_fuel_station/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nsw_fuel_station",
"iot_class": "cloud_polling",
"loggers": ["nsw_fuel"],
+ "quality_scale": "legacy",
"requirements": ["nsw-fuel-api-client==1.1.0"]
}
diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json
index 9d1f60e33d1..802f4c89b72 100644
--- a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json
+++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_geojson_nsw_rfs_incidents"],
+ "quality_scale": "legacy",
"requirements": ["aio-geojson-nsw-rfs-incidents==0.7"]
}
diff --git a/homeassistant/components/numato/manifest.json b/homeassistant/components/numato/manifest.json
index f7bcf0527c2..81f3793fa6c 100644
--- a/homeassistant/components/numato/manifest.json
+++ b/homeassistant/components/numato/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["numato_gpio"],
+ "quality_scale": "legacy",
"requirements": ["numato-gpio==0.13.0"]
}
diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py
index dc169fcb348..9f4aef08aa9 100644
--- a/homeassistant/components/number/__init__.py
+++ b/homeassistant/components/number/__init__.py
@@ -384,6 +384,18 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
):
return self.hass.config.units.temperature_unit
+ if (translation_key := self._unit_of_measurement_translation_key) and (
+ unit_of_measurement
+ := self.platform.default_language_platform_translations.get(translation_key)
+ ):
+ if native_unit_of_measurement is not None:
+ raise ValueError(
+ f"Number entity {type(self)} from integration '{self.platform.platform_name}' "
+ f"has a translation key for unit_of_measurement '{unit_of_measurement}', "
+ f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'"
+ )
+ return unit_of_measurement
+
return native_unit_of_measurement
@cached_property
diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py
index 23e3ce0910b..5a2f4c8675c 100644
--- a/homeassistant/components/number/const.py
+++ b/homeassistant/components/number/const.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from enum import StrEnum
-from functools import partial
from typing import Final
import voluptuous as vol
@@ -17,6 +16,7 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfApparentPower,
+ UnitOfArea,
UnitOfBloodGlucoseConcentration,
UnitOfConductivity,
UnitOfDataRate,
@@ -40,12 +40,6 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
UnitOfVolumetricFlux,
)
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.util.unit_conversion import (
BaseUnitConverter,
TemperatureConverter,
@@ -75,12 +69,6 @@ class NumberMode(StrEnum):
SLIDER = "slider"
-# MODE_* are deprecated as of 2021.12, use the NumberMode enum instead.
-_DEPRECATED_MODE_AUTO: Final = DeprecatedConstantEnum(NumberMode.AUTO, "2025.1")
-_DEPRECATED_MODE_BOX: Final = DeprecatedConstantEnum(NumberMode.BOX, "2025.1")
-_DEPRECATED_MODE_SLIDER: Final = DeprecatedConstantEnum(NumberMode.SLIDER, "2025.1")
-
-
class NumberDeviceClass(StrEnum):
"""Device class for numbers."""
@@ -98,6 +86,12 @@ class NumberDeviceClass(StrEnum):
Unit of measurement: `None`
"""
+ AREA = "area"
+ """Area
+
+ Unit of measurement: `UnitOfArea` units
+ """
+
ATMOSPHERIC_PRESSURE = "atmospheric_pressure"
"""Atmospheric pressure.
@@ -369,7 +363,7 @@ class NumberDeviceClass(StrEnum):
VOLTAGE = "voltage"
"""Voltage.
- Unit of measurement: `V`, `mV`
+ Unit of measurement: `V`, `mV`, `µV`
"""
VOLUME = "volume"
@@ -397,7 +391,7 @@ class NumberDeviceClass(StrEnum):
"""Generic flow rate
Unit of measurement: UnitOfVolumeFlowRate
- - SI / metric: `m³/h`, `L/min`
+ - SI / metric: `m³/h`, `L/min`, `mL/s`
- USCS / imperial: `ft³/min`, `gal/min`
"""
@@ -434,6 +428,7 @@ DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass))
DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower),
NumberDeviceClass.AQI: {None},
+ NumberDeviceClass.AREA: set(UnitOfArea),
NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
NumberDeviceClass.BATTERY: {PERCENTAGE},
NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
@@ -511,10 +506,3 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = {
NumberDeviceClass.TEMPERATURE: TemperatureConverter,
NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter,
}
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json
index 5e0fc6e44d2..636fa0a7751 100644
--- a/homeassistant/components/number/icons.json
+++ b/homeassistant/components/number/icons.json
@@ -9,6 +9,9 @@
"aqi": {
"default": "mdi:air-filter"
},
+ "area": {
+ "default": "mdi:texture-box"
+ },
"atmospheric_pressure": {
"default": "mdi:thermometer-lines"
},
diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json
index b9aec880ecc..cc77d224d72 100644
--- a/homeassistant/components/number/strings.json
+++ b/homeassistant/components/number/strings.json
@@ -37,6 +37,9 @@
"aqi": {
"name": "[%key:component::sensor::entity_component::aqi::name%]"
},
+ "area": {
+ "name": "[%key:component::sensor::entity_component::area::name%]"
+ },
"atmospheric_pressure": {
"name": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]"
},
diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json
index d11a0e62bcf..0e02e652b49 100644
--- a/homeassistant/components/nws/manifest.json
+++ b/homeassistant/components/nws/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nws",
"iot_class": "cloud_polling",
"loggers": ["metar", "pynws"],
- "quality_scale": "platinum",
"requirements": ["pynws[retry]==1.8.2"]
}
diff --git a/homeassistant/components/nx584/manifest.json b/homeassistant/components/nx584/manifest.json
index 84ead05d083..9ac469224d0 100644
--- a/homeassistant/components/nx584/manifest.json
+++ b/homeassistant/components/nx584/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/nx584",
"iot_class": "local_push",
"loggers": ["nx584"],
+ "quality_scale": "legacy",
"requirements": ["pynx584==0.8.2"]
}
diff --git a/homeassistant/components/oasa_telematics/manifest.json b/homeassistant/components/oasa_telematics/manifest.json
index d3dbaad98e3..7365081a959 100644
--- a/homeassistant/components/oasa_telematics/manifest.json
+++ b/homeassistant/components/oasa_telematics/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/oasa_telematics",
"iot_class": "cloud_polling",
"loggers": ["oasatelematics"],
+ "quality_scale": "legacy",
"requirements": ["oasatelematics==0.3"]
}
diff --git a/homeassistant/components/oem/manifest.json b/homeassistant/components/oem/manifest.json
index a8ce99b9372..f7ab34adbd9 100644
--- a/homeassistant/components/oem/manifest.json
+++ b/homeassistant/components/oem/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/oem",
"iot_class": "local_polling",
"loggers": ["oemthermostat"],
+ "quality_scale": "legacy",
"requirements": ["oemthermostat==1.1.1"]
}
diff --git a/homeassistant/components/ohmconnect/manifest.json b/homeassistant/components/ohmconnect/manifest.json
index 74754485ea0..e2f02add22d 100644
--- a/homeassistant/components/ohmconnect/manifest.json
+++ b/homeassistant/components/ohmconnect/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@robbiet480"],
"documentation": "https://www.home-assistant.io/integrations/ohmconnect",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1"]
}
diff --git a/homeassistant/components/ombi/manifest.json b/homeassistant/components/ombi/manifest.json
index d9da13d2381..1afc385a5a7 100644
--- a/homeassistant/components/ombi/manifest.json
+++ b/homeassistant/components/ombi/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@larssont"],
"documentation": "https://www.home-assistant.io/integrations/ombi",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pyombi==0.1.10"]
}
diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json
index 32a08223075..4f3cb5d04ab 100644
--- a/homeassistant/components/onewire/manifest.json
+++ b/homeassistant/components/onewire/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyownet"],
- "quality_scale": "gold",
"requirements": ["pyownet==0.10.0.post1"]
}
diff --git a/homeassistant/components/openalpr_cloud/manifest.json b/homeassistant/components/openalpr_cloud/manifest.json
index 45bce5c7345..5148cb396b6 100644
--- a/homeassistant/components/openalpr_cloud/manifest.json
+++ b/homeassistant/components/openalpr_cloud/manifest.json
@@ -3,5 +3,6 @@
"name": "OpenALPR Cloud",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/openalpr_cloud",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/openerz/manifest.json b/homeassistant/components/openerz/manifest.json
index c7a5a202568..f75e3e492a8 100644
--- a/homeassistant/components/openerz/manifest.json
+++ b/homeassistant/components/openerz/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/openerz",
"iot_class": "cloud_polling",
"loggers": ["openerz_api"],
+ "quality_scale": "legacy",
"requirements": ["openerz-api==0.3.0"]
}
diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json
index 066eb5ee384..45452fe325b 100644
--- a/homeassistant/components/openevse/manifest.json
+++ b/homeassistant/components/openevse/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/openevse",
"iot_class": "local_polling",
"loggers": ["openevsewifi"],
+ "quality_scale": "legacy",
"requirements": ["openevsewifi==1.1.2"]
}
diff --git a/homeassistant/components/openhardwaremonitor/manifest.json b/homeassistant/components/openhardwaremonitor/manifest.json
index 562a2433eab..901424eebc1 100644
--- a/homeassistant/components/openhardwaremonitor/manifest.json
+++ b/homeassistant/components/openhardwaremonitor/manifest.json
@@ -3,5 +3,6 @@
"name": "Open Hardware Monitor",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/openhardwaremonitor",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/opensensemap/manifest.json b/homeassistant/components/opensensemap/manifest.json
index 8fed7ec906e..0256ae42a3a 100644
--- a/homeassistant/components/opensensemap/manifest.json
+++ b/homeassistant/components/opensensemap/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/opensensemap",
"iot_class": "cloud_polling",
"loggers": ["opensensemap_api"],
+ "quality_scale": "legacy",
"requirements": ["opensensemap-api==0.2.0"]
}
diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py
index 5ce9d808b21..8c92c70ab49 100644
--- a/homeassistant/components/opentherm_gw/__init__.py
+++ b/homeassistant/components/opentherm_gw/__init__.py
@@ -47,6 +47,7 @@ from .const import (
CONF_CLIMATE,
CONF_FLOOR_TEMP,
CONF_PRECISION,
+ CONF_TEMPORARY_OVRD_MODE,
CONNECTION_TIMEOUT,
DATA_GATEWAYS,
DATA_OPENTHERM_GW,
@@ -105,6 +106,7 @@ PLATFORMS = [
async def options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
gateway = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][entry.data[CONF_ID]]
+ gateway.options = entry.options
async_dispatcher_send(hass, gateway.options_update_signal, entry)
@@ -469,7 +471,7 @@ class OpenThermGatewayHub:
self.device_path = config_entry.data[CONF_DEVICE]
self.hub_id = config_entry.data[CONF_ID]
self.name = config_entry.data[CONF_NAME]
- self.climate_config = config_entry.options
+ self.options = config_entry.options
self.config_entry_id = config_entry.entry_id
self.update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_update"
self.options_update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_options_update"
@@ -565,3 +567,9 @@ class OpenThermGatewayHub:
def connected(self):
"""Report whether or not we are connected to the gateway."""
return self.gateway.connection.connected
+
+ async def set_room_setpoint(self, temp) -> float:
+ """Set the room temperature setpoint on the gateway. Return the new temperature."""
+ return await self.gateway.set_target_temp(
+ temp, self.options.get(CONF_TEMPORARY_OVRD_MODE, True)
+ )
diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py
index 6edfeb35ec3..e93a76fe7b7 100644
--- a/homeassistant/components/opentherm_gw/climate.py
+++ b/homeassistant/components/opentherm_gw/climate.py
@@ -28,7 +28,6 @@ from . import OpenThermGatewayHub
from .const import (
CONF_READ_PRECISION,
CONF_SET_PRECISION,
- CONF_TEMPORARY_OVRD_MODE,
DATA_GATEWAYS,
DATA_OPENTHERM_GW,
THERMOSTAT_DEVICE_DESCRIPTION,
@@ -102,14 +101,12 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
if CONF_READ_PRECISION in options:
self._attr_precision = options[CONF_READ_PRECISION]
self._attr_target_temperature_step = options.get(CONF_SET_PRECISION)
- self.temporary_ovrd_mode = options.get(CONF_TEMPORARY_OVRD_MODE, True)
@callback
def update_options(self, entry):
"""Update climate entity options."""
self._attr_precision = entry.options[CONF_READ_PRECISION]
self._attr_target_temperature_step = entry.options[CONF_SET_PRECISION]
- self.temporary_ovrd_mode = entry.options[CONF_TEMPORARY_OVRD_MODE]
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
@@ -195,7 +192,5 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
temp = float(kwargs[ATTR_TEMPERATURE])
if temp == self.target_temperature:
return
- self._new_target_temperature = await self._gateway.gateway.set_target_temp(
- temp, self.temporary_ovrd_mode
- )
+ self._new_target_temperature = await self._gateway.set_room_setpoint(temp)
self.async_write_ha_state()
diff --git a/homeassistant/components/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json
index bf8a41d1785..4dd82216f1a 100644
--- a/homeassistant/components/opnsense/manifest.json
+++ b/homeassistant/components/opnsense/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/opnsense",
"iot_class": "local_polling",
"loggers": ["pbr", "pyopnsense"],
+ "quality_scale": "legacy",
"requirements": ["pyopnsense==0.4.0"]
}
diff --git a/homeassistant/components/opple/manifest.json b/homeassistant/components/opple/manifest.json
index 174907dfd0f..dc28d1f0f33 100644
--- a/homeassistant/components/opple/manifest.json
+++ b/homeassistant/components/opple/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/opple",
"iot_class": "local_polling",
"loggers": ["pyoppleio"],
+ "quality_scale": "legacy",
"requirements": ["pyoppleio-legacy==1.0.8"]
}
diff --git a/homeassistant/components/oru/manifest.json b/homeassistant/components/oru/manifest.json
index 23c43e32306..347388b6f15 100644
--- a/homeassistant/components/oru/manifest.json
+++ b/homeassistant/components/oru/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/oru",
"iot_class": "cloud_polling",
"loggers": ["oru"],
+ "quality_scale": "legacy",
"requirements": ["oru==0.1.11"]
}
diff --git a/homeassistant/components/orvibo/manifest.json b/homeassistant/components/orvibo/manifest.json
index 05ce5edd8bd..e3a6676b2f2 100644
--- a/homeassistant/components/orvibo/manifest.json
+++ b/homeassistant/components/orvibo/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/orvibo",
"iot_class": "local_push",
"loggers": ["orvibo"],
+ "quality_scale": "legacy",
"requirements": ["orvibo==1.1.2"]
}
diff --git a/homeassistant/components/osramlightify/manifest.json b/homeassistant/components/osramlightify/manifest.json
index f6a922a09ec..3b11200f1e5 100644
--- a/homeassistant/components/osramlightify/manifest.json
+++ b/homeassistant/components/osramlightify/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/osramlightify",
"iot_class": "local_polling",
"loggers": ["lightify"],
+ "quality_scale": "legacy",
"requirements": ["lightify==1.0.7.3"]
}
diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json
index 52fd1dfc669..8c750aec6bd 100644
--- a/homeassistant/components/overkiz/manifest.json
+++ b/homeassistant/components/overkiz/manifest.json
@@ -20,7 +20,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
- "requirements": ["pyoverkiz==1.14.1"],
+ "requirements": ["pyoverkiz==1.15.0"],
"zeroconf": [
{
"type": "_kizbox._tcp.local.",
diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py
index 1b2a1e218d4..8ba2c1678c2 100644
--- a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py
+++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py
@@ -13,6 +13,7 @@ from homeassistant.components.water_heater import (
WaterHeaterEntityFeature,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.util import dt as dt_util
from .. import OverkizDataUpdateCoordinator
from ..entity import OverkizEntity
@@ -153,11 +154,11 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE
async def async_turn_away_mode_on(self) -> None:
"""Turn away mode on.
- This requires the start date and the end date to be also set.
+ This requires the start date and the end date to be also set, and those dates have to match the device datetime.
The API accepts setting dates in the format of the core:DateTimeState state for the DHW
- {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024})
- The dict is then passed as an away mode start date, and then as an end date, but with the year incremented by 1,
- so the away mode is getting turned on for the next year.
+ {'day': 11, 'hour': 21, 'minute': 12, 'month': 7, 'second': 53, 'weekday': 3, 'year': 2024}
+ The dict is then passed as an actual device date, the away mode start date, and then as an end date,
+ but with the year incremented by 1, so the away mode is getting turned on for the next year.
The weekday number seems to have no effect so the calculation of the future date's weekday number is redundant,
but possible via homeassistant dt_util to form both start and end dates dictionaries from scratch
based on datetime.now() and datetime.timedelta into the future.
@@ -167,13 +168,19 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE
With `refresh_afterwards=False` on the first commands, and `refresh_afterwards=True` only the last command,
the API is not choking and the transition is smooth without the unavailability state.
"""
- now_date = cast(
- dict,
- self.executor.select_state(OverkizState.CORE_DATETIME),
- )
+ now = dt_util.now()
+ now_date = {
+ "month": now.month,
+ "hour": now.hour,
+ "year": now.year,
+ "weekday": now.weekday(),
+ "day": now.day,
+ "minute": now.minute,
+ "second": now.second,
+ }
await self.executor.async_execute_command(
- OverkizCommand.SET_ABSENCE_MODE,
- OverkizCommandParam.PROG,
+ OverkizCommand.SET_DATE_TIME,
+ now_date,
refresh_afterwards=False,
)
await self.executor.async_execute_command(
@@ -183,7 +190,11 @@ class AtlanticDomesticHotWaterProductionMBLComponent(OverkizEntity, WaterHeaterE
await self.executor.async_execute_command(
OverkizCommand.SET_ABSENCE_END_DATE, now_date, refresh_afterwards=False
)
-
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_ABSENCE_MODE,
+ OverkizCommandParam.PROG,
+ refresh_afterwards=False,
+ )
await self.coordinator.async_refresh()
async def async_turn_away_mode_off(self) -> None:
diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py
index 3361506dafb..d2ccc83972a 100644
--- a/homeassistant/components/p1_monitor/__init__.py
+++ b/homeassistant/components/p1_monitor/__init__.py
@@ -7,10 +7,12 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .const import DOMAIN, LOGGER
+from .const import LOGGER
from .coordinator import P1MonitorDataUpdateCoordinator
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+type P1MonitorConfigEntry = ConfigEntry[P1MonitorDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -23,8 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.p1monitor.close()
raise
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -55,7 +56,4 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload P1 Monitor config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- del hass.data[DOMAIN][entry.entry_id]
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py
index c8b4e99099e..d2e2ec5c24e 100644
--- a/homeassistant/components/p1_monitor/diagnostics.py
+++ b/homeassistant/components/p1_monitor/diagnostics.py
@@ -11,13 +11,11 @@ from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from .const import (
- DOMAIN,
SERVICE_PHASES,
SERVICE_SETTINGS,
SERVICE_SMARTMETER,
SERVICE_WATERMETER,
)
-from .coordinator import P1MonitorDataUpdateCoordinator
if TYPE_CHECKING:
from _typeshed import DataclassInstance
@@ -29,23 +27,21 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: P1MonitorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
-
data = {
"entry": {
"title": entry.title,
"data": async_redact_data(entry.data, TO_REDACT),
},
"data": {
- "smartmeter": asdict(coordinator.data[SERVICE_SMARTMETER]),
- "phases": asdict(coordinator.data[SERVICE_PHASES]),
- "settings": asdict(coordinator.data[SERVICE_SETTINGS]),
+ "smartmeter": asdict(entry.runtime_data.data[SERVICE_SMARTMETER]),
+ "phases": asdict(entry.runtime_data.data[SERVICE_PHASES]),
+ "settings": asdict(entry.runtime_data.data[SERVICE_SETTINGS]),
},
}
- if coordinator.has_water_meter:
+ if entry.runtime_data.has_water_meter:
data["data"]["watermeter"] = asdict(
- cast("DataclassInstance", coordinator.data[SERVICE_WATERMETER])
+ cast("DataclassInstance", entry.runtime_data.data[SERVICE_WATERMETER])
)
return data
diff --git a/homeassistant/components/p1_monitor/manifest.json b/homeassistant/components/p1_monitor/manifest.json
index dfc681977a5..28016242a6a 100644
--- a/homeassistant/components/p1_monitor/manifest.json
+++ b/homeassistant/components/p1_monitor/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/p1_monitor",
"iot_class": "local_polling",
"loggers": ["p1monitor"],
- "quality_scale": "platinum",
"requirements": ["p1monitor==3.1.0"]
}
diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py
index 88f6d165f14..771ef0e19af 100644
--- a/homeassistant/components/p1_monitor/sensor.py
+++ b/homeassistant/components/p1_monitor/sensor.py
@@ -239,11 +239,10 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up P1 Monitor Sensors based on a config entry."""
- coordinator: P1MonitorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[P1MonitorSensorEntity] = []
entities.extend(
P1MonitorSensorEntity(
- coordinator=coordinator,
+ entry=entry,
description=description,
name="SmartMeter",
service=SERVICE_SMARTMETER,
@@ -252,7 +251,7 @@ async def async_setup_entry(
)
entities.extend(
P1MonitorSensorEntity(
- coordinator=coordinator,
+ entry=entry,
description=description,
name="Phases",
service=SERVICE_PHASES,
@@ -261,17 +260,17 @@ async def async_setup_entry(
)
entities.extend(
P1MonitorSensorEntity(
- coordinator=coordinator,
+ entry=entry,
description=description,
name="Settings",
service=SERVICE_SETTINGS,
)
for description in SENSORS_SETTINGS
)
- if coordinator.has_water_meter:
+ if entry.runtime_data.has_water_meter:
entities.extend(
P1MonitorSensorEntity(
- coordinator=coordinator,
+ entry=entry,
description=description,
name="WaterMeter",
service=SERVICE_WATERMETER,
@@ -291,24 +290,26 @@ class P1MonitorSensorEntity(
def __init__(
self,
*,
- coordinator: P1MonitorDataUpdateCoordinator,
+ entry: ConfigEntry,
description: SensorEntityDescription,
name: str,
service: Literal["smartmeter", "watermeter", "phases", "settings"],
) -> None:
"""Initialize P1 Monitor sensor."""
- super().__init__(coordinator=coordinator)
+ super().__init__(coordinator=entry.runtime_data)
self._service_key = service
self.entity_description = description
self._attr_unique_id = (
- f"{coordinator.config_entry.entry_id}_{service}_{description.key}"
+ f"{entry.runtime_data.config_entry.entry_id}_{service}_{description.key}"
)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
- identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{service}")},
- configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}",
+ identifiers={
+ (DOMAIN, f"{entry.runtime_data.config_entry.entry_id}_{service}")
+ },
+ configuration_url=f"http://{entry.runtime_data.config_entry.data[CONF_HOST]}",
manufacturer="P1 Monitor",
name=name,
)
diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py
index ecaa8089097..4bea4434496 100644
--- a/homeassistant/components/palazzetti/__init__.py
+++ b/homeassistant/components/palazzetti/__init__.py
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator
-PLATFORMS: list[Platform] = [Platform.CLIMATE]
+PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool:
diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py
index 055b3b40172..356f3a7306f 100644
--- a/homeassistant/components/palazzetti/climate.py
+++ b/homeassistant/components/palazzetti/climate.py
@@ -13,13 +13,12 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import PalazzettiConfigEntry
-from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT, PALAZZETTI
+from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES, FAN_SILENT
from .coordinator import PalazzettiDataUpdateCoordinator
+from .entity import PalazzettiEntity
async def async_setup_entry(
@@ -31,9 +30,7 @@ async def async_setup_entry(
async_add_entities([PalazzettiClimateEntity(entry.runtime_data)])
-class PalazzettiClimateEntity(
- CoordinatorEntity[PalazzettiDataUpdateCoordinator], ClimateEntity
-):
+class PalazzettiClimateEntity(PalazzettiEntity, ClimateEntity):
"""Defines a Palazzetti climate."""
_attr_has_entity_name = True
@@ -53,15 +50,7 @@ class PalazzettiClimateEntity(
super().__init__(coordinator)
client = coordinator.client
mac = coordinator.config_entry.unique_id
- assert mac is not None
self._attr_unique_id = mac
- self._attr_device_info = dr.DeviceInfo(
- connections={(dr.CONNECTION_NETWORK_MAC, mac)},
- name=client.name,
- manufacturer=PALAZZETTI,
- sw_version=client.sw_version,
- hw_version=client.hw_version,
- )
self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
self._attr_min_temp = client.target_temperature_min
self._attr_max_temp = client.target_temperature_max
@@ -75,11 +64,6 @@ class PalazzettiClimateEntity(
if client.has_fan_auto:
self._attr_fan_modes.append(FAN_AUTO)
- @property
- def available(self) -> bool:
- """Is the entity available."""
- return super().available and self.coordinator.client.connected
-
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat or off mode."""
diff --git a/homeassistant/components/palazzetti/diagnostics.py b/homeassistant/components/palazzetti/diagnostics.py
new file mode 100644
index 00000000000..3843f0ec111
--- /dev/null
+++ b/homeassistant/components/palazzetti/diagnostics.py
@@ -0,0 +1,20 @@
+"""Provides diagnostics for Palazzetti."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import PalazzettiConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: PalazzettiConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ client = entry.runtime_data.client
+
+ return {
+ "api_data": client.to_dict(redact=True),
+ }
diff --git a/homeassistant/components/palazzetti/entity.py b/homeassistant/components/palazzetti/entity.py
new file mode 100644
index 00000000000..677c6ccbdc4
--- /dev/null
+++ b/homeassistant/components/palazzetti/entity.py
@@ -0,0 +1,32 @@
+"""Base class for Palazzetti entities."""
+
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import PALAZZETTI
+from .coordinator import PalazzettiDataUpdateCoordinator
+
+
+class PalazzettiEntity(CoordinatorEntity[PalazzettiDataUpdateCoordinator]):
+ """Defines a base Palazzetti entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: PalazzettiDataUpdateCoordinator) -> None:
+ """Initialize Palazzetti entity."""
+ super().__init__(coordinator)
+ client = coordinator.client
+ mac = coordinator.config_entry.unique_id
+ assert mac is not None
+ self._attr_device_info = dr.DeviceInfo(
+ connections={(dr.CONNECTION_NETWORK_MAC, mac)},
+ name=client.name,
+ manufacturer=PALAZZETTI,
+ sw_version=client.sw_version,
+ hw_version=client.hw_version,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Is the entity available."""
+ return super().available and self.coordinator.client.connected
diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json
index 9bf7287fe05..05a5d260b50 100644
--- a/homeassistant/components/palazzetti/manifest.json
+++ b/homeassistant/components/palazzetti/manifest.json
@@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["pypalazzetti==0.1.12"]
+ "requirements": ["pypalazzetti==0.1.14"]
}
diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml
new file mode 100644
index 00000000000..493b2595117
--- /dev/null
+++ b/homeassistant/components/palazzetti/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not register actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not register actions.
+ docs-high-level-description: done
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ This integration does not subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have configuration.
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+ entity-category: todo
+ entity-device-class: done
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: |
+ This integration does not have custom icons.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration does not raise any repairable issues.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py
new file mode 100644
index 00000000000..ead2b236b17
--- /dev/null
+++ b/homeassistant/components/palazzetti/sensor.py
@@ -0,0 +1,106 @@
+"""Support for Palazzetti sensors."""
+
+from dataclasses import dataclass
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import UnitOfLength, UnitOfMass, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from . import PalazzettiConfigEntry
+from .coordinator import PalazzettiDataUpdateCoordinator
+from .entity import PalazzettiEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class PropertySensorEntityDescription(SensorEntityDescription):
+ """Describes a Palazzetti sensor entity that is read from a `PalazzettiClient` property."""
+
+ client_property: str
+ presence_flag: None | str = None
+
+
+PROPERTY_SENSOR_DESCRIPTIONS: list[PropertySensorEntityDescription] = [
+ PropertySensorEntityDescription(
+ key="pellet_quantity",
+ device_class=SensorDeviceClass.WEIGHT,
+ native_unit_of_measurement=UnitOfMass.KILOGRAMS,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key="pellet_quantity",
+ client_property="pellet_quantity",
+ ),
+ PropertySensorEntityDescription(
+ key="pellet_level",
+ device_class=SensorDeviceClass.DISTANCE,
+ native_unit_of_measurement=UnitOfLength.CENTIMETERS,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key="pellet_level",
+ presence_flag="has_pellet_level",
+ client_property="pellet_level",
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: PalazzettiConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up Palazzetti sensor entities based on a config entry."""
+
+ coordinator = entry.runtime_data
+
+ sensors = [
+ PalazzettiSensor(
+ coordinator,
+ PropertySensorEntityDescription(
+ key=sensor.description_key.value,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key=sensor.description_key.value,
+ client_property=sensor.state_property,
+ ),
+ )
+ for sensor in coordinator.client.list_temperatures()
+ ]
+
+ sensors.extend(
+ [
+ PalazzettiSensor(coordinator, description)
+ for description in PROPERTY_SENSOR_DESCRIPTIONS
+ if not description.presence_flag
+ or getattr(coordinator.client, description.presence_flag)
+ ]
+ )
+
+ if sensors:
+ async_add_entities(sensors)
+
+
+class PalazzettiSensor(PalazzettiEntity, SensorEntity):
+ """Define a Palazzetti sensor."""
+
+ entity_description: PropertySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PalazzettiDataUpdateCoordinator,
+ description: PropertySensorEntityDescription,
+ ) -> None:
+ """Initialize Palazzetti sensor."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{description.key}"
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state value of the sensor."""
+
+ return getattr(self.coordinator.client, self.entity_description.client_property)
diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json
index cc10c8ed5c6..435ec0aab85 100644
--- a/homeassistant/components/palazzetti/strings.json
+++ b/homeassistant/components/palazzetti/strings.json
@@ -27,7 +27,7 @@
"invalid_fan_mode": {
"message": "Fan mode {value} is invalid."
},
- "invalid_target_temperatures": {
+ "invalid_target_temperature": {
"message": "Target temperature {value} is invalid."
},
"cannot_connect": {
@@ -47,6 +47,35 @@
}
}
}
+ },
+ "sensor": {
+ "pellet_quantity": {
+ "name": "Pellet quantity"
+ },
+ "pellet_level": {
+ "name": "Pellet level"
+ },
+ "air_outlet_temperature": {
+ "name": "Air outlet temperature"
+ },
+ "wood_combustion_temperature": {
+ "name": "Wood combustion temperature"
+ },
+ "room_temperature": {
+ "name": "Room temperature"
+ },
+ "return_water_temperature": {
+ "name": "Return water temperature"
+ },
+ "tank_water_temperature": {
+ "name": "Tank water temperature"
+ },
+ "t1_hydro": {
+ "name": "Hydro temperature 1"
+ },
+ "t2_hydro": {
+ "name": "Hydro temperature 2"
+ }
}
}
}
diff --git a/homeassistant/components/panasonic_bluray/manifest.json b/homeassistant/components/panasonic_bluray/manifest.json
index fa0202c0871..3de12b051e5 100644
--- a/homeassistant/components/panasonic_bluray/manifest.json
+++ b/homeassistant/components/panasonic_bluray/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/panasonic_bluray",
"iot_class": "local_polling",
"loggers": ["panacotta"],
+ "quality_scale": "legacy",
"requirements": ["panacotta==0.2"]
}
diff --git a/homeassistant/components/pandora/manifest.json b/homeassistant/components/pandora/manifest.json
index b86f0754af3..e7d8946fb38 100644
--- a/homeassistant/components/pandora/manifest.json
+++ b/homeassistant/components/pandora/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pandora",
"iot_class": "local_polling",
"loggers": ["pexpect", "ptyprocess"],
+ "quality_scale": "legacy",
"requirements": ["pexpect==4.6.0"]
}
diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py
index 1802af8e05c..c8233673fde 100644
--- a/homeassistant/components/pegel_online/coordinator.py
+++ b/homeassistant/components/pegel_online/coordinator.py
@@ -7,7 +7,7 @@ from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station, StationMeasurem
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import MIN_TIME_BETWEEN_UPDATES
+from .const import DOMAIN, MIN_TIME_BETWEEN_UPDATES
_LOGGER = logging.getLogger(__name__)
@@ -33,4 +33,8 @@ class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements
try:
return await self.api.async_get_station_measurements(self.station.uuid)
except CONNECT_ERRORS as err:
- raise UpdateFailed(f"Failed to communicate with API: {err}") from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="communication_error",
+ translation_placeholders={"error": str(err)},
+ ) from err
diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json
index d51278d0c1b..443e8c58467 100644
--- a/homeassistant/components/pegel_online/manifest.json
+++ b/homeassistant/components/pegel_online/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aiopegelonline"],
- "requirements": ["aiopegelonline==0.0.10"]
+ "requirements": ["aiopegelonline==0.1.0"]
}
diff --git a/homeassistant/components/pegel_online/strings.json b/homeassistant/components/pegel_online/strings.json
index e777f6169ba..b8d18e63a4f 100644
--- a/homeassistant/components/pegel_online/strings.json
+++ b/homeassistant/components/pegel_online/strings.json
@@ -48,5 +48,10 @@
"name": "Water temperature"
}
}
+ },
+ "exceptions": {
+ "communication_error": {
+ "message": "Failed to communicate with API: {error}"
+ }
}
}
diff --git a/homeassistant/components/pencom/manifest.json b/homeassistant/components/pencom/manifest.json
index 34ebe315972..306b2e7be49 100644
--- a/homeassistant/components/pencom/manifest.json
+++ b/homeassistant/components/pencom/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pencom",
"iot_class": "local_polling",
"loggers": ["pencompy"],
+ "quality_scale": "legacy",
"requirements": ["pencompy==0.0.3"]
}
diff --git a/homeassistant/components/persistent_notification/strings.json b/homeassistant/components/persistent_notification/strings.json
index b9a4ae4f10f..e6c3d3b7775 100644
--- a/homeassistant/components/persistent_notification/strings.json
+++ b/homeassistant/components/persistent_notification/strings.json
@@ -21,17 +21,17 @@
},
"dismiss": {
"name": "Dismiss",
- "description": "Removes a notification from the notifications panel.",
+ "description": "Deletes a notification from the notifications panel.",
"fields": {
"notification_id": {
"name": "[%key:component::persistent_notification::services::create::fields::notification_id::name%]",
- "description": "ID of the notification to be removed."
+ "description": "ID of the notification to be deleted."
}
}
},
"dismiss_all": {
"name": "Dismiss all",
- "description": "Removes all notifications from the notifications panel."
+ "description": "Deletes all notifications from the notifications panel."
}
}
}
diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py
index 503883e9326..4cf5133e700 100644
--- a/homeassistant/components/pi_hole/sensor.py
+++ b/homeassistant/components/pi_hole/sensor.py
@@ -18,7 +18,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="ads_blocked_today",
translation_key="ads_blocked_today",
- native_unit_of_measurement="ads",
),
SensorEntityDescription(
key="ads_percentage_today",
@@ -28,38 +27,20 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="clients_ever_seen",
translation_key="clients_ever_seen",
- native_unit_of_measurement="clients",
),
SensorEntityDescription(
- key="dns_queries_today",
- translation_key="dns_queries_today",
- native_unit_of_measurement="queries",
+ key="dns_queries_today", translation_key="dns_queries_today"
),
SensorEntityDescription(
key="domains_being_blocked",
translation_key="domains_being_blocked",
- native_unit_of_measurement="domains",
),
+ SensorEntityDescription(key="queries_cached", translation_key="queries_cached"),
SensorEntityDescription(
- key="queries_cached",
- translation_key="queries_cached",
- native_unit_of_measurement="queries",
- ),
- SensorEntityDescription(
- key="queries_forwarded",
- translation_key="queries_forwarded",
- native_unit_of_measurement="queries",
- ),
- SensorEntityDescription(
- key="unique_clients",
- translation_key="unique_clients",
- native_unit_of_measurement="clients",
- ),
- SensorEntityDescription(
- key="unique_domains",
- translation_key="unique_domains",
- native_unit_of_measurement="domains",
+ key="queries_forwarded", translation_key="queries_forwarded"
),
+ SensorEntityDescription(key="unique_clients", translation_key="unique_clients"),
+ SensorEntityDescription(key="unique_domains", translation_key="unique_domains"),
)
diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json
index b76b61f1903..9e1d5948a09 100644
--- a/homeassistant/components/pi_hole/strings.json
+++ b/homeassistant/components/pi_hole/strings.json
@@ -41,31 +41,39 @@
},
"sensor": {
"ads_blocked_today": {
- "name": "Ads blocked today"
+ "name": "Ads blocked today",
+ "unit_of_measurement": "ads"
},
"ads_percentage_today": {
"name": "Ads percentage blocked today"
},
"clients_ever_seen": {
- "name": "Seen clients"
+ "name": "Seen clients",
+ "unit_of_measurement": "clients"
},
"dns_queries_today": {
- "name": "DNS queries today"
+ "name": "DNS queries today",
+ "unit_of_measurement": "queries"
},
"domains_being_blocked": {
- "name": "Domains blocked"
+ "name": "Domains blocked",
+ "unit_of_measurement": "domains"
},
"queries_cached": {
- "name": "DNS queries cached"
+ "name": "DNS queries cached",
+ "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]"
},
"queries_forwarded": {
- "name": "DNS queries forwarded"
+ "name": "DNS queries forwarded",
+ "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]"
},
"unique_clients": {
- "name": "DNS unique clients"
+ "name": "DNS unique clients",
+ "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::clients_ever_seen::unit_of_measurement%]"
},
"unique_domains": {
- "name": "DNS unique domains"
+ "name": "DNS unique domains",
+ "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::domains_being_blocked::unit_of_measurement%]"
}
},
"update": {
diff --git a/homeassistant/components/picotts/manifest.json b/homeassistant/components/picotts/manifest.json
index 74b91e187ba..6e8c346a3c9 100644
--- a/homeassistant/components/picotts/manifest.json
+++ b/homeassistant/components/picotts/manifest.json
@@ -3,5 +3,6 @@
"name": "Pico TTS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/picotts",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/pilight/manifest.json b/homeassistant/components/pilight/manifest.json
index 341d0abdf67..da07c4ee645 100644
--- a/homeassistant/components/pilight/manifest.json
+++ b/homeassistant/components/pilight/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pilight",
"iot_class": "local_push",
"loggers": ["pilight"],
+ "quality_scale": "legacy",
"requirements": ["pilight==0.1.1"]
}
diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py
index f4a04caae5b..4b03e5e4407 100644
--- a/homeassistant/components/ping/__init__.py
+++ b/homeassistant/components/ping/__init__.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from dataclasses import dataclass
import logging
from icmplib import SocketPermissionError, async_ping
@@ -12,6 +11,7 @@ from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util.hass_dict import HassKey
from .const import CONF_PING_COUNT, DOMAIN
from .coordinator import PingUpdateCoordinator
@@ -21,13 +21,7 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR]
-
-
-@dataclass(slots=True)
-class PingDomainData:
- """Dataclass to store privileged status."""
-
- privileged: bool | None
+DATA_PRIVILEGED_KEY: HassKey[bool | None] = HassKey(DOMAIN)
type PingConfigEntry = ConfigEntry[PingUpdateCoordinator]
@@ -35,29 +29,25 @@ type PingConfigEntry = ConfigEntry[PingUpdateCoordinator]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the ping integration."""
-
- hass.data[DOMAIN] = PingDomainData(
- privileged=await _can_use_icmp_lib_with_privilege(),
- )
+ hass.data[DATA_PRIVILEGED_KEY] = await _can_use_icmp_lib_with_privilege()
return True
async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool:
"""Set up Ping (ICMP) from a config entry."""
-
- data: PingDomainData = hass.data[DOMAIN]
+ privileged = hass.data[DATA_PRIVILEGED_KEY]
host: str = entry.options[CONF_HOST]
count: int = int(entry.options[CONF_PING_COUNT])
ping_cls: type[PingDataICMPLib | PingDataSubProcess]
- if data.privileged is None:
+ if privileged is None:
ping_cls = PingDataSubProcess
else:
ping_cls = PingDataICMPLib
coordinator = PingUpdateCoordinator(
- hass=hass, ping=ping_cls(hass, host, count, data.privileged)
+ hass=hass, ping=ping_cls(hass, host, count, privileged)
)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py
index 4f2adb0d2c0..27cb3f62bcd 100644
--- a/homeassistant/components/ping/config_flow.py
+++ b/homeassistant/components/ping/config_flow.py
@@ -27,6 +27,12 @@ from .const import CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN
_LOGGER = logging.getLogger(__name__)
+def _clean_user_input(user_input: dict[str, Any]) -> dict[str, Any]:
+ """Clean up the user input."""
+ user_input[CONF_HOST] = user_input[CONF_HOST].strip()
+ return user_input
+
+
class PingConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ping."""
@@ -46,6 +52,7 @@ class PingConfigFlow(ConfigFlow, domain=DOMAIN):
),
)
+ user_input = _clean_user_input(user_input)
if not is_ip_address(user_input[CONF_HOST]):
self.async_abort(reason="invalid_ip_address")
@@ -77,7 +84,7 @@ class OptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
- return self.async_create_entry(title="", data=user_input)
+ return self.async_create_entry(title="", data=_clean_user_input(user_input))
return self.async_show_form(
step_id="init",
diff --git a/homeassistant/components/pioneer/manifest.json b/homeassistant/components/pioneer/manifest.json
index c8aa3a79789..019b7680e09 100644
--- a/homeassistant/components/pioneer/manifest.json
+++ b/homeassistant/components/pioneer/manifest.json
@@ -3,5 +3,6 @@
"name": "Pioneer",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/pioneer",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/pjlink/manifest.json b/homeassistant/components/pjlink/manifest.json
index 553ed185241..787311b250a 100644
--- a/homeassistant/components/pjlink/manifest.json
+++ b/homeassistant/components/pjlink/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pjlink",
"iot_class": "local_polling",
"loggers": ["pypjlink"],
+ "quality_scale": "legacy",
"requirements": ["pypjlink2==1.2.1"]
}
diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py
index 7d1b9ceac8a..a100103b029 100644
--- a/homeassistant/components/plugwise/__init__.py
+++ b/homeassistant/components/plugwise/__init__.py
@@ -83,7 +83,7 @@ def migrate_sensor_entities(
# Migrating opentherm_outdoor_temperature
# to opentherm_outdoor_air_temperature sensor
for device_id, device in coordinator.data.devices.items():
- if device.get("dev_class") != "heater_central":
+ if device["dev_class"] != "heater_central":
continue
old_unique_id = f"{device_id}-outdoor_temperature"
diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py
index fb271ea7264..f422d4facf3 100644
--- a/homeassistant/components/plugwise/binary_sensor.py
+++ b/homeassistant/components/plugwise/binary_sensor.py
@@ -34,7 +34,6 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription):
BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
PlugwiseBinarySensorEntityDescription(
key="low_battery",
- translation_key="low_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -56,7 +55,6 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
PlugwiseBinarySensorEntityDescription(
key="flame_state",
translation_key="flame_state",
- name="Flame state",
entity_category=EntityCategory.DIAGNOSTIC,
),
PlugwiseBinarySensorEntityDescription(
diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py
index 7b0fe35835d..06b8171a528 100644
--- a/homeassistant/components/plugwise/climate.py
+++ b/homeassistant/components/plugwise/climate.py
@@ -39,11 +39,19 @@ async def async_setup_entry(
if not coordinator.new_devices:
return
- async_add_entities(
- PlugwiseClimateEntity(coordinator, device_id)
- for device_id in coordinator.new_devices
- if coordinator.data.devices[device_id]["dev_class"] in MASTER_THERMOSTATS
- )
+ if coordinator.data.gateway["smile_name"] == "Adam":
+ async_add_entities(
+ PlugwiseClimateEntity(coordinator, device_id)
+ for device_id in coordinator.new_devices
+ if coordinator.data.devices[device_id]["dev_class"] == "climate"
+ )
+ else:
+ async_add_entities(
+ PlugwiseClimateEntity(coordinator, device_id)
+ for device_id in coordinator.new_devices
+ if coordinator.data.devices[device_id]["dev_class"]
+ in MASTER_THERMOSTATS
+ )
_add_entities()
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
@@ -52,7 +60,6 @@ async def async_setup_entry(
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
"""Representation of a Plugwise thermostat."""
- _attr_has_entity_name = True
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = DOMAIN
@@ -67,17 +74,20 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
) -> None:
"""Set up the Plugwise API."""
super().__init__(coordinator, device_id)
- self._attr_extra_state_attributes = {}
self._attr_unique_id = f"{device_id}-climate"
- self.cdr_gateway = coordinator.data.gateway
- gateway_id: str = coordinator.data.gateway["gateway_id"]
- self.gateway_data = coordinator.data.devices[gateway_id]
+
+ self._devices = coordinator.data.devices
+ self._gateway = coordinator.data.gateway
+ gateway_id: str = self._gateway["gateway_id"]
+ self._gateway_data = self._devices[gateway_id]
+
+ self._location = device_id
+ if (location := self.device.get("location")) is not None:
+ self._location = location
+
# Determine supported features
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
- if (
- self.cdr_gateway["cooling_present"]
- and self.cdr_gateway["smile_name"] != "Adam"
- ):
+ if self._gateway["cooling_present"] and self._gateway["smile_name"] != "Adam":
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
@@ -103,10 +113,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
"""
# When no cooling available, _previous_mode is always heating
if (
- "regulation_modes" in self.gateway_data
- and "cooling" in self.gateway_data["regulation_modes"]
+ "regulation_modes" in self._gateway_data
+ and "cooling" in self._gateway_data["regulation_modes"]
):
- mode = self.gateway_data["select_regulation_mode"]
+ mode = self._gateway_data["select_regulation_mode"]
if mode in ("cooling", "heating"):
self._previous_mode = mode
@@ -143,7 +153,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return HVAC operation ie. auto, cool, heat, heat_cool, or off mode."""
- if (mode := self.device.get("mode")) is None or mode not in self.hvac_modes:
+ if (
+ mode := self.device.get("climate_mode")
+ ) is None or mode not in self.hvac_modes:
return HVACMode.HEAT
return HVACMode(mode)
@@ -151,17 +163,17 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
def hvac_modes(self) -> list[HVACMode]:
"""Return a list of available HVACModes."""
hvac_modes: list[HVACMode] = []
- if "regulation_modes" in self.gateway_data:
+ if "regulation_modes" in self._gateway_data:
hvac_modes.append(HVACMode.OFF)
if "available_schedules" in self.device:
hvac_modes.append(HVACMode.AUTO)
- if self.cdr_gateway["cooling_present"]:
- if "regulation_modes" in self.gateway_data:
- if self.gateway_data["select_regulation_mode"] == "cooling":
+ if self._gateway["cooling_present"]:
+ if "regulation_modes" in self._gateway_data:
+ if self._gateway_data["select_regulation_mode"] == "cooling":
hvac_modes.append(HVACMode.COOL)
- if self.gateway_data["select_regulation_mode"] == "heating":
+ if self._gateway_data["select_regulation_mode"] == "heating":
hvac_modes.append(HVACMode.HEAT)
else:
hvac_modes.append(HVACMode.HEAT_COOL)
@@ -177,17 +189,21 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
self._previous_action_mode(self.coordinator)
# Adam provides the hvac_action for each thermostat
- if (control_state := self.device.get("control_state")) == "cooling":
- return HVACAction.COOLING
- if control_state == "heating":
- return HVACAction.HEATING
- if control_state == "preheating":
- return HVACAction.PREHEATING
- if control_state == "off":
+ if self._gateway["smile_name"] == "Adam":
+ if (control_state := self.device.get("control_state")) == "cooling":
+ return HVACAction.COOLING
+ if control_state == "heating":
+ return HVACAction.HEATING
+ if control_state == "preheating":
+ return HVACAction.PREHEATING
+ if control_state == "off":
+ return HVACAction.IDLE
+
return HVACAction.IDLE
- heater: str = self.coordinator.data.gateway["heater_id"]
- heater_data = self.coordinator.data.devices[heater]
+ # Anna
+ heater: str = self._gateway["heater_id"]
+ heater_data = self._devices[heater]
if heater_data["binary_sensors"]["heating_state"]:
return HVACAction.HEATING
if heater_data["binary_sensors"].get("cooling_state", False):
@@ -220,7 +236,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
if mode := kwargs.get(ATTR_HVAC_MODE):
await self.async_set_hvac_mode(mode)
- await self.coordinator.api.set_temperature(self.device["location"], data)
+ await self.coordinator.api.set_temperature(self._location, data)
@plugwise_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
@@ -235,7 +251,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
await self.coordinator.api.set_regulation_mode(hvac_mode)
else:
await self.coordinator.api.set_schedule_state(
- self.device["location"],
+ self._location,
"on" if hvac_mode == HVACMode.AUTO else "off",
)
if self.hvac_mode == HVACMode.OFF:
@@ -244,4 +260,4 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
@plugwise_command
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode."""
- await self.coordinator.api.set_preset(self.device["location"], preset_mode)
+ await self.coordinator.api.set_preset(self._location, preset_mode)
diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py
index b897a8bf833..6ce6855e7d6 100644
--- a/homeassistant/components/plugwise/coordinator.py
+++ b/homeassistant/components/plugwise/coordinator.py
@@ -64,11 +64,11 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]):
version = await self.api.connect()
self._connected = isinstance(version, Version)
if self._connected:
- self.api.get_all_devices()
+ self.api.get_all_gateway_entities()
async def _async_update_data(self) -> PlugwiseData:
"""Fetch data from Plugwise."""
- data = PlugwiseData({}, {})
+ data = PlugwiseData(devices={}, gateway={})
try:
if not self._connected:
await self._connect()
diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py
index 9d15ea4fe28..47ff7d1a9fb 100644
--- a/homeassistant/components/plugwise/diagnostics.py
+++ b/homeassistant/components/plugwise/diagnostics.py
@@ -15,6 +15,6 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
- "gateway": coordinator.data.gateway,
"devices": coordinator.data.devices,
+ "gateway": coordinator.data.gateway,
}
diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py
index e24f3d1e1bb..7b28bf78342 100644
--- a/homeassistant/components/plugwise/entity.py
+++ b/homeassistant/components/plugwise/entity.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from plugwise.constants import DeviceData
+from plugwise.constants import GwEntityData
from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE, CONF_HOST
from homeassistant.helpers.device_registry import (
@@ -74,7 +74,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]):
)
@property
- def device(self) -> DeviceData:
+ def device(self) -> GwEntityData:
"""Return data for this device."""
return self.coordinator.data.devices[self._dev_id]
diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json
index dbbad15c0dc..df35777ac54 100644
--- a/homeassistant/components/plugwise/manifest.json
+++ b/homeassistant/components/plugwise/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["plugwise"],
- "requirements": ["plugwise==1.5.0"],
+ "requirements": ["plugwise==1.6.1"],
"zeroconf": ["_plugwise._tcp.local."]
}
diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py
index 06db5faa55b..833ea3ec761 100644
--- a/homeassistant/components/plugwise/number.py
+++ b/homeassistant/components/plugwise/number.py
@@ -91,12 +91,12 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity):
) -> None:
"""Initiate Plugwise Number."""
super().__init__(coordinator, device_id)
- self.device_id = device_id
- self.entity_description = description
- self._attr_unique_id = f"{device_id}-{description.key}"
self._attr_mode = NumberMode.BOX
self._attr_native_max_value = self.device[description.key]["upper_bound"]
self._attr_native_min_value = self.device[description.key]["lower_bound"]
+ self._attr_unique_id = f"{device_id}-{description.key}"
+ self.device_id = device_id
+ self.entity_description = description
native_step = self.device[description.key]["resolution"]
if description.key != "temperature_offset":
diff --git a/homeassistant/components/plugwise/quality_scale.yaml b/homeassistant/components/plugwise/quality_scale.yaml
new file mode 100644
index 00000000000..0881e79c1c0
--- /dev/null
+++ b/homeassistant/components/plugwise/quality_scale.yaml
@@ -0,0 +1,109 @@
+rules:
+ ## Bronze
+ config-flow: done
+ test-before-configure: done
+ unique-config-entry:
+ status: todo
+ comment: Add tests preventing second entry for same device
+ config-flow-test-coverage:
+ status: todo
+ comment: Cover test_form and zeroconf
+ runtime-data:
+ status: todo
+ comment: Clean up test_init for testing internals
+ test-before-setup: done
+ appropriate-polling:
+ status: todo
+ comment: Clean up coordinator (L71) check for mypy happiness
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup: done
+ dependency-transparency: done
+ action-setup:
+ status: todo
+ comment: Check if we have these, otherwise exempt
+ common-modules:
+ status: todo
+ comment: Verify entity for async_added_to_hass usage (discard?)
+ docs-high-level-description:
+ status: todo
+ comment: Rewrite top section
+ docs-installation-instructions:
+ status: todo
+ comment: Docs PR 36087
+ docs-removal-instructions:
+ status: todo
+ comment: Docs PR 36055 (done, but mark todo for benchmark)
+ docs-actions: done
+ brands: done
+ ## Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: todo
+ comment: Climate exception on ValueError should be ServiceValidationError
+ reauthentication-flow:
+ status: exempt
+ comment: The hubs have a hardcoded `Smile ID` printed on the sticker used as password, it can not be changed
+ parallel-updates:
+ status: todo
+ comment: Using coordinator, but required due to mutable platform
+ test-coverage:
+ status: todo
+ comment: Consider using snapshots + consistency in setup calls + add numerical tests + use fixtures
+ integration-owner: done
+ docs-installation-parameters:
+ status: todo
+ comment: Docs PR 36087 (partial) + todo rewrite generically
+ docs-configuration-parameters:
+ status: exempt
+ comment: Plugwise has no options flow
+ ## Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery: done
+ stale-devices: done
+ diagnostics: done
+ exception-translations:
+ status: todo
+ comment: Add coordinator, util and climate exceptions
+ icon-translations: done
+ reconfiguration-flow:
+ status: todo
+ comment: This integration does not have any reconfiguration steps (yet) investigate how/why
+ dynamic-devices:
+ status: todo
+ comment: Add missing logic to button for unloading and creation
+ discovery-update-info: done
+ repair-issues:
+ status: exempt
+ comment: This integration does not have repairs
+ docs-use-cases:
+ status: todo
+ comment: Check for completeness
+ docs-supported-devices:
+ status: todo
+ comment: The list is there but could be improved for readability
+ docs-supported-functions:
+ status: todo
+ comment: Check for completeness
+ docs-data-update:
+ status: todo
+ comment: Docs PR 36055 (done, but mark todo for benchmark)
+ docs-known-limitations:
+ status: todo
+ comment: Partial in 36087 but could be more elaborat
+ docs-troubleshooting:
+ status: todo
+ comment: Check for completeness
+ docs-examples:
+ status: todo
+ comment: Check for completeness
+ ## Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py
index b7d4a0a1ded..46b27ca6225 100644
--- a/homeassistant/components/plugwise/select.py
+++ b/homeassistant/components/plugwise/select.py
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import PlugwiseConfigEntry
-from .const import LOCATION, SelectOptionsType, SelectType
+from .const import SelectOptionsType, SelectType
from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
@@ -89,8 +89,12 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
) -> None:
"""Initialise the selector."""
super().__init__(coordinator, device_id)
- self.entity_description = entity_description
self._attr_unique_id = f"{device_id}-{entity_description.key}"
+ self.entity_description = entity_description
+
+ self._location = device_id
+ if (location := self.device.get("location")) is not None:
+ self._location = location
@property
def current_option(self) -> str:
@@ -106,8 +110,8 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Change to the selected entity option.
- self.device[LOCATION] and STATE_ON are required for the thermostat-schedule select.
+ self._location and STATE_ON are required for the thermostat-schedule select.
"""
await self.coordinator.api.set_select(
- self.entity_description.key, self.device[LOCATION], option, STATE_ON
+ self.entity_description.key, self._location, option, STATE_ON
)
diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py
index ae5b4e6ed91..41ca439451a 100644
--- a/homeassistant/components/plugwise/sensor.py
+++ b/homeassistant/components/plugwise/sensor.py
@@ -439,8 +439,8 @@ class PlugwiseSensorEntity(PlugwiseEntity, SensorEntity):
) -> None:
"""Initialise the sensor."""
super().__init__(coordinator, device_id)
- self.entity_description = description
self._attr_unique_id = f"{device_id}-{description.key}"
+ self.entity_description = description
@property
def native_value(self) -> int | float:
diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json
index c09323f458b..f74fc036e2a 100644
--- a/homeassistant/components/plugwise/strings.json
+++ b/homeassistant/components/plugwise/strings.json
@@ -30,9 +30,6 @@
},
"entity": {
"binary_sensor": {
- "low_battery": {
- "name": "Battery state"
- },
"compressor_state": {
"name": "Compressor state"
},
diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py
index a134ab5b044..305518f4bef 100644
--- a/homeassistant/components/plugwise/switch.py
+++ b/homeassistant/components/plugwise/switch.py
@@ -48,7 +48,6 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = (
PlugwiseSwitchEntityDescription(
key="cooling_ena_switch",
translation_key="cooling_ena_switch",
- name="Cooling",
entity_category=EntityCategory.CONFIG,
),
)
@@ -93,8 +92,8 @@ class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity):
) -> None:
"""Set up the Plugwise API."""
super().__init__(coordinator, device_id)
- self.entity_description = description
self._attr_unique_id = f"{device_id}-{description.key}"
+ self.entity_description = description
@property
def is_on(self) -> bool:
diff --git a/homeassistant/components/pocketcasts/manifest.json b/homeassistant/components/pocketcasts/manifest.json
index 3cb6f52995e..f2a85ecac0d 100644
--- a/homeassistant/components/pocketcasts/manifest.json
+++ b/homeassistant/components/pocketcasts/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/pocketcasts",
"iot_class": "cloud_polling",
"loggers": ["pycketcasts"],
+ "quality_scale": "legacy",
"requirements": ["pycketcasts==1.0.1"]
}
diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json
index 7b0a2f0e01e..5aa733b510f 100644
--- a/homeassistant/components/point/manifest.json
+++ b/homeassistant/components/point/manifest.json
@@ -7,6 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/point",
"iot_class": "cloud_polling",
"loggers": ["pypoint"],
- "quality_scale": "silver",
"requirements": ["pypoint==3.0.0"]
}
diff --git a/homeassistant/components/proliphix/manifest.json b/homeassistant/components/proliphix/manifest.json
index 2b01d5deb46..9cf0b9b0950 100644
--- a/homeassistant/components/proliphix/manifest.json
+++ b/homeassistant/components/proliphix/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/proliphix",
"iot_class": "local_polling",
"loggers": ["proliphix"],
+ "quality_scale": "legacy",
"requirements": ["proliphix==0.4.1"]
}
diff --git a/homeassistant/components/prometheus/manifest.json b/homeassistant/components/prometheus/manifest.json
index 8c43be8539d..e747226074c 100644
--- a/homeassistant/components/prometheus/manifest.json
+++ b/homeassistant/components/prometheus/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/prometheus",
"iot_class": "assumed_state",
"loggers": ["prometheus_client"],
+ "quality_scale": "legacy",
"requirements": ["prometheus-client==0.21.0"]
}
diff --git a/homeassistant/components/prowl/manifest.json b/homeassistant/components/prowl/manifest.json
index 50decb3f046..049d95fb94c 100644
--- a/homeassistant/components/prowl/manifest.json
+++ b/homeassistant/components/prowl/manifest.json
@@ -3,5 +3,6 @@
"name": "Prowl",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/prowl",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json
index 8cf3bc7932d..45ead1330e2 100644
--- a/homeassistant/components/proxmoxve/manifest.json
+++ b/homeassistant/components/proxmoxve/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/proxmoxve",
"iot_class": "local_polling",
"loggers": ["proxmoxer"],
+ "quality_scale": "legacy",
"requirements": ["proxmoxer==2.0.1"]
}
diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json
index f13799422df..e73eddf3cdd 100644
--- a/homeassistant/components/proxy/manifest.json
+++ b/homeassistant/components/proxy/manifest.json
@@ -3,5 +3,6 @@
"name": "Camera Proxy",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/proxy",
+ "quality_scale": "legacy",
"requirements": ["Pillow==11.0.0"]
}
diff --git a/homeassistant/components/pulseaudio_loopback/manifest.json b/homeassistant/components/pulseaudio_loopback/manifest.json
index a67dc614c50..90666d18997 100644
--- a/homeassistant/components/pulseaudio_loopback/manifest.json
+++ b/homeassistant/components/pulseaudio_loopback/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/pulseaudio_loopback",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pulsectl==23.5.2"]
}
diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py
index 459dc5c055c..4de1ce02810 100644
--- a/homeassistant/components/pure_energie/__init__.py
+++ b/homeassistant/components/pure_energie/__init__.py
@@ -7,13 +7,14 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from .const import DOMAIN
from .coordinator import PureEnergieDataUpdateCoordinator
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+type PureEnergieConfigEntry = ConfigEntry[PureEnergieDataUpdateCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: PureEnergieConfigEntry) -> bool:
"""Set up Pure Energie from a config entry."""
coordinator = PureEnergieDataUpdateCoordinator(hass)
@@ -23,14 +24,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.gridnet.close()
raise
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: PureEnergieConfigEntry
+) -> bool:
"""Unload Pure Energie config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- del hass.data[DOMAIN][entry.entry_id]
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/pure_energie/diagnostics.py b/homeassistant/components/pure_energie/diagnostics.py
index 6e2b8ee7a35..de9134129ed 100644
--- a/homeassistant/components/pure_energie/diagnostics.py
+++ b/homeassistant/components/pure_energie/diagnostics.py
@@ -6,12 +6,10 @@ from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import PureEnergieDataUpdateCoordinator
+from . import PureEnergieConfigEntry
TO_REDACT = {
CONF_HOST,
@@ -20,18 +18,18 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: PureEnergieConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: PureEnergieDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
-
return {
"entry": {
"title": entry.title,
"data": async_redact_data(entry.data, TO_REDACT),
},
"data": {
- "device": async_redact_data(asdict(coordinator.data.device), TO_REDACT),
- "smartbridge": asdict(coordinator.data.smartbridge),
+ "device": async_redact_data(
+ asdict(entry.runtime_data.data.device), TO_REDACT
+ ),
+ "smartbridge": asdict(entry.runtime_data.data.smartbridge),
},
}
diff --git a/homeassistant/components/pure_energie/manifest.json b/homeassistant/components/pure_energie/manifest.json
index ff52ec0ecf9..9efb1734f84 100644
--- a/homeassistant/components/pure_energie/manifest.json
+++ b/homeassistant/components/pure_energie/manifest.json
@@ -5,7 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/pure_energie",
"iot_class": "local_polling",
- "quality_scale": "platinum",
"requirements": ["gridnet==5.0.1"],
"zeroconf": [
{
diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py
index 85f4672a618..468858f117f 100644
--- a/homeassistant/components/pure_energie/sensor.py
+++ b/homeassistant/components/pure_energie/sensor.py
@@ -12,13 +12,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from . import PureEnergieConfigEntry
from .const import DOMAIN
from .coordinator import PureEnergieData, PureEnergieDataUpdateCoordinator
@@ -59,12 +59,13 @@ SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: PureEnergieConfigEntry,
+ async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Pure Energie Sensors based on a config entry."""
async_add_entities(
PureEnergieSensorEntity(
- coordinator=hass.data[DOMAIN][entry.entry_id],
description=description,
entry=entry,
)
@@ -83,21 +84,22 @@ class PureEnergieSensorEntity(
def __init__(
self,
*,
- coordinator: PureEnergieDataUpdateCoordinator,
description: PureEnergieSensorEntityDescription,
- entry: ConfigEntry,
+ entry: PureEnergieConfigEntry,
) -> None:
"""Initialize Pure Energie sensor."""
- super().__init__(coordinator=coordinator)
+ super().__init__(coordinator=entry.runtime_data)
self.entity_id = f"{SENSOR_DOMAIN}.pem_{description.key}"
self.entity_description = description
- self._attr_unique_id = f"{coordinator.data.device.n2g_id}_{description.key}"
+ self._attr_unique_id = (
+ f"{entry.runtime_data.data.device.n2g_id}_{description.key}"
+ )
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, coordinator.data.device.n2g_id)},
- configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}",
- sw_version=coordinator.data.device.firmware,
- manufacturer=coordinator.data.device.manufacturer,
- model=coordinator.data.device.model,
+ identifiers={(DOMAIN, entry.runtime_data.data.device.n2g_id)},
+ configuration_url=f"http://{entry.runtime_data.config_entry.data[CONF_HOST]}",
+ sw_version=entry.runtime_data.data.device.firmware,
+ manufacturer=entry.runtime_data.data.device.manufacturer,
+ model=entry.runtime_data.data.device.model,
name=entry.title,
)
diff --git a/homeassistant/components/push/manifest.json b/homeassistant/components/push/manifest.json
index 900ac25edbf..81cb2dce00c 100644
--- a/homeassistant/components/push/manifest.json
+++ b/homeassistant/components/push/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@dgomes"],
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/push",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/pushsafer/manifest.json b/homeassistant/components/pushsafer/manifest.json
index e9018e2a2ba..8b4ec94b9a5 100644
--- a/homeassistant/components/pushsafer/manifest.json
+++ b/homeassistant/components/pushsafer/manifest.json
@@ -3,5 +3,6 @@
"name": "Pushsafer",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/pushsafer",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json
index 61bd6fd6164..bc96bc5061d 100644
--- a/homeassistant/components/pvoutput/manifest.json
+++ b/homeassistant/components/pvoutput/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/pvoutput",
"integration_type": "device",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["pvo==2.1.1"]
}
diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json
index 8db978135f6..ccddbece7e4 100644
--- a/homeassistant/components/pvpc_hourly_pricing/manifest.json
+++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/pvpc_hourly_pricing",
"iot_class": "cloud_polling",
"loggers": ["aiopvpc"],
- "quality_scale": "platinum",
"requirements": ["aiopvpc==4.2.2"]
}
diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json
index 788cdd1eb05..e21167cf10b 100644
--- a/homeassistant/components/pyload/manifest.json
+++ b/homeassistant/components/pyload/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pyloadapi"],
- "quality_scale": "platinum",
"requirements": ["PyLoadAPI==1.3.2"]
}
diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py
index abc23f39975..67eb856bb83 100644
--- a/homeassistant/components/qbittorrent/sensor.py
+++ b/homeassistant/components/qbittorrent/sensor.py
@@ -100,13 +100,11 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ALL_TORRENTS,
translation_key="all_torrents",
- native_unit_of_measurement="torrents",
value_fn=lambda coordinator: count_torrents_in_states(coordinator, []),
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ACTIVE_TORRENTS,
translation_key="active_torrents",
- native_unit_of_measurement="torrents",
value_fn=lambda coordinator: count_torrents_in_states(
coordinator, ["downloading", "uploading"]
),
@@ -114,7 +112,6 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_INACTIVE_TORRENTS,
translation_key="inactive_torrents",
- native_unit_of_measurement="torrents",
value_fn=lambda coordinator: count_torrents_in_states(
coordinator, ["stalledDL", "stalledUP"]
),
@@ -122,7 +119,6 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_PAUSED_TORRENTS,
translation_key="paused_torrents",
- native_unit_of_measurement="torrents",
value_fn=lambda coordinator: count_torrents_in_states(
coordinator, ["pausedDL", "pausedUP"]
),
diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json
index 88015dad5c3..9c9ee371737 100644
--- a/homeassistant/components/qbittorrent/strings.json
+++ b/homeassistant/components/qbittorrent/strings.json
@@ -36,16 +36,20 @@
}
},
"active_torrents": {
- "name": "Active torrents"
+ "name": "Active torrents",
+ "unit_of_measurement": "torrents"
},
"inactive_torrents": {
- "name": "Inactive torrents"
+ "name": "Inactive torrents",
+ "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
},
"paused_torrents": {
- "name": "Paused torrents"
+ "name": "Paused torrents",
+ "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
},
"all_torrents": {
- "name": "All torrents"
+ "name": "All torrents",
+ "unit_of_measurement": "[%key:component::qbittorrent::entity::sensor::active_torrents::unit_of_measurement%]"
}
},
"switch": {
diff --git a/homeassistant/components/qld_bushfire/manifest.json b/homeassistant/components/qld_bushfire/manifest.json
index 282a931bf05..79a29e6fddb 100644
--- a/homeassistant/components/qld_bushfire/manifest.json
+++ b/homeassistant/components/qld_bushfire/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["georss_qld_bushfire_alert_client"],
+ "quality_scale": "legacy",
"requirements": ["georss-qld-bushfire-alert-client==0.8"]
}
diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json
index 3fcc895c2b9..9634d45b069 100644
--- a/homeassistant/components/qrcode/manifest.json
+++ b/homeassistant/components/qrcode/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/qrcode",
"iot_class": "calculated",
"loggers": ["pyzbar"],
+ "quality_scale": "legacy",
"requirements": ["Pillow==11.0.0", "pyzbar==0.1.7"]
}
diff --git a/homeassistant/components/quantum_gateway/manifest.json b/homeassistant/components/quantum_gateway/manifest.json
index 4494e5a2576..98c6c715417 100644
--- a/homeassistant/components/quantum_gateway/manifest.json
+++ b/homeassistant/components/quantum_gateway/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@cisasteelersfan"],
"documentation": "https://www.home-assistant.io/integrations/quantum_gateway",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["quantum-gateway==0.0.8"]
}
diff --git a/homeassistant/components/qvr_pro/manifest.json b/homeassistant/components/qvr_pro/manifest.json
index 9c0e92698df..2553e1d27c4 100644
--- a/homeassistant/components/qvr_pro/manifest.json
+++ b/homeassistant/components/qvr_pro/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/qvr_pro",
"iot_class": "local_polling",
"loggers": ["pyqvrpro"],
+ "quality_scale": "legacy",
"requirements": ["pyqvrpro==0.52"]
}
diff --git a/homeassistant/components/qwikswitch/manifest.json b/homeassistant/components/qwikswitch/manifest.json
index e30ebffbf2f..750e104d1a3 100644
--- a/homeassistant/components/qwikswitch/manifest.json
+++ b/homeassistant/components/qwikswitch/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/qwikswitch",
"iot_class": "local_push",
"loggers": ["pyqwikswitch"],
+ "quality_scale": "legacy",
"requirements": ["pyqwikswitch==0.93"]
}
diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py
index da2a0e4b475..4827ac3e67c 100644
--- a/homeassistant/components/rainbird/__init__.py
+++ b/homeassistant/components/rainbird/__init__.py
@@ -7,9 +7,8 @@ from typing import Any
import aiohttp
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
-from pyrainbird.exceptions import RainbirdApiException
+from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
@@ -18,12 +17,17 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import format_mac
from .const import CONF_SERIAL_NUMBER
-from .coordinator import RainbirdData, async_create_clientsession
+from .coordinator import (
+ RainbirdScheduleUpdateCoordinator,
+ RainbirdUpdateCoordinator,
+ async_create_clientsession,
+)
+from .types import RainbirdConfigEntry, RainbirdData
_LOGGER = logging.getLogger(__name__)
@@ -40,7 +44,9 @@ DOMAIN = "rainbird"
def _async_register_clientsession_shutdown(
- hass: HomeAssistant, entry: ConfigEntry, clientsession: aiohttp.ClientSession
+ hass: HomeAssistant,
+ entry: RainbirdConfigEntry,
+ clientsession: aiohttp.ClientSession,
) -> None:
"""Register cleanup hooks for the clientsession."""
@@ -55,11 +61,9 @@ def _async_register_clientsession_shutdown(
entry.async_on_unload(_async_close_websession)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool:
"""Set up the config entry for Rain Bird."""
- hass.data.setdefault(DOMAIN, {})
-
clientsession = async_create_clientsession()
_async_register_clientsession_shutdown(hass, entry, clientsession)
@@ -91,21 +95,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
model_info = await controller.get_model_and_version()
+ except RainbirdAuthException as err:
+ raise ConfigEntryAuthFailed from err
except RainbirdApiException as err:
raise ConfigEntryNotReady from err
- data = RainbirdData(hass, entry, controller, model_info)
+ data = RainbirdData(
+ controller,
+ model_info,
+ coordinator=RainbirdUpdateCoordinator(
+ hass,
+ name=entry.title,
+ controller=controller,
+ unique_id=entry.unique_id,
+ model_info=model_info,
+ ),
+ schedule_coordinator=RainbirdScheduleUpdateCoordinator(
+ hass,
+ name=f"{entry.title} Schedule",
+ controller=controller,
+ ),
+ )
await data.coordinator.async_config_entry_first_refresh()
- hass.data[DOMAIN][entry.entry_id] = data
-
+ entry.runtime_data = data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def _async_fix_unique_id(
- hass: HomeAssistant, controller: AsyncRainbirdController, entry: ConfigEntry
+ hass: HomeAssistant, controller: AsyncRainbirdController, entry: RainbirdConfigEntry
) -> bool:
"""Update the config entry with a unique id based on the mac address."""
_LOGGER.debug("Checking for migration of config entry (%s)", entry.unique_id)
@@ -234,10 +254,6 @@ def _async_fix_device_id(
)
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> bool:
"""Unload a config entry."""
-
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py
index d44022b0a2d..5722b8852dd 100644
--- a/homeassistant/components/rainbird/binary_sensor.py
+++ b/homeassistant/components/rainbird/binary_sensor.py
@@ -8,13 +8,12 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
from .coordinator import RainbirdUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -27,11 +26,11 @@ RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird binary_sensor."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator
+ coordinator = config_entry.runtime_data.coordinator
async_add_entities([RainBirdSensor(coordinator, RAIN_SENSOR_ENTITY_DESCRIPTION)])
diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py
index 42c1cce69d3..160fe70c61e 100644
--- a/homeassistant/components/rainbird/calendar.py
+++ b/homeassistant/components/rainbird/calendar.py
@@ -6,7 +6,6 @@ from datetime import datetime
import logging
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -14,19 +13,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
-from .const import DOMAIN
from .coordinator import RainbirdScheduleUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird irrigation calendar."""
- data = hass.data[DOMAIN][config_entry.entry_id]
+ data = config_entry.runtime_data
if not data.model_info.model_info.max_programs:
return
diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py
index abeb1b5da15..1390650ea02 100644
--- a/homeassistant/components/rainbird/config_flow.py
+++ b/homeassistant/components/rainbird/config_flow.py
@@ -3,28 +3,22 @@
from __future__ import annotations
import asyncio
+from collections.abc import Mapping
import logging
from typing import Any
-from pyrainbird.async_client import (
- AsyncRainbirdClient,
- AsyncRainbirdController,
- RainbirdApiException,
-)
+from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
from pyrainbird.data import WifiParams
+from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device_registry import format_mac
+from . import RainbirdConfigEntry
from .const import (
ATTR_DURATION,
CONF_SERIAL_NUMBER,
@@ -45,6 +39,13 @@ DATA_SCHEMA = vol.Schema(
),
}
)
+REAUTH_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): selector.TextSelector(
+ selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
+ ),
+ }
+)
class ConfigFlowError(Exception):
@@ -59,14 +60,45 @@ class ConfigFlowError(Exception):
class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Rain Bird."""
+ host: str
+
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
) -> RainBirdOptionsFlowHandler:
"""Define the config flow to handle options."""
return RainBirdOptionsFlowHandler()
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauthentication upon an API authentication error."""
+ self.host = entry_data[CONF_HOST]
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauthentication dialog."""
+ errors: dict[str, str] = {}
+ if user_input:
+ try:
+ await self._test_connection(self.host, user_input[CONF_PASSWORD])
+ except ConfigFlowError as err:
+ _LOGGER.error("Error during config flow: %s", err)
+ errors["base"] = err.error_code
+ else:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=REAUTH_SCHEMA,
+ errors=errors,
+ )
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -123,6 +155,11 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN):
f"Timeout connecting to Rain Bird controller: {err!s}",
"timeout_connect",
) from err
+ except RainbirdAuthException as err:
+ raise ConfigFlowError(
+ f"Authentication error connecting from Rain Bird controller: {err!s}",
+ "invalid_auth",
+ ) from err
except RainbirdApiException as err:
raise ConfigFlowError(
f"Error connecting to Rain Bird controller: {err!s}",
diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py
index 2657fd6433e..2ccfa0af62a 100644
--- a/homeassistant/components/rainbird/coordinator.py
+++ b/homeassistant/components/rainbird/coordinator.py
@@ -8,7 +8,6 @@ import datetime
import logging
import aiohttp
-from propcache import cached_property
from pyrainbird.async_client import (
AsyncRainbirdController,
RainbirdApiException,
@@ -16,13 +15,13 @@ from pyrainbird.async_client import (
)
from pyrainbird.data import ModelAndVersion, Schedule
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS
+from .types import RainbirdConfigEntry
UPDATE_INTERVAL = datetime.timedelta(minutes=1)
# The calendar data requires RPCs for each program/zone, and the data rarely
@@ -141,7 +140,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]):
"""Coordinator for rainbird irrigation schedule calls."""
- config_entry: ConfigEntry
+ config_entry: RainbirdConfigEntry
def __init__(
self,
@@ -166,36 +165,3 @@ class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]):
return await self._controller.get_schedule()
except RainbirdApiException as err:
raise UpdateFailed(f"Error communicating with Device: {err}") from err
-
-
-@dataclass
-class RainbirdData:
- """Holder for shared integration data.
-
- The coordinators are lazy since they may only be used by some platforms when needed.
- """
-
- hass: HomeAssistant
- entry: ConfigEntry
- controller: AsyncRainbirdController
- model_info: ModelAndVersion
-
- @cached_property
- def coordinator(self) -> RainbirdUpdateCoordinator:
- """Return RainbirdUpdateCoordinator."""
- return RainbirdUpdateCoordinator(
- self.hass,
- name=self.entry.title,
- controller=self.controller,
- unique_id=self.entry.unique_id,
- model_info=self.model_info,
- )
-
- @cached_property
- def schedule_coordinator(self) -> RainbirdScheduleUpdateCoordinator:
- """Return RainbirdScheduleUpdateCoordinator."""
- return RainbirdScheduleUpdateCoordinator(
- self.hass,
- name=f"{self.entry.title} Schedule",
- controller=self.controller,
- )
diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py
index 507a31e59a4..d8081a796b9 100644
--- a/homeassistant/components/rainbird/number.py
+++ b/homeassistant/components/rainbird/number.py
@@ -7,29 +7,28 @@ import logging
from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException
from homeassistant.components.number import NumberEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
from .coordinator import RainbirdUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird number platform."""
async_add_entities(
[
RainDelayNumber(
- hass.data[DOMAIN][config_entry.entry_id].coordinator,
+ config_entry.runtime_data.coordinator,
)
]
)
diff --git a/homeassistant/components/rainbird/quality_scale.yaml b/homeassistant/components/rainbird/quality_scale.yaml
new file mode 100644
index 00000000000..cd000c63fad
--- /dev/null
+++ b/homeassistant/components/rainbird/quality_scale.yaml
@@ -0,0 +1,79 @@
+rules:
+ # Bronze
+ config-flow: done
+ brands: done
+ dependency-transparency: done
+ common-modules: done
+ has-entity-name: done
+ action-setup:
+ status: done
+ comment: |
+ The integration only has an entity service, registered in the platform.
+ appropriate-polling:
+ status: done
+ comment: |
+ Rainbird devices are local. Irrigation valve/controller status is polled
+ once per minute to get fast updates when turning on/off the valves.
+ The irrigation schedule uses a 15 minute poll interval since it rarely
+ changes.
+
+ Rainbird devices can only accept a single http connection, so this uses a
+ an aiohttp.ClientSession with a connection limit, and also uses a request
+ debouncer.
+ test-before-configure: done
+ entity-event-setup:
+ status: exempt
+ comment: Integration is polling and does not subscribe to events.
+ unique-config-entry: done
+ entity-unique-id: done
+ docs-installation-instructions:
+ status: todo
+ comment: |
+ The introduction can be improved and is missing pre-requisites such as
+ installing the app.
+ docs-removal-instructions: todo
+ test-before-setup: done
+ docs-high-level-description: done
+ config-flow-test-coverage: done
+ docs-actions: done
+ runtime-data: done
+
+ # Silver
+ log-when-unavailable: todo
+ config-entry-unloading: todo
+ reauthentication-flow: done
+ action-exceptions: todo
+ docs-installation-parameters: todo
+ integration-owner: todo
+ parallel-updates: todo
+ test-coverage: todo
+ docs-configuration-parameters: todo
+ entity-unavailable: todo
+
+ # Gold
+ docs-examples: todo
+ discovery-update-info: todo
+ entity-device-class: todo
+ entity-translations: todo
+ docs-data-update: todo
+ entity-disabled-by-default: todo
+ discovery: todo
+ exception-translations: todo
+ devices: todo
+ docs-supported-devices: todo
+ icon-translations: todo
+ docs-known-limitations: todo
+ stale-devices: todo
+ docs-supported-functions: todo
+ repair-issues: todo
+ reconfiguration-flow: todo
+ entity-category: todo
+ dynamic-devices: todo
+ docs-troubleshooting: todo
+ diagnostics: todo
+ docs-use-cases: todo
+
+ # Platinum
+ async-dependency: todo
+ strict-typing: todo
+ inject-websession: todo
diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py
index 649d643a20c..4725a33bc9a 100644
--- a/homeassistant/components/rainbird/sensor.py
+++ b/homeassistant/components/rainbird/sensor.py
@@ -5,14 +5,13 @@ from __future__ import annotations
import logging
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
from .coordinator import RainbirdUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -25,14 +24,14 @@ RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird sensor."""
async_add_entities(
[
RainBirdSensor(
- hass.data[DOMAIN][config_entry.entry_id].coordinator,
+ config_entry.runtime_data.coordinator,
RAIN_DELAY_ENTITY_DESCRIPTION,
)
]
diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json
index ea0d64f6208..6f92b1bdb97 100644
--- a/homeassistant/components/rainbird/strings.json
+++ b/homeassistant/components/rainbird/strings.json
@@ -9,16 +9,29 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "host": "The hostname or IP address of your Rain Bird device."
+ "host": "The hostname or IP address of your Rain Bird device.",
+ "password": "The password used to authenticate with the Rain Bird device."
+ }
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The Rain Bird integration needs to re-authenticate with the device.",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "The password to authenticate with your Rain Bird device."
}
}
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
}
},
"options": {
@@ -27,6 +40,9 @@
"title": "[%key:component::rainbird::config::step::user::title%]",
"data": {
"duration": "Default irrigation time in minutes"
+ },
+ "data_description": {
+ "duration": "The default duration the sprinkler will run when turned on."
}
}
}
diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py
index 62a2a7c4a32..f622a1b9b2c 100644
--- a/homeassistant/components/rainbird/switch.py
+++ b/homeassistant/components/rainbird/switch.py
@@ -8,7 +8,6 @@ from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyExcept
import voluptuous as vol
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
@@ -19,6 +18,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTR_DURATION, CONF_IMPORTED_NAMES, DOMAIN, MANUFACTURER
from .coordinator import RainbirdUpdateCoordinator
+from .types import RainbirdConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -31,11 +31,11 @@ SERVICE_SCHEMA_IRRIGATION: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: RainbirdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird irrigation switches."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id].coordinator
+ coordinator = config_entry.runtime_data.coordinator
async_add_entities(
RainBirdSwitch(
coordinator,
diff --git a/homeassistant/components/rainbird/types.py b/homeassistant/components/rainbird/types.py
new file mode 100644
index 00000000000..cc43353ac17
--- /dev/null
+++ b/homeassistant/components/rainbird/types.py
@@ -0,0 +1,33 @@
+"""Types for Rain Bird integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from pyrainbird.async_client import AsyncRainbirdController
+from pyrainbird.data import ModelAndVersion
+
+from homeassistant.config_entries import ConfigEntry
+
+if TYPE_CHECKING:
+ from .coordinator import (
+ RainbirdScheduleUpdateCoordinator,
+ RainbirdUpdateCoordinator,
+ )
+
+
+@dataclass
+class RainbirdData:
+ """Holder for shared integration data.
+
+ The coordinators are lazy since they may only be used by some platforms when needed.
+ """
+
+ controller: AsyncRainbirdController
+ model_info: ModelAndVersion
+ coordinator: RainbirdUpdateCoordinator
+ schedule_coordinator: RainbirdScheduleUpdateCoordinator
+
+
+type RainbirdConfigEntry = ConfigEntry[RainbirdData]
diff --git a/homeassistant/components/raincloud/manifest.json b/homeassistant/components/raincloud/manifest.json
index 70f62d2beee..b5179622441 100644
--- a/homeassistant/components/raincloud/manifest.json
+++ b/homeassistant/components/raincloud/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/raincloud",
"iot_class": "cloud_polling",
"loggers": ["raincloudy"],
+ "quality_scale": "legacy",
"requirements": ["raincloudy==0.0.7"]
}
diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json
index ef19dd6dd67..e5c5543e39f 100644
--- a/homeassistant/components/random/strings.json
+++ b/homeassistant/components/random/strings.json
@@ -20,12 +20,12 @@
"title": "Random sensor"
},
"user": {
- "description": "This helper allows you to create a helper that emits a random value.",
+ "description": "This helper allows you to create an entity that emits a random value.",
"menu_options": {
"binary_sensor": "Random binary sensor",
"sensor": "Random sensor"
},
- "title": "Random helper"
+ "title": "Create Random helper"
}
}
},
diff --git a/homeassistant/components/raspberry_pi/manifest.json b/homeassistant/components/raspberry_pi/manifest.json
index 5ed68154ce1..c8317f7ef1e 100644
--- a/homeassistant/components/raspberry_pi/manifest.json
+++ b/homeassistant/components/raspberry_pi/manifest.json
@@ -6,5 +6,6 @@
"config_flow": false,
"dependencies": ["hardware"],
"documentation": "https://www.home-assistant.io/integrations/raspberry_pi",
- "integration_type": "hardware"
+ "integration_type": "hardware",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/raspyrfm/manifest.json b/homeassistant/components/raspyrfm/manifest.json
index 0fa4ce77200..d001e2b1118 100644
--- a/homeassistant/components/raspyrfm/manifest.json
+++ b/homeassistant/components/raspyrfm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/raspyrfm",
"iot_class": "assumed_state",
"loggers": ["raspyrfm_client"],
+ "quality_scale": "legacy",
"requirements": ["raspyrfm-client==1.2.8"]
}
diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json
index 7af3e861347..2ab90e55ef0 100644
--- a/homeassistant/components/rdw/manifest.json
+++ b/homeassistant/components/rdw/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/rdw",
"integration_type": "service",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["vehicle==2.2.2"]
}
diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py
index 6ba64d4a571..a3163d5b396 100644
--- a/homeassistant/components/recorder/core.py
+++ b/homeassistant/components/recorder/core.py
@@ -740,7 +740,7 @@ class Recorder(threading.Thread):
self.schema_version = schema_status.current_version
# Do non-live data migration
- migration.migrate_data_non_live(self, self.get_session, schema_status)
+ self._migrate_data_offline(schema_status)
# Non-live migration is now completed, remaining steps are live
self.migration_is_live = True
@@ -916,6 +916,13 @@ class Recorder(threading.Thread):
return False
+ def _migrate_data_offline(
+ self, schema_status: migration.SchemaValidationStatus
+ ) -> None:
+ """Migrate data."""
+ with self.hass.timeout.freeze(DOMAIN):
+ migration.migrate_data_non_live(self, self.get_session, schema_status)
+
def _migrate_schema_offline(
self, schema_status: migration.SchemaValidationStatus
) -> tuple[bool, migration.SchemaValidationStatus]:
@@ -1121,7 +1128,6 @@ class Recorder(threading.Thread):
# Map the event data to the StateAttributes table
shared_attrs = shared_attrs_bytes.decode("utf-8")
- dbstate.attributes = None
# Matching attributes found in the pending commit
if pending_event_data := state_attributes_manager.get_pending(shared_attrs):
dbstate.state_attributes = pending_event_data
@@ -1424,6 +1430,7 @@ class Recorder(threading.Thread):
with session_scope(session=self.get_session()) as session:
end_incomplete_runs(session, self.recorder_runs_manager.recording_start)
self.recorder_runs_manager.start(session)
+ self.states_manager.load_from_db(session)
self._open_event_session()
diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py
index 7e8343321c3..fb57a1c73e2 100644
--- a/homeassistant/components/recorder/db_schema.py
+++ b/homeassistant/components/recorder/db_schema.py
@@ -162,14 +162,14 @@ class Unused(CHAR):
"""An unused column type that behaves like a string."""
-@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call]
-@compiles(Unused, "mysql", "mariadb", "sqlite") # type: ignore[misc,no-untyped-call]
+@compiles(UnusedDateTime, "mysql", "mariadb", "sqlite")
+@compiles(Unused, "mysql", "mariadb", "sqlite")
def compile_char_zero(type_: TypeDecorator, compiler: Any, **kw: Any) -> str:
"""Compile UnusedDateTime and Unused as CHAR(0) on mysql, mariadb, and sqlite."""
return "CHAR(0)" # Uses 1 byte on MySQL (no change on sqlite)
-@compiles(Unused, "postgresql") # type: ignore[misc,no-untyped-call]
+@compiles(Unused, "postgresql")
def compile_char_one(type_: TypeDecorator, compiler: Any, **kw: Any) -> str:
"""Compile Unused as CHAR(1) on postgresql."""
return "CHAR(1)" # Uses 1 byte
@@ -691,12 +691,14 @@ class StatisticsBase:
duration: timedelta
@classmethod
- def from_stats(cls, metadata_id: int, stats: StatisticData) -> Self:
+ def from_stats(
+ cls, metadata_id: int, stats: StatisticData, now_timestamp: float | None = None
+ ) -> Self:
"""Create object from a statistics with datetime objects."""
return cls( # type: ignore[call-arg]
metadata_id=metadata_id,
created=None,
- created_ts=time.time(),
+ created_ts=now_timestamp or time.time(),
start=None,
start_ts=stats["start"].timestamp(),
mean=stats.get("mean"),
@@ -709,12 +711,17 @@ class StatisticsBase:
)
@classmethod
- def from_stats_ts(cls, metadata_id: int, stats: StatisticDataTimestamp) -> Self:
+ def from_stats_ts(
+ cls,
+ metadata_id: int,
+ stats: StatisticDataTimestamp,
+ now_timestamp: float | None = None,
+ ) -> Self:
"""Create object from a statistics with timestamps."""
return cls( # type: ignore[call-arg]
metadata_id=metadata_id,
created=None,
- created_ts=time.time(),
+ created_ts=now_timestamp or time.time(),
start=None,
start_ts=stats["start_ts"],
mean=stats.get("mean"),
diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py
index b59fc43c3d0..dc49ebb9768 100644
--- a/homeassistant/components/recorder/history/legacy.py
+++ b/homeassistant/components/recorder/history/legacy.py
@@ -22,9 +22,9 @@ from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.recorder import get_instance
import homeassistant.util.dt as dt_util
-from ..db_schema import RecorderRuns, StateAttributes, States
+from ..db_schema import StateAttributes, States
from ..filters import Filters
-from ..models import process_timestamp, process_timestamp_to_utc_isoformat
+from ..models import process_timestamp_to_utc_isoformat
from ..models.legacy import LegacyLazyState, legacy_row_to_compressed_state
from ..util import execute_stmt_lambda_element, session_scope
from .const import (
@@ -436,7 +436,7 @@ def get_last_state_changes(
def _get_states_for_entities_stmt(
- run_start: datetime,
+ run_start_ts: float,
utc_point_in_time: datetime,
entity_ids: list[str],
no_attributes: bool,
@@ -447,8 +447,7 @@ def _get_states_for_entities_stmt(
)
# We got an include-list of entities, accelerate the query by filtering already
# in the inner query.
- run_start_ts = process_timestamp(run_start).timestamp()
- utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time)
+ utc_point_in_time_ts = utc_point_in_time.timestamp()
stmt += lambda q: q.join(
(
most_recent_states_for_entities_by_date := (
@@ -483,7 +482,7 @@ def _get_rows_with_session(
session: Session,
utc_point_in_time: datetime,
entity_ids: list[str],
- run: RecorderRuns | None = None,
+ *,
no_attributes: bool = False,
) -> Iterable[Row]:
"""Return the states at a specific point in time."""
@@ -495,17 +494,16 @@ def _get_rows_with_session(
),
)
- if run is None:
- run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time)
+ oldest_ts = get_instance(hass).states_manager.oldest_ts
- if run is None or process_timestamp(run.start) > utc_point_in_time:
- # History did not run before utc_point_in_time
+ if oldest_ts is None or oldest_ts > utc_point_in_time.timestamp():
+ # We don't have any states for the requested time
return []
# We have more than one entity to look at so we need to do a query on states
# since the last recorder run started.
stmt = _get_states_for_entities_stmt(
- run.start, utc_point_in_time, entity_ids, no_attributes
+ oldest_ts, utc_point_in_time, entity_ids, no_attributes
)
return execute_stmt_lambda_element(session, stmt)
@@ -520,7 +518,7 @@ def _get_single_entity_states_stmt(
stmt, join_attributes = _lambda_stmt_and_join_attributes(
no_attributes, include_last_changed=True
)
- utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time)
+ utc_point_in_time_ts = utc_point_in_time.timestamp()
stmt += (
lambda q: q.filter(
States.last_updated_ts < utc_point_in_time_ts,
diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py
index b44bec0d0ee..01551de1f28 100644
--- a/homeassistant/components/recorder/history/modern.py
+++ b/homeassistant/components/recorder/history/modern.py
@@ -34,7 +34,6 @@ from ..models import (
LazyState,
datetime_to_timestamp_or_none,
extract_metadata_ids,
- process_timestamp,
row_to_compressed_state,
)
from ..util import execute_stmt_lambda_element, session_scope
@@ -246,12 +245,12 @@ def get_significant_states_with_session(
if metadata_id is not None
and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS
]
- run_start_ts: float | None = None
+ oldest_ts: float | None = None
if include_start_time_state and not (
- run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time)
+ oldest_ts := _get_oldest_possible_ts(hass, start_time)
):
include_start_time_state = False
- start_time_ts = dt_util.utc_to_timestamp(start_time)
+ start_time_ts = start_time.timestamp()
end_time_ts = datetime_to_timestamp_or_none(end_time)
single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None
stmt = lambda_stmt(
@@ -264,7 +263,7 @@ def get_significant_states_with_session(
significant_changes_only,
no_attributes,
include_start_time_state,
- run_start_ts,
+ oldest_ts,
),
track_on=[
bool(single_metadata_id),
@@ -411,12 +410,12 @@ def state_changes_during_period(
entity_id_to_metadata_id: dict[str, int | None] = {
entity_id: single_metadata_id
}
- run_start_ts: float | None = None
+ oldest_ts: float | None = None
if include_start_time_state and not (
- run_start_ts := _get_run_start_ts_for_utc_point_in_time(hass, start_time)
+ oldest_ts := _get_oldest_possible_ts(hass, start_time)
):
include_start_time_state = False
- start_time_ts = dt_util.utc_to_timestamp(start_time)
+ start_time_ts = start_time.timestamp()
end_time_ts = datetime_to_timestamp_or_none(end_time)
stmt = lambda_stmt(
lambda: _state_changed_during_period_stmt(
@@ -426,7 +425,7 @@ def state_changes_during_period(
no_attributes,
limit,
include_start_time_state,
- run_start_ts,
+ oldest_ts,
has_last_reported,
),
track_on=[
@@ -600,17 +599,17 @@ def _get_start_time_state_for_entities_stmt(
)
-def _get_run_start_ts_for_utc_point_in_time(
+def _get_oldest_possible_ts(
hass: HomeAssistant, utc_point_in_time: datetime
) -> float | None:
- """Return the start time of a run."""
- run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time)
- if (
- run is not None
- and (run_start := process_timestamp(run.start)) < utc_point_in_time
- ):
- return run_start.timestamp()
- # History did not run before utc_point_in_time but we still
+ """Return the oldest possible timestamp.
+
+ Returns None if there are no states as old as utc_point_in_time.
+ """
+
+ oldest_ts = get_instance(hass).states_manager.oldest_ts
+ if oldest_ts is not None and oldest_ts < utc_point_in_time.timestamp():
+ return oldest_ts
return None
diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json
index 2be4b6862ba..93ffb12d18c 100644
--- a/homeassistant/components/recorder/manifest.json
+++ b/homeassistant/components/recorder/manifest.json
@@ -7,7 +7,7 @@
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": [
- "SQLAlchemy==2.0.31",
+ "SQLAlchemy==2.0.36",
"fnv-hash-fast==1.0.2",
"psutil-home-assistant==0.0.1"
]
diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py
index 02ab05288c5..fffecff149c 100644
--- a/homeassistant/components/recorder/migration.py
+++ b/homeassistant/components/recorder/migration.py
@@ -313,7 +313,7 @@ def _migrate_schema(
for version in range(current_version, end_version):
new_version = version + 1
- _LOGGER.info("Upgrading recorder db schema to version %s", new_version)
+ _LOGGER.warning("Upgrading recorder db schema to version %s", new_version)
_apply_update(instance, hass, engine, session_maker, new_version, start_version)
with session_scope(session=session_maker()) as session:
session.add(SchemaChanges(schema_version=new_version))
@@ -2326,9 +2326,15 @@ class BaseMigration(ABC):
"""
if self.schema_version < self.required_schema_version:
# Schema is too old, we must have to migrate
+ _LOGGER.info(
+ "Data migration '%s' needed, schema too old", self.migration_id
+ )
return True
if self.migration_changes.get(self.migration_id, -1) >= self.migration_version:
# The migration changes table indicates that the migration has been done
+ _LOGGER.debug(
+ "Data migration '%s' not needed, already completed", self.migration_id
+ )
return False
# We do not know if the migration is done from the
# migration changes table so we must check the index and data
@@ -2338,10 +2344,19 @@ class BaseMigration(ABC):
and get_index_by_name(session, self.index_to_drop[0], self.index_to_drop[1])
is not None
):
+ _LOGGER.info(
+ "Data migration '%s' needed, index to drop still exists",
+ self.migration_id,
+ )
return True
needs_migrate = self.needs_migrate_impl(instance, session)
if needs_migrate.migration_done:
_mark_migration_done(session, self.__class__)
+ _LOGGER.info(
+ "Data migration '%s' needed: %s",
+ self.migration_id,
+ needs_migrate.needs_migrate,
+ )
return needs_migrate.needs_migrate
@@ -2354,10 +2369,17 @@ class BaseOffLineMigration(BaseMigration):
"""Migrate all data."""
with session_scope(session=session_maker()) as session:
if not self.needs_migrate(instance, session):
+ _LOGGER.debug("Migration not needed for '%s'", self.migration_id)
self.migration_done(instance, session)
return
+ _LOGGER.warning(
+ "The database is about to do data migration step '%s', %s",
+ self.migration_id,
+ MIGRATION_NOTE_OFFLINE,
+ )
while not self.migrate_data(instance):
pass
+ _LOGGER.warning("Data migration step '%s' completed", self.migration_id)
@database_job_retry_wrapper_method("migrate data", 10)
def migrate_data(self, instance: Recorder) -> bool:
@@ -2742,7 +2764,10 @@ class EventIDPostMigration(BaseRunTimeMigration):
class EntityIDPostMigration(BaseMigrationWithQuery, BaseRunTimeMigration):
- """Migration to remove old entity_id strings from states."""
+ """Migration to remove old entity_id strings from states.
+
+ Introduced in HA Core 2023.4 by PR #89557.
+ """
migration_id = "entity_id_post_migration"
task = MigrationTask
@@ -2764,9 +2789,9 @@ NON_LIVE_DATA_MIGRATORS = (
)
LIVE_DATA_MIGRATORS = (
- EventTypeIDMigration,
- EntityIDMigration,
- EventIDPostMigration,
+ EventTypeIDMigration, # Introduced in HA Core 2023.4 by PR #89465
+ EntityIDMigration, # Introduced in HA Core 2023.4 by PR #89557
+ EventIDPostMigration, # Introduced in HA Core 2023.4 by PR #89901
)
diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py
index 21a8a39ba0f..a469aa49ab2 100644
--- a/homeassistant/components/recorder/models/legacy.py
+++ b/homeassistant/components/recorder/models/legacy.py
@@ -46,7 +46,7 @@ class LegacyLazyState(State):
self.state = self._row.state or ""
self._attributes: dict[str, Any] | None = None
self._last_updated_ts: float | None = self._row.last_updated_ts or (
- dt_util.utc_to_timestamp(start_time) if start_time else None
+ start_time.timestamp() if start_time else None
)
self._last_changed_ts: float | None = (
self._row.last_changed_ts or self._last_updated_ts
@@ -146,7 +146,7 @@ def legacy_row_to_compressed_state(
COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache),
}
if start_time:
- comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time)
+ comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp()
else:
row_last_updated_ts: float = row.last_updated_ts
comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts
diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py
index d28e7e2a547..28a5a2ed32d 100644
--- a/homeassistant/components/recorder/purge.py
+++ b/homeassistant/components/recorder/purge.py
@@ -110,7 +110,7 @@ def purge_old_data(
_LOGGER.debug("Purging hasn't fully completed yet")
return False
- if apply_filter and _purge_filtered_data(instance, session) is False:
+ if apply_filter and not _purge_filtered_data(instance, session):
_LOGGER.debug("Cleanup filtered data hasn't fully completed yet")
return False
@@ -123,6 +123,9 @@ def purge_old_data(
_purge_old_entity_ids(instance, session)
_purge_old_recorder_runs(instance, session, purge_before)
+ with session_scope(session=instance.get_session(), read_only=True) as session:
+ instance.recorder_runs_manager.load_from_db(session)
+ instance.states_manager.load_from_db(session)
if repack:
repack_database(instance)
return True
@@ -631,7 +634,10 @@ def _purge_old_entity_ids(instance: Recorder, session: Session) -> None:
def _purge_filtered_data(instance: Recorder, session: Session) -> bool:
- """Remove filtered states and events that shouldn't be in the database."""
+ """Remove filtered states and events that shouldn't be in the database.
+
+ Returns true if all states and events are purged.
+ """
_LOGGER.debug("Cleanup filtered data")
database_engine = instance.database_engine
assert database_engine is not None
@@ -639,7 +645,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool:
# Check if excluded entity_ids are in database
entity_filter = instance.entity_filter
- has_more_states_to_purge = False
+ has_more_to_purge = False
excluded_metadata_ids: list[str] = [
metadata_id
for (metadata_id, entity_id) in session.query(
@@ -648,12 +654,11 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool:
if entity_filter and not entity_filter(entity_id)
]
if excluded_metadata_ids:
- has_more_states_to_purge = _purge_filtered_states(
+ has_more_to_purge |= not _purge_filtered_states(
instance, session, excluded_metadata_ids, database_engine, now_timestamp
)
# Check if excluded event_types are in database
- has_more_events_to_purge = False
if (
event_type_to_event_type_ids := instance.event_type_manager.get_many(
instance.exclude_event_types, session
@@ -665,12 +670,12 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool:
if event_type_id is not None
]
):
- has_more_events_to_purge = _purge_filtered_events(
+ has_more_to_purge |= not _purge_filtered_events(
instance, session, excluded_event_type_ids, now_timestamp
)
# Purge has completed if there are not more state or events to purge
- return not (has_more_states_to_purge or has_more_events_to_purge)
+ return not has_more_to_purge
def _purge_filtered_states(
diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py
index 4acf43a491e..8ca7bef2691 100644
--- a/homeassistant/components/recorder/queries.py
+++ b/homeassistant/components/recorder/queries.py
@@ -608,7 +608,8 @@ def delete_recorder_runs_rows(
"""Delete recorder_runs rows."""
return lambda_stmt(
lambda: delete(RecorderRuns)
- .filter(RecorderRuns.start < purge_before)
+ .filter(RecorderRuns.end.is_not(None))
+ .filter(RecorderRuns.end < purge_before)
.filter(RecorderRuns.run_id != current_run_id)
.execution_options(synchronize_session=False)
)
@@ -636,6 +637,15 @@ def find_states_to_purge(
)
+def find_oldest_state() -> StatementLambdaElement:
+ """Find the last_updated_ts of the oldest state."""
+ return lambda_stmt(
+ lambda: select(States.last_updated_ts).where(
+ States.state_id.in_(select(func.min(States.state_id)))
+ )
+ )
+
+
def find_short_term_statistics_to_purge(
purge_before: datetime, max_bind_vars: int
) -> StatementLambdaElement:
diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py
index 7243af9d4d5..3f1d5b981e3 100644
--- a/homeassistant/components/recorder/statistics.py
+++ b/homeassistant/components/recorder/statistics.py
@@ -11,6 +11,7 @@ from itertools import chain, groupby
import logging
from operator import itemgetter
import re
+from time import time as time_time
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text
@@ -27,6 +28,7 @@ from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
+ AreaConverter,
BaseUnitConverter,
BloodGlucoseConcentrationConverter,
ConductivityConverter,
@@ -129,6 +131,7 @@ QUERY_STATISTICS_SUMMARY_SUM = (
STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
+ **{unit: AreaConverter for unit in AreaConverter.VALID_UNITS},
**{
unit: BloodGlucoseConcentrationConverter
for unit in BloodGlucoseConcentrationConverter.VALID_UNITS
@@ -444,8 +447,9 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None:
}
# Insert compiled hourly statistics in the database
+ now_timestamp = time_time()
session.add_all(
- Statistics.from_stats_ts(metadata_id, summary_item)
+ Statistics.from_stats_ts(metadata_id, summary_item, now_timestamp)
for metadata_id, summary_item in summary.items()
)
@@ -576,6 +580,7 @@ def _compile_statistics(
new_short_term_stats: list[StatisticsBase] = []
updated_metadata_ids: set[int] = set()
+ now_timestamp = time_time()
# Insert collected statistics in the database
for stats in platform_stats:
modified_statistic_id, metadata_id = statistics_meta_manager.update_or_add(
@@ -585,10 +590,7 @@ def _compile_statistics(
modified_statistic_ids.add(modified_statistic_id)
updated_metadata_ids.add(metadata_id)
if new_stat := _insert_statistics(
- session,
- StatisticsShortTerm,
- metadata_id,
- stats["stat"],
+ session, StatisticsShortTerm, metadata_id, stats["stat"], now_timestamp
):
new_short_term_stats.append(new_stat)
@@ -664,10 +666,11 @@ def _insert_statistics(
table: type[StatisticsBase],
metadata_id: int,
statistic: StatisticData,
+ now_timestamp: float,
) -> StatisticsBase | None:
"""Insert statistics in the database."""
try:
- stat = table.from_stats(metadata_id, statistic)
+ stat = table.from_stats(metadata_id, statistic, now_timestamp)
session.add(stat)
except SQLAlchemyError:
_LOGGER.exception(
@@ -2345,11 +2348,12 @@ def _import_statistics_with_session(
_, metadata_id = statistics_meta_manager.update_or_add(
session, metadata, old_metadata_dict
)
+ now_timestamp = time_time()
for stat in statistics:
if stat_id := _statistics_exists(session, table, metadata_id, stat["start"]):
_update_statistics(session, table, stat_id, stat)
else:
- _insert_statistics(session, table, metadata_id, stat)
+ _insert_statistics(session, table, metadata_id, stat, now_timestamp)
if table != StatisticsShortTerm:
return True
diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py
index b0b9818118b..4ca0aa18b88 100644
--- a/homeassistant/components/recorder/table_managers/recorder_runs.py
+++ b/homeassistant/components/recorder/table_managers/recorder_runs.py
@@ -2,8 +2,6 @@
from __future__ import annotations
-import bisect
-from dataclasses import dataclass
from datetime import datetime
from sqlalchemy.orm.session import Session
@@ -11,34 +9,6 @@ from sqlalchemy.orm.session import Session
import homeassistant.util.dt as dt_util
from ..db_schema import RecorderRuns
-from ..models import process_timestamp
-
-
-def _find_recorder_run_for_start_time(
- run_history: _RecorderRunsHistory, start: datetime
-) -> RecorderRuns | None:
- """Find the recorder run for a start time in _RecorderRunsHistory."""
- run_timestamps = run_history.run_timestamps
- runs_by_timestamp = run_history.runs_by_timestamp
-
- # bisect_left tells us were we would insert
- # a value in the list of runs after the start timestamp.
- #
- # The run before that (idx-1) is when the run started
- #
- # If idx is 0, history never ran before the start timestamp
- #
- if idx := bisect.bisect_left(run_timestamps, start.timestamp()):
- return runs_by_timestamp[run_timestamps[idx - 1]]
- return None
-
-
-@dataclass(frozen=True)
-class _RecorderRunsHistory:
- """Bisectable history of RecorderRuns."""
-
- run_timestamps: list[int]
- runs_by_timestamp: dict[int, RecorderRuns]
class RecorderRunsManager:
@@ -48,7 +18,7 @@ class RecorderRunsManager:
"""Track recorder run history."""
self._recording_start = dt_util.utcnow()
self._current_run_info: RecorderRuns | None = None
- self._run_history = _RecorderRunsHistory([], {})
+ self._first_run: RecorderRuns | None = None
@property
def recording_start(self) -> datetime:
@@ -58,9 +28,7 @@ class RecorderRunsManager:
@property
def first(self) -> RecorderRuns:
"""Get the first run."""
- if runs_by_timestamp := self._run_history.runs_by_timestamp:
- return next(iter(runs_by_timestamp.values()))
- return self.current
+ return self._first_run or self.current
@property
def current(self) -> RecorderRuns:
@@ -78,15 +46,6 @@ class RecorderRunsManager:
"""Return if a run is active."""
return self._current_run_info is not None
- def get(self, start: datetime) -> RecorderRuns | None:
- """Return the recorder run that started before or at start.
-
- If the first run started after the start, return None
- """
- if start >= self.recording_start:
- return self.current
- return _find_recorder_run_for_start_time(self._run_history, start)
-
def start(self, session: Session) -> None:
"""Start a new run.
@@ -122,31 +81,17 @@ class RecorderRunsManager:
Must run in the recorder thread.
"""
- run_timestamps: list[int] = []
- runs_by_timestamp: dict[int, RecorderRuns] = {}
-
- for run in session.query(RecorderRuns).order_by(RecorderRuns.start.asc()).all():
+ if (
+ run := session.query(RecorderRuns)
+ .order_by(RecorderRuns.start.asc())
+ .first()
+ ):
session.expunge(run)
- if run_dt := process_timestamp(run.start):
- # Not sure if this is correct or runs_by_timestamp annotation should be changed
- timestamp = int(run_dt.timestamp())
- run_timestamps.append(timestamp)
- runs_by_timestamp[timestamp] = run
-
- #
- # self._run_history is accessed in get()
- # which is allowed to be called from any thread
- #
- # We use a dataclass to ensure that when we update
- # run_timestamps and runs_by_timestamp
- # are never out of sync with each other.
- #
- self._run_history = _RecorderRunsHistory(run_timestamps, runs_by_timestamp)
+ self._first_run = run
def clear(self) -> None:
"""Clear the current run after ending it.
Must run in the recorder thread.
"""
- if self._current_run_info:
- self._current_run_info = None
+ self._current_run_info = None
diff --git a/homeassistant/components/recorder/table_managers/states.py b/homeassistant/components/recorder/table_managers/states.py
index d5cef759c54..fafcfa0ea61 100644
--- a/homeassistant/components/recorder/table_managers/states.py
+++ b/homeassistant/components/recorder/table_managers/states.py
@@ -2,7 +2,15 @@
from __future__ import annotations
+from collections.abc import Sequence
+from typing import Any, cast
+
+from sqlalchemy.engine.row import Row
+from sqlalchemy.orm.session import Session
+
from ..db_schema import States
+from ..queries import find_oldest_state
+from ..util import execute_stmt_lambda_element
class StatesManager:
@@ -13,6 +21,12 @@ class StatesManager:
self._pending: dict[str, States] = {}
self._last_committed_id: dict[str, int] = {}
self._last_reported: dict[int, float] = {}
+ self._oldest_ts: float | None = None
+
+ @property
+ def oldest_ts(self) -> float | None:
+ """Return the oldest timestamp."""
+ return self._oldest_ts
def pop_pending(self, entity_id: str) -> States | None:
"""Pop a pending state.
@@ -44,6 +58,8 @@ class StatesManager:
recorder thread.
"""
self._pending[entity_id] = state
+ if self._oldest_ts is None:
+ self._oldest_ts = state.last_updated_ts
def update_pending_last_reported(
self, state_id: int, last_reported_timestamp: float
@@ -74,6 +90,22 @@ class StatesManager:
"""
self._last_committed_id.clear()
self._pending.clear()
+ self._oldest_ts = None
+
+ def load_from_db(self, session: Session) -> None:
+ """Update the cache.
+
+ Must run in the recorder thread.
+ """
+ result = cast(
+ Sequence[Row[Any]],
+ execute_stmt_lambda_element(session, find_oldest_state()),
+ )
+ if not result:
+ ts = None
+ else:
+ ts = result[0].last_updated_ts
+ self._oldest_ts = ts
def evict_purged_state_ids(self, purged_state_ids: set[int]) -> None:
"""Evict purged states from the committed states.
diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py
index 783f0a80b8e..fa10c12aa68 100644
--- a/homeassistant/components/recorder/tasks.py
+++ b/homeassistant/components/recorder/tasks.py
@@ -120,8 +120,6 @@ class PurgeTask(RecorderTask):
if purge.purge_old_data(
instance, self.purge_before, self.repack, self.apply_filter
):
- with instance.get_session() as session:
- instance.recorder_runs_manager.load_from_db(session)
# We always need to do the db cleanups after a purge
# is finished to ensure the WAL checkpoint and other
# tasks happen after a vacuum.
diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py
index f4dce73fa47..ee5c5dd6d75 100644
--- a/homeassistant/components/recorder/websocket_api.py
+++ b/homeassistant/components/recorder/websocket_api.py
@@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
+ AreaConverter,
BloodGlucoseConcentrationConverter,
ConductivityConverter,
DataRateConverter,
@@ -55,6 +56,7 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10
UNIT_SCHEMA = vol.Schema(
{
+ vol.Optional("area"): vol.In(AreaConverter.VALID_UNITS),
vol.Optional("blood_glucose_concentration"): vol.In(
BloodGlucoseConcentrationConverter.VALID_UNITS
),
diff --git a/homeassistant/components/recswitch/manifest.json b/homeassistant/components/recswitch/manifest.json
index 3e243d8f0d2..1273d498efd 100644
--- a/homeassistant/components/recswitch/manifest.json
+++ b/homeassistant/components/recswitch/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/recswitch",
"iot_class": "local_polling",
"loggers": ["pyrecswitch"],
+ "quality_scale": "legacy",
"requirements": ["pyrecswitch==1.0.2"]
}
diff --git a/homeassistant/components/reddit/manifest.json b/homeassistant/components/reddit/manifest.json
index beb2b168e88..a2e20329be0 100644
--- a/homeassistant/components/reddit/manifest.json
+++ b/homeassistant/components/reddit/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/reddit",
"iot_class": "cloud_polling",
"loggers": ["praw", "prawcore"],
+ "quality_scale": "legacy",
"requirements": ["praw==7.5.0"]
}
diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json
index bf046e954d1..da7050433f3 100644
--- a/homeassistant/components/refoss/manifest.json
+++ b/homeassistant/components/refoss/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/refoss",
"iot_class": "local_polling",
- "requirements": ["refoss-ha==1.2.4"]
+ "requirements": ["refoss-ha==1.2.5"]
}
diff --git a/homeassistant/components/rejseplanen/manifest.json b/homeassistant/components/rejseplanen/manifest.json
index 72da7a65f45..6d0642cc996 100644
--- a/homeassistant/components/rejseplanen/manifest.json
+++ b/homeassistant/components/rejseplanen/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/rejseplanen",
"iot_class": "cloud_polling",
"loggers": ["rjpl"],
+ "quality_scale": "legacy",
"requirements": ["rjpl==0.3.6"]
}
diff --git a/homeassistant/components/remember_the_milk/manifest.json b/homeassistant/components/remember_the_milk/manifest.json
index ab309c765fc..13c37d56dba 100644
--- a/homeassistant/components/remember_the_milk/manifest.json
+++ b/homeassistant/components/remember_the_milk/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/remember_the_milk",
"iot_class": "cloud_push",
"loggers": ["rtmapi"],
+ "quality_scale": "legacy",
"requirements": ["RtmAPI==0.7.2", "httplib2==0.20.4"]
}
diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py
index 6a007bde0b4..9c54a40be70 100644
--- a/homeassistant/components/remote/__init__.py
+++ b/homeassistant/components/remote/__init__.py
@@ -22,12 +22,6 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -74,19 +68,6 @@ class RemoteEntityFeature(IntFlag):
ACTIVITY = 4
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the RemoteEntityFeature enum instead.
-_DEPRECATED_SUPPORT_LEARN_COMMAND = DeprecatedConstantEnum(
- RemoteEntityFeature.LEARN_COMMAND, "2025.1"
-)
-_DEPRECATED_SUPPORT_DELETE_COMMAND = DeprecatedConstantEnum(
- RemoteEntityFeature.DELETE_COMMAND, "2025.1"
-)
-_DEPRECATED_SUPPORT_ACTIVITY = DeprecatedConstantEnum(
- RemoteEntityFeature.ACTIVITY, "2025.1"
-)
-
-
REMOTE_SERVICE_ACTIVITY_SCHEMA = cv.make_entity_service_schema(
{vol.Optional(ATTR_ACTIVITY): cv.string}
)
@@ -251,11 +232,3 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
await self.hass.async_add_executor_job(
ft.partial(self.delete_command, **kwargs)
)
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = ft.partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json
index e3df487a57b..09b270b9687 100644
--- a/homeassistant/components/remote/strings.json
+++ b/homeassistant/components/remote/strings.json
@@ -28,7 +28,7 @@
"services": {
"turn_on": {
"name": "[%key:common::action::turn_on%]",
- "description": "Sends the power on command.",
+ "description": "Sends the turn on command.",
"fields": {
"activity": {
"name": "Activity",
@@ -38,11 +38,11 @@
},
"toggle": {
"name": "[%key:common::action::toggle%]",
- "description": "Toggles a device on/off."
+ "description": "Sends the toggle command."
},
"turn_off": {
"name": "[%key:common::action::turn_off%]",
- "description": "Turns the device off."
+ "description": "Sends the turn off command."
},
"send_command": {
"name": "Send command",
diff --git a/homeassistant/components/remote_rpi_gpio/manifest.json b/homeassistant/components/remote_rpi_gpio/manifest.json
index 3a369d859f8..b7e3b55d564 100644
--- a/homeassistant/components/remote_rpi_gpio/manifest.json
+++ b/homeassistant/components/remote_rpi_gpio/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/remote_rpi_gpio",
"iot_class": "local_push",
"loggers": ["gpiozero", "pigpio"],
+ "quality_scale": "legacy",
"requirements": ["gpiozero==1.6.2", "pigpio==1.78"]
}
diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py
index 98c298761ce..a8fdf324f1c 100644
--- a/homeassistant/components/renault/binary_sensor.py
+++ b/homeassistant/components/renault/binary_sensor.py
@@ -19,6 +19,9 @@ from homeassistant.helpers.typing import StateType
from . import RenaultConfigEntry
from .entity import RenaultDataEntity, RenaultDataEntityDescription
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RenaultBinarySensorEntityDescription(
diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py
index d3666388fbb..6a9f5e05a38 100644
--- a/homeassistant/components/renault/button.py
+++ b/homeassistant/components/renault/button.py
@@ -13,6 +13,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import RenaultConfigEntry
from .entity import RenaultEntity
+# Coordinator is used to centralize the data updates
+# but renault servers are unreliable and it's safer to queue action calls
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class RenaultButtonEntityDescription(ButtonEntityDescription):
diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py
index 82429dd146c..70544a5637f 100644
--- a/homeassistant/components/renault/config_flow.py
+++ b/homeassistant/components/renault/config_flow.py
@@ -3,9 +3,11 @@
from __future__ import annotations
from collections.abc import Mapping
-from typing import TYPE_CHECKING, Any
+from typing import Any
+import aiohttp
from renault_api.const import AVAILABLE_LOCALES
+from renault_api.gigya.exceptions import GigyaException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -14,17 +16,24 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN
from .renault_hub import RenaultHub
+USER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()),
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
+
class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Renault config flow."""
- VERSION = 1
+ renault_hub: RenaultHub
def __init__(self) -> None:
"""Initialize the Renault config flow."""
- self._original_data: Mapping[str, Any] | None = None
self.renault_config: dict[str, Any] = {}
- self.renault_hub: RenaultHub | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -33,30 +42,28 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
Ask the user for API keys.
"""
+ errors: dict[str, str] = {}
if user_input:
locale = user_input[CONF_LOCALE]
self.renault_config.update(user_input)
self.renault_config.update(AVAILABLE_LOCALES[locale])
self.renault_hub = RenaultHub(self.hass, locale)
- if not await self.renault_hub.attempt_login(
- user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
- ):
- return self._show_user_form({"base": "invalid_credentials"})
- return await self.async_step_kamereon()
- return self._show_user_form()
-
- def _show_user_form(self, errors: dict[str, Any] | None = None) -> ConfigFlowResult:
- """Show the API keys form."""
+ try:
+ login_success = await self.renault_hub.attempt_login(
+ user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
+ )
+ except (aiohttp.ClientConnectionError, GigyaException):
+ errors["base"] = "cannot_connect"
+ except Exception: # noqa: BLE001
+ errors["base"] = "unknown"
+ else:
+ if login_success:
+ return await self.async_step_kamereon()
+ errors["base"] = "invalid_credentials"
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()),
- vol.Required(CONF_USERNAME): str,
- vol.Required(CONF_PASSWORD): str,
- }
- ),
- errors=errors or {},
+ data_schema=USER_SCHEMA,
+ errors=errors,
)
async def async_step_kamereon(
@@ -72,18 +79,12 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config
)
- assert self.renault_hub
accounts = await self.renault_hub.get_account_ids()
if len(accounts) == 0:
return self.async_abort(reason="kamereon_no_account")
if len(accounts) == 1:
- await self.async_set_unique_id(accounts[0])
- self._abort_if_unique_id_configured()
-
- self.renault_config[CONF_KAMEREON_ACCOUNT_ID] = accounts[0]
- return self.async_create_entry(
- title=self.renault_config[CONF_KAMEREON_ACCOUNT_ID],
- data=self.renault_config,
+ return await self.async_step_kamereon(
+ user_input={CONF_KAMEREON_ACCOUNT_ID: accounts[0]}
)
return self.async_show_form(
@@ -97,48 +98,29 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
- self._original_data = entry_data
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
- if not user_input:
- return self._show_reauth_confirm_form()
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+ if user_input:
+ # Check credentials
+ self.renault_hub = RenaultHub(self.hass, reauth_entry.data[CONF_LOCALE])
+ if await self.renault_hub.attempt_login(
+ reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
+ ):
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
+ )
+ errors = {"base": "invalid_credentials"}
- if TYPE_CHECKING:
- assert self._original_data
-
- # Check credentials
- self.renault_hub = RenaultHub(self.hass, self._original_data[CONF_LOCALE])
- if not await self.renault_hub.attempt_login(
- self._original_data[CONF_USERNAME], user_input[CONF_PASSWORD]
- ):
- return self._show_reauth_confirm_form({"base": "invalid_credentials"})
-
- # Update existing entry
- data = {**self._original_data, CONF_PASSWORD: user_input[CONF_PASSWORD]}
- existing_entry = await self.async_set_unique_id(
- self._original_data[CONF_KAMEREON_ACCOUNT_ID]
- )
- if TYPE_CHECKING:
- assert existing_entry
- self.hass.config_entries.async_update_entry(existing_entry, data=data)
- await self.hass.config_entries.async_reload(existing_entry.entry_id)
- return self.async_abort(reason="reauth_successful")
-
- def _show_reauth_confirm_form(
- self, errors: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Show the API keys form."""
- if TYPE_CHECKING:
- assert self._original_data
return self.async_show_form(
step_id="reauth_confirm",
- data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
- errors=errors or {},
- description_placeholders={
- CONF_USERNAME: self._original_data[CONF_USERNAME]
- },
+ data_schema=REAUTH_SCHEMA,
+ errors=errors,
+ description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
)
diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py
index d7aed6e3560..89e62867130 100644
--- a/homeassistant/components/renault/coordinator.py
+++ b/homeassistant/components/renault/coordinator.py
@@ -18,7 +18,7 @@ from renault_api.kamereon.models import KamereonVehicleDataAttributes
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-T = TypeVar("T", bound=KamereonVehicleDataAttributes | None)
+T = TypeVar("T", bound=KamereonVehicleDataAttributes)
# We have potentially 7 coordinators per vehicle
_PARALLEL_SEMAPHORE = asyncio.Semaphore(1)
@@ -27,6 +27,8 @@ _PARALLEL_SEMAPHORE = asyncio.Semaphore(1)
class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
"""Handle vehicle communication with Renault servers."""
+ update_method: Callable[[], Awaitable[T]]
+
def __init__(
self,
hass: HomeAssistant,
@@ -50,8 +52,6 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
async def _async_update_data(self) -> T:
"""Fetch the latest data from the source."""
- if self.update_method is None:
- raise NotImplementedError("Update method not implemented")
try:
async with _PARALLEL_SEMAPHORE:
data = await self.update_method()
diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py
index 2f7aeda5c39..08a2a698802 100644
--- a/homeassistant/components/renault/device_tracker.py
+++ b/homeassistant/components/renault/device_tracker.py
@@ -16,6 +16,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import RenaultConfigEntry
from .entity import RenaultDataEntity, RenaultDataEntityDescription
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RenaultTrackerEntityDescription(
diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py
index 10de028b2d0..7beb91e9603 100644
--- a/homeassistant/components/renault/entity.py
+++ b/homeassistant/components/renault/entity.py
@@ -59,6 +59,4 @@ class RenaultDataEntity(
def _get_data_attr(self, key: str) -> StateType:
"""Return the attribute value from the coordinator data."""
- if self.coordinator.data is None:
- return None # type: ignore[unreachable]
return cast(StateType, getattr(self.coordinator.data, key))
diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json
index 716f2086bf1..a4817fc84e6 100644
--- a/homeassistant/components/renault/manifest.json
+++ b/homeassistant/components/renault/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
- "quality_scale": "platinum",
- "requirements": ["renault-api==0.2.7"]
+ "quality_scale": "silver",
+ "requirements": ["renault-api==0.2.8"]
}
diff --git a/homeassistant/components/renault/quality_scale.yaml b/homeassistant/components/renault/quality_scale.yaml
new file mode 100644
index 00000000000..f2d70622192
--- /dev/null
+++ b/homeassistant/components/renault/quality_scale.yaml
@@ -0,0 +1,64 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ 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: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options flow
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: Discovery not possible
+ discovery:
+ status: exempt
+ comment: Discovery not possible
+ 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: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues: done
+ stale-devices: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py
index b430da9396e..cab1d1f4d8a 100644
--- a/homeassistant/components/renault/select.py
+++ b/homeassistant/components/renault/select.py
@@ -15,6 +15,10 @@ from homeassistant.helpers.typing import StateType
from . import RenaultConfigEntry
from .entity import RenaultDataEntity, RenaultDataEntityDescription
+# Coordinator is used to centralize the data updates
+# but renault servers are unreliable and it's safer to queue action calls
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class RenaultSelectEntityDescription(
diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py
index 78e64ae9acc..7854d70b1c4 100644
--- a/homeassistant/components/renault/sensor.py
+++ b/homeassistant/components/renault/sensor.py
@@ -40,6 +40,9 @@ from .coordinator import T
from .entity import RenaultDataEntity, RenaultDataEntityDescription
from .renault_vehicle import RenaultVehicleProxy
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RenaultSensorEntityDescription(
diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py
index 4409d9f284b..80fb2363b1e 100644
--- a/homeassistant/components/renault/services.py
+++ b/homeassistant/components/renault/services.py
@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN
@@ -169,18 +170,27 @@ def setup_services(hass: HomeAssistant) -> None:
device_id = service_call_data[ATTR_VEHICLE]
device_entry = device_registry.async_get(device_id)
if device_entry is None:
- raise ValueError(f"Unable to find device with id: {device_id}")
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_device_id",
+ translation_placeholders={"device_id": device_id},
+ )
loaded_entries: list[RenaultConfigEntry] = [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.state == ConfigEntryState.LOADED
+ and entry.entry_id in device_entry.config_entries
]
for entry in loaded_entries:
for vin, vehicle in entry.runtime_data.vehicles.items():
if (DOMAIN, vin) in device_entry.identifiers:
return vehicle
- raise ValueError(f"Unable to find vehicle with VIN: {device_entry.identifiers}")
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_config_entry_for_device",
+ translation_placeholders={"device_id": device_entry.name or device_id},
+ )
hass.services.async_register(
DOMAIN,
diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json
index 9cc34edb82f..7d9cae1bcf1 100644
--- a/homeassistant/components/renault/strings.json
+++ b/homeassistant/components/renault/strings.json
@@ -6,19 +6,28 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
- "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]"
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"kamereon": {
"data": {
- "kamereon_account_id": "Kamereon account id"
+ "kamereon_account_id": "Account ID"
},
- "title": "Select Kamereon account id"
+ "data_description": {
+ "kamereon_account_id": "The Kamereon account ID associated with your vehicle"
+ },
+ "title": "Kamereon Account ID",
+ "description": "You have multiple Kamereon accounts associated to this email, please select one"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
+ "data_description": {
+ "password": "Your MyRenault phone application password"
+ },
"description": "Please update your password for {username}",
"title": "[%key:common::config_flow::title::reauth%]"
},
@@ -28,6 +37,11 @@
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
+ "data_description": {
+ "locale": "Your country code",
+ "username": "Your MyRenault phone application email address",
+ "password": "Your MyRenault phone application password"
+ },
"title": "Set Renault credentials"
}
}
@@ -211,5 +225,13 @@
}
}
}
+ },
+ "exceptions": {
+ "invalid_device_id": {
+ "message": "No device with id {device_id} was found"
+ },
+ "no_config_entry_for_device": {
+ "message": "No loaded config entry was found for device with id {device_id}"
+ }
}
}
diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py
index f6c64d0b060..c168c97e809 100644
--- a/homeassistant/components/reolink/binary_sensor.py
+++ b/homeassistant/components/reolink/binary_sensor.py
@@ -28,6 +28,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class ReolinkBinarySensorEntityDescription(
@@ -103,6 +105,7 @@ BINARY_PUSH_SENSORS = (
BINARY_SENSORS = (
ReolinkBinarySensorEntityDescription(
key="sleep",
+ cmd_id=145,
cmd_key="GetChannelstatus",
translation_key="sleep",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -173,14 +176,14 @@ class ReolinkPushBinarySensorEntity(ReolinkBinarySensorEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- f"{self._host.webhook_id}_{self._channel}",
+ f"{self._host.unique_id}_{self._channel}",
self._async_handle_event,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- f"{self._host.webhook_id}_all",
+ f"{self._host.unique_id}_all",
self._async_handle_event,
)
)
diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py
index 986ac9d872c..cd1e1b05fae 100644
--- a/homeassistant/components/reolink/button.py
+++ b/homeassistant/components/reolink/button.py
@@ -33,6 +33,7 @@ from .entity import (
)
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
ATTR_SPEED = "speed"
SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM
SERVICE_PTZ_MOVE = "ptz_move"
@@ -211,7 +212,7 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity):
except ReolinkError as err:
raise HomeAssistantError(err) from err
- async def async_ptz_move(self, **kwargs) -> None:
+ async def async_ptz_move(self, **kwargs: Any) -> None:
"""PTZ move with speed."""
speed = kwargs[ATTR_SPEED]
try:
diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py
index 600286be9a2..26ef0b0f4fc 100644
--- a/homeassistant/components/reolink/camera.py
+++ b/homeassistant/components/reolink/camera.py
@@ -21,6 +21,7 @@ from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescrip
from .util import ReolinkConfigEntry, ReolinkData
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py
index 0b1ed7b4b15..c28e076aab4 100644
--- a/homeassistant/components/reolink/config_flow.py
+++ b/homeassistant/components/reolink/config_flow.py
@@ -128,13 +128,8 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Dialog that informs the user that reauth is required."""
- if user_input is not None:
- return await self.async_step_user()
- placeholders = {"name": self.context["title_placeholders"]["name"]}
- return self.async_show_form(
- step_id="reauth_confirm", description_placeholders=placeholders
- )
+ """Perform a reauthentication."""
+ return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
@@ -278,7 +273,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_update_reload_and_abort(
entry=self._get_reconfigure_entry(), data=user_input
)
- self._abort_if_unique_id_configured(updates=user_input)
+ self._abort_if_unique_id_configured()
return self.async_create_entry(
title=str(host.api.nvr_name),
diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py
index 6101eee8a4c..dc2366e8f56 100644
--- a/homeassistant/components/reolink/entity.py
+++ b/homeassistant/components/reolink/entity.py
@@ -179,7 +179,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
"""Return True if entity is available."""
return super().available and self._host.api.camera_online(self._channel)
- def register_callback(self, unique_id: str, cmd_id) -> None:
+ def register_callback(self, unique_id: str, cmd_id: int) -> None:
"""Register callback for TCP push events."""
self._host.api.baichuan.register_callback(
unique_id, self._push_callback, cmd_id, self._channel
diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py
index 336876d4c4f..97d888c0323 100644
--- a/homeassistant/components/reolink/host.py
+++ b/homeassistant/components/reolink/host.py
@@ -110,6 +110,7 @@ class ReolinkHost:
self._cancel_onvif_check: CALLBACK_TYPE | None = None
self._cancel_long_poll_check: CALLBACK_TYPE | None = None
self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True)
+ self._fast_poll_error: bool = False
self._long_poll_task: asyncio.Task | None = None
self._lost_subscription: bool = False
@@ -261,7 +262,7 @@ class ReolinkHost:
else:
ir.async_delete_issue(self._hass, DOMAIN, f"firmware_update_{key}")
- async def _async_check_tcp_push(self, *_) -> None:
+ async def _async_check_tcp_push(self, *_: Any) -> None:
"""Check the TCP push subscription."""
if self._api.baichuan.events_active:
ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
@@ -322,7 +323,7 @@ class ReolinkHost:
self._cancel_tcp_push_check = None
- async def _async_check_onvif(self, *_) -> None:
+ async def _async_check_onvif(self, *_: Any) -> None:
"""Check the ONVIF subscription."""
if self._webhook_reachable:
ir.async_delete_issue(self._hass, DOMAIN, "webhook_url")
@@ -343,7 +344,7 @@ class ReolinkHost:
self._cancel_onvif_check = None
- async def _async_check_onvif_long_poll(self, *_) -> None:
+ async def _async_check_onvif_long_poll(self, *_: Any) -> None:
"""Check if ONVIF long polling is working."""
if not self._long_poll_received:
_LOGGER.debug(
@@ -449,7 +450,7 @@ class ReolinkHost:
err,
)
- async def _async_start_long_polling(self, initial=False) -> None:
+ async def _async_start_long_polling(self, initial: bool = False) -> None:
"""Start ONVIF long polling task."""
if self._long_poll_task is None:
try:
@@ -494,7 +495,7 @@ class ReolinkHost:
err,
)
- async def stop(self, event=None) -> None:
+ async def stop(self, *_: Any) -> None:
"""Disconnect the API."""
if self._cancel_poll is not None:
self._cancel_poll()
@@ -535,6 +536,8 @@ class ReolinkHost:
async def renew(self) -> None:
"""Renew the subscription of motion events (lease time is 15 minutes)."""
+ await self._api.baichuan.check_subscribe_events()
+
if self._api.baichuan.events_active and self._api.subscribed(SubType.push):
# TCP push active, unsubscribe from ONVIF push because not needed
self.unregister_webhook()
@@ -650,7 +653,7 @@ class ReolinkHost:
webhook.async_unregister(self._hass, self.webhook_id)
self.webhook_id = None
- async def _async_long_polling(self, *_) -> None:
+ async def _async_long_polling(self, *_: Any) -> None:
"""Use ONVIF long polling to immediately receive events."""
# This task will be cancelled once _async_stop_long_polling is called
while True:
@@ -687,7 +690,7 @@ class ReolinkHost:
# Cooldown to prevent CPU over usage on camera freezes
await asyncio.sleep(LONG_POLL_COOLDOWN)
- async def _async_poll_all_motion(self, *_) -> None:
+ async def _async_poll_all_motion(self, *_: Any) -> None:
"""Poll motion and AI states until the first ONVIF push is received."""
if (
self._api.baichuan.events_active
@@ -699,14 +702,20 @@ class ReolinkHost:
return
try:
- await self._api.get_motion_state_all_ch()
+ if self._api.session_active:
+ await self._api.get_motion_state_all_ch()
except ReolinkError as err:
- _LOGGER.error(
- "Reolink error while polling motion state for host %s:%s: %s",
- self._api.host,
- self._api.port,
- err,
- )
+ if not self._fast_poll_error:
+ _LOGGER.error(
+ "Reolink error while polling motion state for host %s:%s: %s",
+ self._api.host,
+ self._api.port,
+ err,
+ )
+ self._fast_poll_error = True
+ else:
+ if self._api.session_active:
+ self._fast_poll_error = False
finally:
# schedule next poll
if not self._hass.is_stopping:
@@ -714,7 +723,7 @@ class ReolinkHost:
self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job
)
- self._signal_write_ha_state(None)
+ self._signal_write_ha_state()
async def handle_webhook(
self, hass: HomeAssistant, webhook_id: str, request: Request
@@ -773,7 +782,7 @@ class ReolinkHost:
"Could not poll motion state after losing connection during receiving ONVIF event"
)
return
- async_dispatcher_send(hass, f"{webhook_id}_all", {})
+ self._signal_write_ha_state()
return
message = data.decode("utf-8")
@@ -786,14 +795,14 @@ class ReolinkHost:
self._signal_write_ha_state(channels)
- def _signal_write_ha_state(self, channels: list[int] | None) -> None:
+ def _signal_write_ha_state(self, channels: list[int] | None = None) -> None:
"""Update the binary sensors with async_write_ha_state."""
if channels is None:
- async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {})
+ async_dispatcher_send(self._hass, f"{self.unique_id}_all", {})
return
for channel in channels:
- async_dispatcher_send(self._hass, f"{self.webhook_id}_{channel}", {})
+ async_dispatcher_send(self._hass, f"{self.unique_id}_{channel}", {})
@property
def event_connection(self) -> str:
diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json
index d333a8a0201..cee044189ea 100644
--- a/homeassistant/components/reolink/icons.json
+++ b/homeassistant/components/reolink/icons.json
@@ -222,6 +222,9 @@
"hdr": {
"default": "mdi:hdr"
},
+ "binning_mode": {
+ "default": "mdi:code-block-brackets"
+ },
"hub_alarm_ringtone": {
"default": "mdi:music-note",
"state": {
@@ -263,6 +266,18 @@
"state": {
"off": "mdi:music-note-off"
}
+ },
+ "main_frame_rate": {
+ "default": "mdi:play-speed"
+ },
+ "sub_frame_rate": {
+ "default": "mdi:play-speed"
+ },
+ "main_bit_rate": {
+ "default": "mdi:play-speed"
+ },
+ "sub_bit_rate": {
+ "default": "mdi:play-speed"
}
},
"sensor": {
diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py
index 0f239a30813..3bd9a120798 100644
--- a/homeassistant/components/reolink/light.py
+++ b/homeassistant/components/reolink/light.py
@@ -28,6 +28,8 @@ from .entity import (
)
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class ReolinkLightEntityDescription(
diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json
index 7921bdb6ed5..72bf21ccfd9 100644
--- a/homeassistant/components/reolink/manifest.json
+++ b/homeassistant/components/reolink/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "reolink",
- "name": "Reolink IP NVR/camera",
+ "name": "Reolink",
"codeowners": ["@starkillerOG"],
"config_flow": true,
"dependencies": ["webhook"],
@@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
- "requirements": ["reolink-aio==0.11.1"]
+ "requirements": ["reolink-aio==0.11.4"]
}
diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py
index 9280df0f5bd..0c23bed7e2f 100644
--- a/homeassistant/components/reolink/media_source.py
+++ b/homeassistant/components/reolink/media_source.py
@@ -24,6 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
from .host import ReolinkHost
+from .util import ReolinkConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -48,7 +49,9 @@ def res_name(stream: str) -> str:
def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost:
"""Return the Reolink host from the config entry id."""
- config_entry = hass.config_entries.async_get_entry(config_entry_id)
+ config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry(
+ config_entry_id
+ )
assert config_entry is not None
return config_entry.runtime_data.host
@@ -65,7 +68,9 @@ class ReolinkVODMediaSource(MediaSource):
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
- identifier = item.identifier.split("|", 5)
+ identifier = ["UNKNOWN"]
+ if item.identifier is not None:
+ identifier = item.identifier.split("|", 5)
if identifier[0] != "FILE":
raise Unresolvable(f"Unknown media item '{item.identifier}'.")
@@ -110,7 +115,7 @@ class ReolinkVODMediaSource(MediaSource):
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
- if item.identifier is None:
+ if not item.identifier:
return await self._async_generate_root()
identifier = item.identifier.split("|", 7)
diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py
index 8ce568d4bd0..692b43bca9e 100644
--- a/homeassistant/components/reolink/number.py
+++ b/homeassistant/components/reolink/number.py
@@ -29,6 +29,8 @@ from .entity import (
)
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class ReolinkNumberEntityDescription(
diff --git a/homeassistant/components/reolink/quality_scale.yaml b/homeassistant/components/reolink/quality_scale.yaml
new file mode 100644
index 00000000000..540cf19e22a
--- /dev/null
+++ b/homeassistant/components/reolink/quality_scale.yaml
@@ -0,0 +1,71 @@
+rules:
+ # Bronze
+ action-setup:
+ status: done
+ comment: |
+ play_chime service is setup in async_setup
+ ptz_move service is setup in async_setup_entry since it is a entity_service
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
+ 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: done
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: done
+ comment: |
+ Coordinators are used and asyncio mutex locks ensure safe operation in the upstream lib
+ Parallel_update=0 set on all platforms
+ reauthentication-flow: done
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices: done
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues: done
+ stale-devices:
+ status: done
+ comment: |
+ For standalone cameras this does not apply: the integration should be removed.
+ For cameras connected to a NVR/Hub: the entities of a device are marked unavailable when power is unplugged. They can be removed using async_remove_config_entry_device.
+ Chimes can be uncoupled from the doorbell and removed from HA using async_remove_config_entry_device
+ Automatic removal lead to many user issues when a device was temporarily out of wifi range or disconnected from power, so not implemented anymore.
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py
index a444997a907..8625f7fb600 100644
--- a/homeassistant/components/reolink/select.py
+++ b/homeassistant/components/reolink/select.py
@@ -8,6 +8,7 @@ import logging
from typing import Any
from reolink_aio.api import (
+ BinningModeEnum,
Chime,
ChimeToneEnum,
DayNightEnum,
@@ -21,7 +22,7 @@ from reolink_aio.api import (
from reolink_aio.exceptions import InvalidParameterError, ReolinkError
from homeassistant.components.select import SelectEntity, SelectEntityDescription
-from homeassistant.const import EntityCategory
+from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfFrequency
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -35,6 +36,7 @@ from .entity import (
from .util import ReolinkConfigEntry, ReolinkData
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -174,6 +176,67 @@ SELECT_ENTITIES = (
value=lambda api, ch: HDREnum(api.HDR_state(ch)).name,
method=lambda api, ch, name: api.set_HDR(ch, HDREnum[name].value),
),
+ ReolinkSelectEntityDescription(
+ key="binning_mode",
+ cmd_key="GetIsp",
+ translation_key="binning_mode",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ get_options=[method.name for method in BinningModeEnum],
+ supported=lambda api, ch: api.supported(ch, "binning_mode"),
+ value=lambda api, ch: BinningModeEnum(api.binning_mode(ch)).name,
+ method=lambda api, ch, name: api.set_binning_mode(
+ ch, BinningModeEnum[name].value
+ ),
+ ),
+ ReolinkSelectEntityDescription(
+ key="main_frame_rate",
+ cmd_key="GetEnc",
+ translation_key="main_frame_rate",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ unit_of_measurement=UnitOfFrequency.HERTZ,
+ get_options=lambda api, ch: [str(v) for v in api.frame_rate_list(ch, "main")],
+ supported=lambda api, ch: api.supported(ch, "frame_rate"),
+ value=lambda api, ch: str(api.frame_rate(ch, "main")),
+ method=lambda api, ch, value: api.set_frame_rate(ch, int(value), "main"),
+ ),
+ ReolinkSelectEntityDescription(
+ key="sub_frame_rate",
+ cmd_key="GetEnc",
+ translation_key="sub_frame_rate",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ unit_of_measurement=UnitOfFrequency.HERTZ,
+ get_options=lambda api, ch: [str(v) for v in api.frame_rate_list(ch, "sub")],
+ supported=lambda api, ch: api.supported(ch, "frame_rate"),
+ value=lambda api, ch: str(api.frame_rate(ch, "sub")),
+ method=lambda api, ch, value: api.set_frame_rate(ch, int(value), "sub"),
+ ),
+ ReolinkSelectEntityDescription(
+ key="main_bit_rate",
+ cmd_key="GetEnc",
+ translation_key="main_bit_rate",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
+ get_options=lambda api, ch: [str(v) for v in api.bit_rate_list(ch, "main")],
+ supported=lambda api, ch: api.supported(ch, "bit_rate"),
+ value=lambda api, ch: str(api.bit_rate(ch, "main")),
+ method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "main"),
+ ),
+ ReolinkSelectEntityDescription(
+ key="sub_bit_rate",
+ cmd_key="GetEnc",
+ translation_key="sub_bit_rate",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
+ get_options=lambda api, ch: [str(v) for v in api.bit_rate_list(ch, "sub")],
+ supported=lambda api, ch: api.supported(ch, "bit_rate"),
+ value=lambda api, ch: str(api.bit_rate(ch, "sub")),
+ method=lambda api, ch, value: api.set_bit_rate(ch, int(value), "sub"),
+ ),
)
CHIME_SELECT_ENTITIES = (
diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py
index 80e58c3d5c2..36900da99ca 100644
--- a/homeassistant/components/reolink/sensor.py
+++ b/homeassistant/components/reolink/sensor.py
@@ -29,6 +29,8 @@ from .entity import (
)
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class ReolinkSensorEntityDescription(
@@ -71,6 +73,7 @@ SENSORS = (
),
ReolinkSensorEntityDescription(
key="battery_percent",
+ cmd_id=252,
cmd_key="GetBatteryInfo",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
@@ -81,6 +84,7 @@ SENSORS = (
),
ReolinkSensorEntityDescription(
key="battery_temperature",
+ cmd_id=252,
cmd_key="GetBatteryInfo",
translation_key="battery_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
@@ -93,6 +97,7 @@ SENSORS = (
),
ReolinkSensorEntityDescription(
key="battery_state",
+ cmd_id=252,
cmd_key="GetBatteryInfo",
translation_key="battery_state",
device_class=SensorDeviceClass.ENUM,
diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py
index 45f435c1f2c..cb12eb5d38c 100644
--- a/homeassistant/components/reolink/siren.py
+++ b/homeassistant/components/reolink/siren.py
@@ -21,6 +21,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True)
class ReolinkSirenEntityDescription(
diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json
index 1d699b7b658..ac73581ce22 100644
--- a/homeassistant/components/reolink/strings.json
+++ b/homeassistant/components/reolink/strings.json
@@ -18,10 +18,6 @@
"username": "Username to login to the Reolink device itself. Not the Reolink cloud account.",
"password": "Password to login to the Reolink device itself. Not the Reolink cloud account."
}
- },
- "reauth_confirm": {
- "title": "[%key:common::config_flow::title::reauth%]",
- "description": "The Reolink integration needs to re-authenticate your connection details"
}
},
"error": {
@@ -37,7 +33,8 @@
"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%]"
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "unique_id_mismatch": "The mac address of the device does not match the previous mac address"
}
},
"options": {
@@ -490,7 +487,7 @@
"name": "Floodlight mode",
"state": {
"off": "[%key:common::state::off%]",
- "auto": "Auto",
+ "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]",
"onatnight": "On at night",
"schedule": "Schedule",
"adaptive": "Adaptive",
@@ -529,7 +526,7 @@
"name": "Doorbell LED",
"state": {
"stayoff": "Stay off",
- "auto": "Auto",
+ "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]",
"alwaysonatnight": "Auto & always on at night",
"alwayson": "Always on"
}
@@ -539,7 +536,15 @@
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
- "auto": "Auto"
+ "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]"
+ }
+ },
+ "binning_mode": {
+ "name": "Binning mode",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]",
+ "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]"
}
},
"hub_alarm_ringtone": {
@@ -653,6 +658,18 @@
"moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]",
"waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]"
}
+ },
+ "main_frame_rate": {
+ "name": "Clear frame rate"
+ },
+ "sub_frame_rate": {
+ "name": "Fluent frame rate"
+ },
+ "main_bit_rate": {
+ "name": "Clear bit rate"
+ },
+ "sub_bit_rate": {
+ "name": "Fluent bit rate"
}
},
"sensor": {
diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py
index 482cdab18a7..c274609599d 100644
--- a/homeassistant/components/reolink/switch.py
+++ b/homeassistant/components/reolink/switch.py
@@ -27,6 +27,8 @@ from .entity import (
)
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class ReolinkSwitchEntityDescription(
diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py
index 33e446e8b25..aa607e2b29e 100644
--- a/homeassistant/components/reolink/update.py
+++ b/homeassistant/components/reolink/update.py
@@ -32,6 +32,7 @@ from .entity import (
)
from .util import ReolinkConfigEntry, ReolinkData
+PARALLEL_UPDATES = 0
RESUME_AFTER_INSTALL = 15
POLL_AFTER_INSTALL = 120
POLL_PROGRESS = 2
@@ -212,7 +213,7 @@ class ReolinkUpdateBaseEntity(
self._reolink_data.device_coordinator.update_interval = None
self._reolink_data.device_coordinator.async_set_updated_data(None)
- async def _resume_update_coordinator(self, *args) -> None:
+ async def _resume_update_coordinator(self, *args: Any) -> None:
"""Resume updating the states using the data update coordinator (after reboots)."""
self._reolink_data.device_coordinator.update_interval = DEVICE_UPDATE_INTERVAL
try:
@@ -220,7 +221,7 @@ class ReolinkUpdateBaseEntity(
finally:
self._cancel_resume = None
- async def _async_update_progress(self, *args) -> None:
+ async def _async_update_progress(self, *args: Any) -> None:
"""Request update."""
self.async_write_ha_state()
if self._installing:
@@ -228,7 +229,7 @@ class ReolinkUpdateBaseEntity(
self.hass, POLL_PROGRESS, self._async_update_progress
)
- async def _async_update_future(self, *args) -> None:
+ async def _async_update_future(self, *args: Any) -> None:
"""Request update."""
try:
await self.async_update()
diff --git a/homeassistant/components/repetier/manifest.json b/homeassistant/components/repetier/manifest.json
index dfddb298284..7392ae0b23e 100644
--- a/homeassistant/components/repetier/manifest.json
+++ b/homeassistant/components/repetier/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/repetier",
"iot_class": "local_polling",
"loggers": ["pyrepetierng"],
+ "quality_scale": "legacy",
"requirements": ["pyrepetierng==0.1.0"]
}
diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json
index 7917fa0bded..f5f372d2d33 100644
--- a/homeassistant/components/rflink/manifest.json
+++ b/homeassistant/components/rflink/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/rflink",
"iot_class": "assumed_state",
"loggers": ["rflink"],
+ "quality_scale": "legacy",
"requirements": ["rflink==0.0.66"]
}
diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py
index b2340b34556..edc084fb57b 100644
--- a/homeassistant/components/ring/__init__.py
+++ b/homeassistant/components/ring/__init__.py
@@ -9,6 +9,7 @@ import uuid
from ring_doorbell import Auth, Ring, RingDevices
+from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import APPLICATION_NAME, CONF_DEVICE_ID, CONF_TOKEN
from homeassistant.core import HomeAssistant, callback
@@ -70,8 +71,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
)
ring = Ring(auth)
- await _migrate_old_unique_ids(hass, entry.entry_id)
-
devices_coordinator = RingDataCoordinator(hass, ring)
listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS)
listen_coordinator = RingListenCoordinator(
@@ -104,42 +103,46 @@ async def async_remove_config_entry_device(
return True
-async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None:
- entity_registry = er.async_get(hass)
-
- @callback
- def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
- # Old format for camera and light was int
- unique_id = cast(str | int, entity_entry.unique_id)
- if isinstance(unique_id, int):
- new_unique_id = str(unique_id)
- if existing_entity_id := entity_registry.async_get_entity_id(
- entity_entry.domain, entity_entry.platform, new_unique_id
- ):
- _LOGGER.error(
- "Cannot migrate to unique_id '%s', already exists for '%s', "
- "You may have to delete unavailable ring entities",
- new_unique_id,
- existing_entity_id,
- )
- return None
- _LOGGER.debug("Fixing non string unique id %s", entity_entry.unique_id)
- return {"new_unique_id": new_unique_id}
- return None
-
- await er.async_migrate_entries(hass, entry_id, _async_migrator)
-
-
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entry."""
entry_version = entry.version
entry_minor_version = entry.minor_version
+ entry_id = entry.entry_id
new_minor_version = 2
if entry_version == 1 and entry_minor_version == 1:
_LOGGER.debug(
"Migrating from version %s.%s", entry_version, entry_minor_version
)
+ # Migrate non-str unique ids
+ # This step used to run unconditionally from async_setup_entry
+ entity_registry = er.async_get(hass)
+
+ @callback
+ def _async_str_unique_id_migrator(
+ entity_entry: er.RegistryEntry,
+ ) -> dict[str, str] | None:
+ # Old format for camera and light was int
+ unique_id = cast(str | int, entity_entry.unique_id)
+ if isinstance(unique_id, int):
+ new_unique_id = str(unique_id)
+ if existing_entity_id := entity_registry.async_get_entity_id(
+ entity_entry.domain, entity_entry.platform, new_unique_id
+ ):
+ _LOGGER.error(
+ "Cannot migrate to unique_id '%s', already exists for '%s', "
+ "You may have to delete unavailable ring entities",
+ new_unique_id,
+ existing_entity_id,
+ )
+ return None
+ _LOGGER.debug("Fixing non string unique id %s", entity_entry.unique_id)
+ return {"new_unique_id": new_unique_id}
+ return None
+
+ await er.async_migrate_entries(hass, entry_id, _async_str_unique_id_migrator)
+
+ # Migrate the hardware id
hardware_id = str(uuid.uuid4())
hass.config_entries.async_update_entry(
entry,
@@ -149,4 +152,34 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug(
"Migration to version %s.%s complete", entry_version, new_minor_version
)
+
+ entry_minor_version = entry.minor_version
+ new_minor_version = 3
+ if entry_version == 1 and entry_minor_version == 2:
+ _LOGGER.debug(
+ "Migrating from version %s.%s", entry_version, entry_minor_version
+ )
+
+ @callback
+ def _async_camera_unique_id_migrator(
+ entity_entry: er.RegistryEntry,
+ ) -> dict[str, str] | None:
+ # Migrate camera unique ids to append -last
+ if entity_entry.domain == CAMERA_DOMAIN and not isinstance(
+ cast(str | int, entity_entry.unique_id), int
+ ):
+ new_unique_id = f"{entity_entry.unique_id}-last_recording"
+ return {"new_unique_id": new_unique_id}
+ return None
+
+ await er.async_migrate_entries(hass, entry_id, _async_camera_unique_id_migrator)
+
+ hass.config_entries.async_update_entry(
+ entry,
+ minor_version=new_minor_version,
+ )
+ _LOGGER.debug(
+ "Migration to version %s.%s complete", entry_version, new_minor_version
+ )
+
return True
diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py
index 9c66df9d89e..ccd91c163d6 100644
--- a/homeassistant/components/ring/camera.py
+++ b/homeassistant/components/ring/camera.py
@@ -2,24 +2,37 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
from datetime import timedelta
import logging
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Generic
from aiohttp import web
from haffmpeg.camera import CameraMjpeg
from ring_doorbell import RingDoorBell
+from ring_doorbell.webrtcstream import RingWebRtcMessage
from homeassistant.components import ffmpeg
-from homeassistant.components.camera import Camera
+from homeassistant.components.camera import (
+ Camera,
+ CameraEntityDescription,
+ CameraEntityFeature,
+ RTCIceCandidateInit,
+ WebRTCAnswer,
+ WebRTCCandidate,
+ WebRTCError,
+ WebRTCSendMessage,
+)
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from . import RingConfigEntry
from .coordinator import RingDataCoordinator
-from .entity import RingEntity, exception_wrap
+from .entity import RingDeviceT, RingEntity, exception_wrap
FORCE_REFRESH_INTERVAL = timedelta(minutes=3)
MOTION_DETECTION_CAPABILITY = "motion_detection"
@@ -27,6 +40,34 @@ MOTION_DETECTION_CAPABILITY = "motion_detection"
_LOGGER = logging.getLogger(__name__)
+@dataclass(frozen=True, kw_only=True)
+class RingCameraEntityDescription(CameraEntityDescription, Generic[RingDeviceT]):
+ """Base class for event entity description."""
+
+ exists_fn: Callable[[RingDoorBell], bool]
+ live_stream: bool
+ motion_detection: bool
+
+
+CAMERA_DESCRIPTIONS: tuple[RingCameraEntityDescription, ...] = (
+ RingCameraEntityDescription(
+ key="live_view",
+ translation_key="live_view",
+ exists_fn=lambda _: True,
+ live_stream=True,
+ motion_detection=False,
+ ),
+ RingCameraEntityDescription(
+ key="last_recording",
+ translation_key="last_recording",
+ entity_registry_enabled_default=False,
+ exists_fn=lambda camera: camera.has_subscription,
+ live_stream=False,
+ motion_detection=True,
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
@@ -38,9 +79,10 @@ async def async_setup_entry(
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
cams = [
- RingCam(camera, devices_coordinator, ffmpeg_manager)
+ RingCam(camera, devices_coordinator, description, ffmpeg_manager=ffmpeg_manager)
+ for description in CAMERA_DESCRIPTIONS
for camera in ring_data.devices.video_devices
- if camera.has_subscription
+ if description.exists_fn(camera)
]
async_add_entities(cams)
@@ -49,26 +91,31 @@ async def async_setup_entry(
class RingCam(RingEntity[RingDoorBell], Camera):
"""An implementation of a Ring Door Bell camera."""
- _attr_name = None
-
def __init__(
self,
device: RingDoorBell,
coordinator: RingDataCoordinator,
+ description: RingCameraEntityDescription,
+ *,
ffmpeg_manager: ffmpeg.FFmpegManager,
) -> None:
"""Initialize a Ring Door Bell camera."""
super().__init__(device, coordinator)
+ self.entity_description = description
Camera.__init__(self)
self._ffmpeg_manager = ffmpeg_manager
self._last_event: dict[str, Any] | None = None
self._last_video_id: int | None = None
self._video_url: str | None = None
- self._image: bytes | None = None
+ self._images: dict[tuple[int | None, int | None], bytes] = {}
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
- self._attr_unique_id = str(device.id)
- if device.has_capability(MOTION_DETECTION_CAPABILITY):
+ self._attr_unique_id = f"{device.id}-{description.key}"
+ if description.motion_detection and device.has_capability(
+ MOTION_DETECTION_CAPABILITY
+ ):
self._attr_motion_detection_enabled = device.motion_detection
+ if description.live_stream:
+ self._attr_supported_features |= CameraEntityFeature.STREAM
@callback
def _handle_coordinator_update(self) -> None:
@@ -86,7 +133,7 @@ class RingCam(RingEntity[RingDoorBell], Camera):
self._last_event = None
self._last_video_id = None
self._video_url = None
- self._image = None
+ self._images = {}
self._expires_at = dt_util.utcnow()
self.async_write_ha_state()
@@ -102,7 +149,8 @@ class RingCam(RingEntity[RingDoorBell], Camera):
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
- if self._image is None and self._video_url is not None:
+ key = (width, height)
+ if not (image := self._images.get(key)) and self._video_url is not None:
image = await ffmpeg.async_get_image(
self.hass,
self._video_url,
@@ -111,9 +159,9 @@ class RingCam(RingEntity[RingDoorBell], Camera):
)
if image:
- self._image = image
+ self._images[key] = image
- return self._image
+ return image
async def handle_async_mjpeg_stream(
self, request: web.Request
@@ -136,6 +184,47 @@ class RingCam(RingEntity[RingDoorBell], Camera):
finally:
await stream.close()
+ async def async_handle_async_webrtc_offer(
+ self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
+ ) -> None:
+ """Return the source of the stream."""
+
+ def message_wrapper(ring_message: RingWebRtcMessage) -> None:
+ if ring_message.error_code:
+ msg = ring_message.error_message or ""
+ send_message(WebRTCError(ring_message.error_code, msg))
+ elif ring_message.answer:
+ send_message(WebRTCAnswer(ring_message.answer))
+ elif ring_message.candidate:
+ send_message(
+ WebRTCCandidate(
+ RTCIceCandidateInit(
+ ring_message.candidate,
+ sdp_m_line_index=ring_message.sdp_m_line_index or 0,
+ )
+ )
+ )
+
+ return await self._device.generate_async_webrtc_stream(
+ offer_sdp, session_id, message_wrapper, keep_alive_timeout=None
+ )
+
+ async def async_on_webrtc_candidate(
+ self, session_id: str, candidate: RTCIceCandidateInit
+ ) -> None:
+ """Handle a WebRTC candidate."""
+ if candidate.sdp_m_line_index is None:
+ msg = "The sdp_m_line_index is required for ring webrtc streaming"
+ raise HomeAssistantError(msg)
+ await self._device.on_webrtc_candidate(
+ session_id, candidate.candidate, candidate.sdp_m_line_index
+ )
+
+ @callback
+ def close_webrtc_session(self, session_id: str) -> None:
+ """Close a WebRTC session."""
+ self._device.sync_close_webrtc_stream(session_id)
+
async def async_update(self) -> None:
"""Update camera entity and refresh attributes."""
if (
@@ -157,7 +246,7 @@ class RingCam(RingEntity[RingDoorBell], Camera):
return
if self._last_video_id != self._last_event["id"]:
- self._image = None
+ self._images = {}
self._video_url = await self._async_get_video()
diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py
index 9595241ebb1..68ac00d69f6 100644
--- a/homeassistant/components/ring/const.py
+++ b/homeassistant/components/ring/const.py
@@ -33,4 +33,4 @@ SCAN_INTERVAL = timedelta(minutes=1)
CONF_2FA = "2fa"
CONF_LISTEN_CREDENTIALS = "listen_token"
-CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 2
+CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 3
diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json
index e431c680081..86758b26794 100644
--- a/homeassistant/components/ring/manifest.json
+++ b/homeassistant/components/ring/manifest.json
@@ -29,6 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/ring",
"iot_class": "cloud_polling",
"loggers": ["ring_doorbell"],
- "quality_scale": "silver",
- "requirements": ["ring-doorbell==0.9.12"]
+ "requirements": ["ring-doorbell==0.9.13"]
}
diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json
index 0887e4112c6..8170ec8e161 100644
--- a/homeassistant/components/ring/strings.json
+++ b/homeassistant/components/ring/strings.json
@@ -124,6 +124,14 @@
"motion_detection": {
"name": "Motion detection"
}
+ },
+ "camera": {
+ "live_view": {
+ "name": "Live view"
+ },
+ "last_recording": {
+ "name": "Last recording"
+ }
}
},
"issues": {
diff --git a/homeassistant/components/ripple/manifest.json b/homeassistant/components/ripple/manifest.json
index 72df64ac850..17ff6b34f38 100644
--- a/homeassistant/components/ripple/manifest.json
+++ b/homeassistant/components/ripple/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ripple",
"iot_class": "cloud_polling",
"loggers": ["pyripple"],
+ "quality_scale": "legacy",
"requirements": ["python-ripple-api==0.0.3"]
}
diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json
index 372d8e0c629..c226c1c590d 100644
--- a/homeassistant/components/risco/manifest.json
+++ b/homeassistant/components/risco/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/risco",
"iot_class": "local_push",
"loggers": ["pyrisco"],
- "quality_scale": "platinum",
"requirements": ["pyrisco==0.6.4"]
}
diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json
index 996dd1faecf..114491d9122 100644
--- a/homeassistant/components/rituals_perfume_genie/manifest.json
+++ b/homeassistant/components/rituals_perfume_genie/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie",
"iot_class": "cloud_polling",
"loggers": ["pyrituals"],
- "quality_scale": "silver",
"requirements": ["pyrituals==0.0.6"]
}
diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py
index e93d6ae03ef..27aff70649b 100644
--- a/homeassistant/components/rituals_perfume_genie/select.py
+++ b/homeassistant/components/rituals_perfume_genie/select.py
@@ -9,7 +9,7 @@ from pyrituals import Diffuser
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import AREA_SQUARE_METERS, EntityCategory
+from homeassistant.const import EntityCategory, UnitOfArea
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -30,7 +30,7 @@ ENTITY_DESCRIPTIONS = (
RitualsSelectEntityDescription(
key="room_size_square_meter",
translation_key="room_size_square_meter",
- unit_of_measurement=AREA_SQUARE_METERS,
+ unit_of_measurement=UnitOfArea.SQUARE_METERS,
entity_category=EntityCategory.CONFIG,
options=["15", "30", "60", "100"],
current_fn=lambda diffuser: str(diffuser.room_size_square_meter),
diff --git a/homeassistant/components/rmvtransport/manifest.json b/homeassistant/components/rmvtransport/manifest.json
index 81b650bcdc0..30be5417ff6 100644
--- a/homeassistant/components/rmvtransport/manifest.json
+++ b/homeassistant/components/rmvtransport/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/rmvtransport",
"iot_class": "cloud_polling",
"loggers": ["RMVtransport"],
+ "quality_scale": "legacy",
"requirements": ["PyRMVtransport==0.3.3"]
}
diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py
index 33ce6be5a68..47849ed5cc5 100644
--- a/homeassistant/components/roborock/sensor.py
+++ b/homeassistant/components/roborock/sensor.py
@@ -25,12 +25,7 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.const import (
- AREA_SQUARE_METERS,
- PERCENTAGE,
- EntityCategory,
- UnitOfTime,
-)
+from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -131,14 +126,14 @@ SENSOR_DESCRIPTIONS = [
translation_key="cleaning_area",
value_fn=lambda data: data.status.square_meter_clean_area,
entity_category=EntityCategory.DIAGNOSTIC,
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
),
RoborockSensorDescription(
key="total_cleaning_area",
translation_key="total_cleaning_area",
value_fn=lambda data: data.clean_summary.square_meter_clean_area,
entity_category=EntityCategory.DIAGNOSTIC,
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
),
RoborockSensorDescription(
key="vacuum_error",
diff --git a/homeassistant/components/rocketchat/manifest.json b/homeassistant/components/rocketchat/manifest.json
index 50d7579df02..f4f72f02a10 100644
--- a/homeassistant/components/rocketchat/manifest.json
+++ b/homeassistant/components/rocketchat/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/rocketchat",
"iot_class": "cloud_push",
"loggers": ["rocketchat_API"],
+ "quality_scale": "legacy",
"requirements": ["rocketchat-API==0.6.1"]
}
diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json
index fa9823de172..7fe2fb3b686 100644
--- a/homeassistant/components/roku/manifest.json
+++ b/homeassistant/components/roku/manifest.json
@@ -10,7 +10,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["rokuecp"],
- "quality_scale": "silver",
"requirements": ["rokuecp==0.19.3"],
"ssdp": [
{
diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py
index bdd486c4f8f..341125b86ba 100644
--- a/homeassistant/components/romy/sensor.py
+++ b/homeassistant/components/romy/sensor.py
@@ -8,10 +8,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- AREA_SQUARE_METERS,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
+ UnitOfArea,
UnitOfLength,
UnitOfTime,
)
@@ -61,7 +61,7 @@ SENSORS: list[SensorEntityDescription] = [
key="total_area_cleaned",
translation_key="total_area_cleaned",
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
entity_category=EntityCategory.DIAGNOSTIC,
),
SensorEntityDescription(
diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py
index 87e97fdb760..d358dcb428c 100644
--- a/homeassistant/components/roomba/sensor.py
+++ b/homeassistant/components/roomba/sensor.py
@@ -12,12 +12,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- AREA_SQUARE_METERS,
- PERCENTAGE,
- EntityCategory,
- UnitOfTime,
-)
+from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -108,7 +103,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [
RoombaSensorEntityDescription(
key="total_cleaned_area",
translation_key="total_cleaned_area",
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda self: (
None if (sqft := self.run_stats.get("sqft")) is None else sqft * 9.29
diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json
index 6db240bdcab..978c916e3ee 100644
--- a/homeassistant/components/route53/manifest.json
+++ b/homeassistant/components/route53/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/route53",
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
+ "quality_scale": "legacy",
"requirements": ["boto3==1.34.131"]
}
diff --git a/homeassistant/components/rpi_camera/manifest.json b/homeassistant/components/rpi_camera/manifest.json
index 9f7346ea353..aab16b1c462 100644
--- a/homeassistant/components/rpi_camera/manifest.json
+++ b/homeassistant/components/rpi_camera/manifest.json
@@ -3,5 +3,6 @@
"name": "Raspberry Pi Camera",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/rpi_camera",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/rtorrent/manifest.json b/homeassistant/components/rtorrent/manifest.json
index 96b079c4363..bcd39a03aa3 100644
--- a/homeassistant/components/rtorrent/manifest.json
+++ b/homeassistant/components/rtorrent/manifest.json
@@ -3,5 +3,6 @@
"name": "rTorrent",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/rtorrent",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py
index 59b8077e398..0fc257c463f 100644
--- a/homeassistant/components/rtsp_to_webrtc/__init__.py
+++ b/homeassistant/components/rtsp_to_webrtc/__init__.py
@@ -30,6 +30,7 @@ from homeassistant.components import camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
@@ -40,10 +41,24 @@ DATA_UNSUB = "unsub"
TIMEOUT = 10
CONF_STUN_SERVER = "stun_server"
+_DEPRECATED = "deprecated"
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up RTSPtoWebRTC from a config entry."""
hass.data.setdefault(DOMAIN, {})
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ _DEPRECATED,
+ breaks_in_ha_version="2025.6.0",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key=_DEPRECATED,
+ translation_placeholders={
+ "go2rtc": "[go2rtc](https://www.home-assistant.io/integrations/go2rtc/)",
+ },
+ )
client: WebRTCClientInterface
try:
@@ -98,6 +113,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if DOMAIN in hass.data:
del hass.data[DOMAIN]
+ ir.async_delete_issue(hass, DOMAIN, _DEPRECATED)
return True
diff --git a/homeassistant/components/rtsp_to_webrtc/strings.json b/homeassistant/components/rtsp_to_webrtc/strings.json
index e52ab554473..c8dcbb7f462 100644
--- a/homeassistant/components/rtsp_to_webrtc/strings.json
+++ b/homeassistant/components/rtsp_to_webrtc/strings.json
@@ -24,6 +24,12 @@
"server_unreachable": "[%key:component::rtsp_to_webrtc::config::error::server_unreachable%]"
}
},
+ "issues": {
+ "deprecated": {
+ "title": "The RTSPtoWebRTC integration is deprecated",
+ "description": "The RTSPtoWebRTC integration is deprecated and will be removed. Please use the {go2rtc} integration instead, which is enabled by default and provides a better experience. You only need to remove the RTSPtoWebRTC config entry."
+ }
+ },
"options": {
"step": {
"init": {
diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json
index ab77ca3ab6a..2cd153c232c 100644
--- a/homeassistant/components/russound_rio/manifest.json
+++ b/homeassistant/components/russound_rio/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
"iot_class": "local_push",
"loggers": ["aiorussound"],
- "quality_scale": "silver",
"requirements": ["aiorussound==4.1.0"]
}
diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json
index 90bf5d5a7f3..27fbfbca57f 100644
--- a/homeassistant/components/russound_rnet/manifest.json
+++ b/homeassistant/components/russound_rnet/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/russound_rnet",
"iot_class": "local_polling",
"loggers": ["russound"],
+ "quality_scale": "legacy",
"requirements": ["russound==0.2.0"]
}
diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py
index a827e9a36a4..e6a99c858c3 100644
--- a/homeassistant/components/sabnzbd/__init__.py
+++ b/homeassistant/components/sabnzbd/__init__.py
@@ -8,40 +8,26 @@ from typing import Any
import voluptuous as vol
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState
-from homeassistant.const import (
- CONF_API_KEY,
- CONF_HOST,
- CONF_NAME,
- CONF_PORT,
- CONF_SENSORS,
- CONF_SSL,
- Platform,
-)
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
-from homeassistant.helpers import config_validation as cv, device_registry as dr
-from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers import config_validation as cv
+import homeassistant.helpers.issue_registry as ir
from .const import (
ATTR_API_KEY,
ATTR_SPEED,
- DEFAULT_HOST,
- DEFAULT_NAME,
- DEFAULT_PORT,
DEFAULT_SPEED_LIMIT,
- DEFAULT_SSL,
DOMAIN,
SERVICE_PAUSE,
SERVICE_RESUME,
SERVICE_SET_SPEED,
)
-from .coordinator import SabnzbdUpdateCoordinator
-from .sab import get_client
-from .sensor import OLD_SENSOR_KEYS
+from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator
+from .helpers import get_client
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
SERVICES = (
@@ -62,121 +48,31 @@ SERVICE_SPEED_SCHEMA = SERVICE_BASE_SCHEMA.extend(
}
)
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- vol.All(
- cv.deprecated(CONF_HOST),
- cv.deprecated(CONF_PORT),
- cv.deprecated(CONF_SENSORS),
- cv.deprecated(CONF_SSL),
- {
- vol.Required(CONF_API_KEY): str,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_SENSORS): vol.All(
- cv.ensure_list, [vol.In(OLD_SENSOR_KEYS)]
- ),
- vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
- },
- )
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the SABnzbd component."""
- hass.data.setdefault(DOMAIN, {})
-
- if hass.config_entries.async_entries(DOMAIN):
- return True
-
- if DOMAIN in config:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config[DOMAIN],
- )
- )
-
- return True
-
@callback
-def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) -> str:
+def async_get_entry_for_service_call(
+ hass: HomeAssistant, call: ServiceCall
+) -> SabnzbdConfigEntry:
"""Get the entry ID related to a service call (by device ID)."""
call_data_api_key = call.data[ATTR_API_KEY]
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.data[ATTR_API_KEY] == call_data_api_key:
- return entry.entry_id
+ return entry
raise ValueError(f"No api for API key: {call_data_api_key}")
-def update_device_identifiers(hass: HomeAssistant, entry: ConfigEntry):
- """Update device identifiers to new identifiers."""
- device_registry = dr.async_get(hass)
- device_entry = device_registry.async_get_device(identifiers={(DOMAIN, DOMAIN)})
- if device_entry and entry.entry_id in device_entry.config_entries:
- new_identifiers = {(DOMAIN, entry.entry_id)}
- _LOGGER.debug(
- "Updating device id <%s> with new identifiers <%s>",
- device_entry.id,
- new_identifiers,
- )
- device_registry.async_update_device(
- device_entry.id, new_identifiers=new_identifiers
- )
-
-
-async def migrate_unique_id(hass: HomeAssistant, entry: ConfigEntry):
- """Migrate entities to new unique ids (with entry_id)."""
-
- @callback
- def async_migrate_callback(entity_entry: RegistryEntry) -> dict | None:
- """Define a callback to migrate appropriate SabnzbdSensor entities to new unique IDs.
-
- Old: description.key
- New: {entry_id}_description.key
- """
- entry_id = entity_entry.config_entry_id
- if entry_id is None:
- return None
- if entity_entry.unique_id.startswith(entry_id):
- return None
-
- new_unique_id = f"{entry_id}_{entity_entry.unique_id}"
-
- _LOGGER.debug(
- "Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
- entity_entry.entity_id,
- entity_entry.unique_id,
- new_unique_id,
- )
-
- return {"new_unique_id": new_unique_id}
-
- await async_migrate_entries(hass, entry.entry_id, async_migrate_callback)
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool:
"""Set up the SabNzbd Component."""
sab_api = await get_client(hass, entry.data)
if not sab_api:
raise ConfigEntryNotReady
- await migrate_unique_id(hass, entry)
- update_device_identifiers(hass, entry)
-
- coordinator = SabnzbdUpdateCoordinator(hass, sab_api)
+ coordinator = SabnzbdUpdateCoordinator(hass, entry, sab_api)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
@callback
def extract_api(
@@ -188,8 +84,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def wrapper(call: ServiceCall) -> None:
"""Wrap the service function."""
- entry_id = async_get_entry_id_for_service_call(hass, call)
- coordinator: SabnzbdUpdateCoordinator = hass.data[DOMAIN][entry_id]
+ config_entry = async_get_entry_for_service_call(hass, call)
+ coordinator = config_entry.runtime_data
try:
await func(call, coordinator)
@@ -204,18 +100,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_pause_queue(
call: ServiceCall, coordinator: SabnzbdUpdateCoordinator
) -> None:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "pause_action_deprecated",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ breaks_in_ha_version="2025.6",
+ translation_key="pause_action_deprecated",
+ )
await coordinator.sab_api.pause_queue()
@extract_api
async def async_resume_queue(
call: ServiceCall, coordinator: SabnzbdUpdateCoordinator
) -> None:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "resume_action_deprecated",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ breaks_in_ha_version="2025.6",
+ translation_key="resume_action_deprecated",
+ )
await coordinator.sab_api.resume_queue()
@extract_api
async def async_set_queue_speed(
call: ServiceCall, coordinator: SabnzbdUpdateCoordinator
) -> None:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "set_speed_action_deprecated",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ breaks_in_ha_version="2025.6",
+ translation_key="set_speed_action_deprecated",
+ )
speed = call.data.get(ATTR_SPEED)
await coordinator.sab_api.set_speed_limit(speed)
@@ -234,11 +157,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: SabnzbdConfigEntry) -> bool:
"""Unload a Sabnzbd config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
loaded_entries = [
entry
diff --git a/homeassistant/components/sabnzbd/binary_sensor.py b/homeassistant/components/sabnzbd/binary_sensor.py
new file mode 100644
index 00000000000..1d65bf01211
--- /dev/null
+++ b/homeassistant/components/sabnzbd/binary_sensor.py
@@ -0,0 +1,61 @@
+"""Binary sensor platform for SABnzbd."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .coordinator import SabnzbdConfigEntry
+from .entity import SabnzbdEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SabnzbdBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describes Sabnzbd binary sensor entity."""
+
+ is_on_fn: Callable[[dict[str, Any]], bool]
+
+
+BINARY_SENSORS: tuple[SabnzbdBinarySensorEntityDescription, ...] = (
+ SabnzbdBinarySensorEntityDescription(
+ key="warnings",
+ translation_key="warnings",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ is_on_fn=lambda data: data["have_warnings"] != "0",
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: SabnzbdConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up a Sabnzbd sensor entry."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ [SabnzbdBinarySensor(coordinator, sensor) for sensor in BINARY_SENSORS]
+ )
+
+
+class SabnzbdBinarySensor(SabnzbdEntity, BinarySensorEntity):
+ """Representation of an SABnzbd binary sensor."""
+
+ entity_description: SabnzbdBinarySensorEntityDescription
+
+ @property
+ def is_on(self) -> bool:
+ """Return latest sensor data."""
+ return self.entity_description.is_on_fn(self.coordinator.data)
diff --git a/homeassistant/components/sabnzbd/button.py b/homeassistant/components/sabnzbd/button.py
new file mode 100644
index 00000000000..1ff26b41655
--- /dev/null
+++ b/homeassistant/components/sabnzbd/button.py
@@ -0,0 +1,68 @@
+"""Button platform for the SABnzbd component."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from pysabnzbd import SabnzbdApiException
+
+from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator
+from .entity import SabnzbdEntity
+
+
+@dataclass(kw_only=True, frozen=True)
+class SabnzbdButtonEntityDescription(ButtonEntityDescription):
+ """Describes SABnzbd button entity."""
+
+ press_fn: Callable[[SabnzbdUpdateCoordinator], Any]
+
+
+BUTTON_DESCRIPTIONS: tuple[SabnzbdButtonEntityDescription, ...] = (
+ SabnzbdButtonEntityDescription(
+ key="pause",
+ translation_key="pause",
+ press_fn=lambda coordinator: coordinator.sab_api.pause_queue(),
+ ),
+ SabnzbdButtonEntityDescription(
+ key="resume",
+ translation_key="resume",
+ press_fn=lambda coordinator: coordinator.sab_api.resume_queue(),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SabnzbdConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up buttons from a config entry."""
+ coordinator = entry.runtime_data
+
+ async_add_entities(
+ SabnzbdButton(coordinator, description) for description in BUTTON_DESCRIPTIONS
+ )
+
+
+class SabnzbdButton(SabnzbdEntity, ButtonEntity):
+ """Representation of a SABnzbd button."""
+
+ entity_description: SabnzbdButtonEntityDescription
+
+ async def async_press(self) -> None:
+ """Handle the button press."""
+ try:
+ await self.entity_description.press_fn(self.coordinator)
+ except SabnzbdApiException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ ) from e
+ else:
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py
index 2637659e91a..ce9b0a13b18 100644
--- a/homeassistant/components/sabnzbd/config_flow.py
+++ b/homeassistant/components/sabnzbd/config_flow.py
@@ -6,27 +6,38 @@ import logging
from typing import Any
import voluptuous as vol
+import yarl
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import (
- CONF_API_KEY,
- CONF_HOST,
- CONF_NAME,
- CONF_PORT,
- CONF_SSL,
- CONF_URL,
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ ConfigFlow,
+ ConfigFlowResult,
)
+from homeassistant.const import CONF_API_KEY, CONF_URL
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+from homeassistant.util import slugify
-from .const import DEFAULT_NAME, DOMAIN
-from .sab import get_client
+from .const import DOMAIN
+from .helpers import get_client
_LOGGER = logging.getLogger(__name__)
USER_SCHEMA = vol.Schema(
{
- vol.Required(CONF_API_KEY): str,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
- vol.Required(CONF_URL): str,
+ vol.Required(CONF_URL): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.URL,
+ )
+ ),
+ vol.Required(CONF_API_KEY): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ )
+ ),
}
)
@@ -36,39 +47,47 @@ class SABnzbdConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
- async def _async_validate_input(self, user_input):
- """Validate the user input allows us to connect."""
- errors = {}
- sab_api = await get_client(self.hass, user_input)
- if not sab_api:
- errors["base"] = "cannot_connect"
-
- return errors
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration flow."""
+ return await self.async_step_user(user_input)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
-
errors = {}
- if user_input is not None:
- errors = await self._async_validate_input(user_input)
- if not errors:
+ if user_input is not None:
+ sab_api = await get_client(self.hass, user_input)
+ if not sab_api:
+ errors["base"] = "cannot_connect"
+ else:
+ self._async_abort_entries_match(
+ {
+ CONF_URL: user_input[CONF_URL],
+ CONF_API_KEY: user_input[CONF_API_KEY],
+ }
+ )
+
+ if self.source == SOURCE_RECONFIGURE:
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(), data_updates=user_input
+ )
+
+ parsed_url = yarl.URL(user_input[CONF_URL])
return self.async_create_entry(
- title=user_input[CONF_API_KEY][:12], data=user_input
+ title=slugify(parsed_url.host), data=user_input
)
return self.async_show_form(
step_id="user",
- data_schema=USER_SCHEMA,
+ data_schema=self.add_suggested_values_to_schema(
+ USER_SCHEMA,
+ self._get_reconfigure_entry().data
+ if self.source == SOURCE_RECONFIGURE
+ else user_input,
+ ),
errors=errors,
)
-
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import sabnzbd config from configuration.yaml."""
- protocol = "https://" if import_data[CONF_SSL] else "http://"
- import_data[CONF_URL] = (
- f"{protocol}{import_data[CONF_HOST]}:{import_data[CONF_PORT]}"
- )
- return await self.async_step_user(import_data)
diff --git a/homeassistant/components/sabnzbd/const.py b/homeassistant/components/sabnzbd/const.py
index 55346509133..991490f5716 100644
--- a/homeassistant/components/sabnzbd/const.py
+++ b/homeassistant/components/sabnzbd/const.py
@@ -7,7 +7,6 @@ ATTR_SPEED = "speed"
ATTR_API_KEY = "api_key"
DEFAULT_HOST = "localhost"
-DEFAULT_NAME = "SABnzbd"
DEFAULT_PORT = 8080
DEFAULT_SPEED_LIMIT = "100"
DEFAULT_SSL = False
diff --git a/homeassistant/components/sabnzbd/coordinator.py b/homeassistant/components/sabnzbd/coordinator.py
index 5db59bb584b..dac8d8a8e95 100644
--- a/homeassistant/components/sabnzbd/coordinator.py
+++ b/homeassistant/components/sabnzbd/coordinator.py
@@ -6,18 +6,24 @@ from typing import Any
from pysabnzbd import SabnzbdApi, SabnzbdApiException
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
+type SabnzbdConfigEntry = ConfigEntry[SabnzbdUpdateCoordinator]
+
class SabnzbdUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""The SABnzbd update coordinator."""
+ config_entry: SabnzbdConfigEntry
+
def __init__(
self,
hass: HomeAssistant,
+ config_entry: SabnzbdConfigEntry,
sab_api: SabnzbdApi,
) -> None:
"""Initialize the SABnzbd update coordinator."""
@@ -26,6 +32,7 @@ class SabnzbdUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name="SABnzbd",
update_interval=timedelta(seconds=30),
)
diff --git a/homeassistant/components/sabnzbd/entity.py b/homeassistant/components/sabnzbd/entity.py
new file mode 100644
index 00000000000..60a2eb8d251
--- /dev/null
+++ b/homeassistant/components/sabnzbd/entity.py
@@ -0,0 +1,33 @@
+"""Base entity for Sabnzbd."""
+
+from homeassistant.const import CONF_URL
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import SabnzbdUpdateCoordinator
+
+
+class SabnzbdEntity(CoordinatorEntity[SabnzbdUpdateCoordinator]):
+ """Defines a base Sabnzbd entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: SabnzbdUpdateCoordinator,
+ description: EntityDescription,
+ ) -> None:
+ """Initialize the base entity."""
+ super().__init__(coordinator)
+
+ entry_id = coordinator.config_entry.entry_id
+ self._attr_unique_id = f"{entry_id}_{description.key}"
+ self.entity_description = description
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, entry_id)},
+ sw_version=coordinator.data["version"],
+ configuration_url=coordinator.config_entry.data[CONF_URL],
+ )
diff --git a/homeassistant/components/sabnzbd/sab.py b/homeassistant/components/sabnzbd/helpers.py
similarity index 100%
rename from homeassistant/components/sabnzbd/sab.py
rename to homeassistant/components/sabnzbd/helpers.py
diff --git a/homeassistant/components/sabnzbd/icons.json b/homeassistant/components/sabnzbd/icons.json
index ca4f4d584ae..b0a72040b4b 100644
--- a/homeassistant/components/sabnzbd/icons.json
+++ b/homeassistant/components/sabnzbd/icons.json
@@ -1,4 +1,19 @@
{
+ "entity": {
+ "button": {
+ "pause": {
+ "default": "mdi:pause"
+ },
+ "resume": {
+ "default": "mdi:play"
+ }
+ },
+ "number": {
+ "speedlimit": {
+ "default": "mdi:speedometer"
+ }
+ }
+ },
"services": {
"pause": {
"service": "mdi:pause"
diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py
new file mode 100644
index 00000000000..53c8d462f11
--- /dev/null
+++ b/homeassistant/components/sabnzbd/number.py
@@ -0,0 +1,81 @@
+"""Number entities for the SABnzbd integration."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
+from pysabnzbd import SabnzbdApiException
+
+from homeassistant.components.number import (
+ NumberEntity,
+ NumberEntityDescription,
+ NumberMode,
+)
+from homeassistant.const import PERCENTAGE
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator
+from .entity import SabnzbdEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SabnzbdNumberEntityDescription(NumberEntityDescription):
+ """Class describing a SABnzbd number entities."""
+
+ set_fn: Callable[[SabnzbdUpdateCoordinator, float], Awaitable]
+
+
+NUMBER_DESCRIPTIONS: tuple[SabnzbdNumberEntityDescription, ...] = (
+ SabnzbdNumberEntityDescription(
+ key="speedlimit",
+ translation_key="speedlimit",
+ mode=NumberMode.BOX,
+ native_max_value=100,
+ native_min_value=0,
+ native_step=1,
+ native_unit_of_measurement=PERCENTAGE,
+ set_fn=lambda coordinator, speed: (
+ coordinator.sab_api.set_speed_limit(int(speed))
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: SabnzbdConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the SABnzbd number entity."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ SabnzbdNumber(coordinator, description) for description in NUMBER_DESCRIPTIONS
+ )
+
+
+class SabnzbdNumber(SabnzbdEntity, NumberEntity):
+ """Representation of a SABnzbd number."""
+
+ entity_description: SabnzbdNumberEntityDescription
+
+ @property
+ def native_value(self) -> float:
+ """Return latest value for number."""
+ return self.coordinator.data[self.entity_description.key]
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the new number value."""
+ try:
+ await self.entity_description.set_fn(self.coordinator, value)
+ except SabnzbdApiException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ ) from e
+ else:
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py
index d956d06f1ac..662ae739d15 100644
--- a/homeassistant/components/sabnzbd/sensor.py
+++ b/homeassistant/components/sabnzbd/sensor.py
@@ -10,16 +10,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfDataRate, UnitOfInformation
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import DOMAIN, SabnzbdUpdateCoordinator
-from .const import DEFAULT_NAME
+from .coordinator import SabnzbdConfigEntry
+from .entity import SabnzbdEntity
@dataclass(frozen=True, kw_only=True)
@@ -114,59 +111,22 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = (
),
)
-OLD_SENSOR_KEYS = [
- "current_status",
- "speed",
- "queue_size",
- "queue_remaining",
- "disk_size",
- "disk_free",
- "queue_count",
- "day_size",
- "week_size",
- "month_size",
- "total_size",
-]
-
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SabnzbdConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a Sabnzbd sensor entry."""
+ coordinator = config_entry.runtime_data
- entry_id = config_entry.entry_id
- coordinator: SabnzbdUpdateCoordinator = hass.data[DOMAIN][entry_id]
-
- async_add_entities(
- [SabnzbdSensor(coordinator, sensor, entry_id) for sensor in SENSOR_TYPES]
- )
+ async_add_entities([SabnzbdSensor(coordinator, sensor) for sensor in SENSOR_TYPES])
-class SabnzbdSensor(CoordinatorEntity[SabnzbdUpdateCoordinator], SensorEntity):
+class SabnzbdSensor(SabnzbdEntity, SensorEntity):
"""Representation of an SABnzbd sensor."""
entity_description: SabnzbdSensorEntityDescription
- _attr_should_poll = False
- _attr_has_entity_name = True
-
- def __init__(
- self,
- coordinator: SabnzbdUpdateCoordinator,
- description: SabnzbdSensorEntityDescription,
- entry_id,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator)
-
- self._attr_unique_id = f"{entry_id}_{description.key}"
- self.entity_description = description
- self._attr_device_info = DeviceInfo(
- entry_type=DeviceEntryType.SERVICE,
- identifiers={(DOMAIN, entry_id)},
- name=DEFAULT_NAME,
- )
@property
def native_value(self) -> StateType:
diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json
index 5b7312e3b0d..0ac8b93c57f 100644
--- a/homeassistant/components/sabnzbd/strings.json
+++ b/homeassistant/components/sabnzbd/strings.json
@@ -4,20 +4,42 @@
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
- "name": "[%key:common::config_flow::data::name%]",
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
- "url": "The full URL, including port, of the SABnzbd server. Example: `http://localhost:8080` or `http://a02368d7-sabnzbd:8080`"
+ "url": "The full URL, including port, of the SABnzbd server. Example: `http://localhost:8080` or `http://a02368d7-sabnzbd:8080`, if you are using the add-on.",
+ "api_key": "The API key of the SABnzbd server. This can be found in the SABnzbd web interface under Config cog (top right) > General > Security."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"entity": {
+ "binary_sensor": {
+ "warnings": {
+ "name": "Warnings"
+ }
+ },
+ "button": {
+ "pause": {
+ "name": "[%key:common::action::pause%]"
+ },
+ "resume": {
+ "name": "[%key:component::sabnzbd::services::resume::name%]"
+ }
+ },
+ "number": {
+ "speedlimit": {
+ "name": "Speedlimit"
+ }
+ },
"sensor": {
"status": {
"name": "Status"
@@ -89,5 +111,24 @@
}
}
}
+ },
+ "issues": {
+ "pause_action_deprecated": {
+ "title": "SABnzbd pause action deprecated",
+ "description": "The 'Pause' action is deprecated and will be removed in a future version. Please use the 'Pause' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant."
+ },
+ "resume_action_deprecated": {
+ "title": "SABnzbd resume action deprecated",
+ "description": "The 'Resume' action is deprecated and will be removed in a future version. Please use the 'Resume' button instead. To remove this issue, please adjust automations accordingly and restart Home Assistant."
+ },
+ "set_speed_action_deprecated": {
+ "title": "SABnzbd set_speed action deprecated",
+ "description": "The 'Set speed' action is deprecated and will be removed in a future version. Please use the 'Speedlimit' number entity instead. To remove this issue, please adjust automations accordingly and restart Home Assistant."
+ }
+ },
+ "exceptions": {
+ "service_call_exception": {
+ "message": "Unable to send command to SABnzbd due to a connection error, try again later"
+ }
}
}
diff --git a/homeassistant/components/saj/manifest.json b/homeassistant/components/saj/manifest.json
index e882c9f0d02..2a4243f7489 100644
--- a/homeassistant/components/saj/manifest.json
+++ b/homeassistant/components/saj/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/saj",
"iot_class": "local_polling",
"loggers": ["pysaj"],
+ "quality_scale": "legacy",
"requirements": ["pysaj==0.0.16"]
}
diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json
index bc4ba900028..041e9b8fe9b 100644
--- a/homeassistant/components/samsungtv/manifest.json
+++ b/homeassistant/components/samsungtv/manifest.json
@@ -37,7 +37,7 @@
"requirements": [
"getmac==0.9.4",
"samsungctl[websocket]==0.7.1",
- "samsungtvws[async,encrypted]==2.6.0",
+ "samsungtvws[async,encrypted]==2.7.1",
"wakeonlan==2.1.0",
"async-upnp-client==0.41.0"
],
diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json
index 828261aa466..a90ea1db5a5 100644
--- a/homeassistant/components/satel_integra/manifest.json
+++ b/homeassistant/components/satel_integra/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/satel_integra",
"iot_class": "local_push",
"loggers": ["satel_integra"],
+ "quality_scale": "legacy",
"requirements": ["satel-integra==0.3.7"]
}
diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py
index e9fb24f1309..6eae69d9542 100644
--- a/homeassistant/components/schlage/__init__.py
+++ b/homeassistant/components/schlage/__init__.py
@@ -10,7 +10,6 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from .const import DOMAIN
from .coordinator import SchlageDataUpdateCoordinator
PLATFORMS: list[Platform] = [
@@ -21,8 +20,10 @@ PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
+type SchlageConfigEntry = ConfigEntry[SchlageDataUpdateCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+
+async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> bool:
"""Set up Schlage from a config entry."""
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
@@ -32,15 +33,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryAuthFailed from ex
coordinator = SchlageDataUpdateCoordinator(hass, username, pyschlage.Schlage(auth))
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py
index bc1ee666f9e..f928d42b3ee 100644
--- a/homeassistant/components/schlage/binary_sensor.py
+++ b/homeassistant/components/schlage/binary_sensor.py
@@ -10,12 +10,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import SchlageConfigEntry
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -40,11 +39,11 @@ _DESCRIPTIONS: tuple[SchlageBinarySensorEntityDescription] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SchlageConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary_sensors based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py
index f359f7dda71..6e8f94473dd 100644
--- a/homeassistant/components/schlage/config_flow.py
+++ b/homeassistant/components/schlage/config_flow.py
@@ -40,6 +40,7 @@ class SchlageConfigFlow(ConfigFlow, domain=DOMAIN):
return self._show_user_form(errors)
await self.async_set_unique_id(user_id)
+ self._abort_if_unique_id_configured()
return self.async_create_entry(
title=username,
data={
diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py
index 53bb43751a9..b319b21be0c 100644
--- a/homeassistant/components/schlage/coordinator.py
+++ b/homeassistant/components/schlage/coordinator.py
@@ -44,6 +44,7 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]):
super().__init__(
hass, LOGGER, name=f"{DOMAIN} ({username})", update_interval=UPDATE_INTERVAL
)
+ self.data = SchlageData(locks={})
self.api = api
self.new_locks_callbacks: list[Callable[[dict[str, LockData]], None]] = []
self.async_add_listener(self._add_remove_locks)
@@ -55,7 +56,9 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]):
except NotAuthorizedError as ex:
raise ConfigEntryAuthFailed from ex
except SchlageError as ex:
- raise UpdateFailed("Failed to refresh Schlage data") from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="schlage_refresh_failed"
+ ) from ex
lock_data = await asyncio.gather(
*(
self.hass.async_add_executor_job(self._get_lock_data, lock)
@@ -83,9 +86,6 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]):
@callback
def _add_remove_locks(self) -> None:
"""Add newly discovered locks and remove nonexistent locks."""
- if self.data is None:
- return
-
device_registry = dr.async_get(self.hass)
devices = dr.async_entries_for_config_entry(
device_registry, self.config_entry.entry_id
diff --git a/homeassistant/components/schlage/diagnostics.py b/homeassistant/components/schlage/diagnostics.py
index af1bf311676..ec4d9c489e3 100644
--- a/homeassistant/components/schlage/diagnostics.py
+++ b/homeassistant/components/schlage/diagnostics.py
@@ -4,19 +4,17 @@ from __future__ import annotations
from typing import Any
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import SchlageDataUpdateCoordinator
+from . import SchlageConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SchlageConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
# NOTE: Schlage diagnostics are already redacted.
return {
"locks": [ld.lock.get_diagnostics() for ld in coordinator.data.locks.values()]
diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py
index 97dbfc78d41..d203913191d 100644
--- a/homeassistant/components/schlage/lock.py
+++ b/homeassistant/components/schlage/lock.py
@@ -5,22 +5,21 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.lock import LockEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import SchlageConfigEntry
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SchlageConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Schlage WiFi locks based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json
index 5619cf7b312..61cc2a3c63d 100644
--- a/homeassistant/components/schlage/manifest.json
+++ b/homeassistant/components/schlage/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/schlage",
"iot_class": "cloud_polling",
- "requirements": ["pyschlage==2024.8.0"]
+ "requirements": ["pyschlage==2024.11.0"]
}
diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py
index 6d93eccaa85..6cf0853835f 100644
--- a/homeassistant/components/schlage/select.py
+++ b/homeassistant/components/schlage/select.py
@@ -3,12 +3,11 @@
from __future__ import annotations
from homeassistant.components.select import SelectEntity, SelectEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
+from . import SchlageConfigEntry
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -33,11 +32,11 @@ _DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: SchlageConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up selects based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py
index 115412882a2..a15d1740b91 100644
--- a/homeassistant/components/schlage/sensor.py
+++ b/homeassistant/components/schlage/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -34,7 +33,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json
index 5c8cd0826a9..56e72c2d2c0 100644
--- a/homeassistant/components/schlage/strings.json
+++ b/homeassistant/components/schlage/strings.json
@@ -53,5 +53,10 @@
"name": "1-Touch Locking"
}
}
+ },
+ "exceptions": {
+ "schlage_refresh_failed": {
+ "message": "Failed to refresh Schlage data"
+ }
}
}
diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py
index aaed57fc741..39fe6dbbc99 100644
--- a/homeassistant/components/schlage/switch.py
+++ b/homeassistant/components/schlage/switch.py
@@ -19,7 +19,6 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import DOMAIN
from .coordinator import LockData, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -61,7 +60,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches based on a config entry."""
- coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
def _add_new_locks(locks: dict[str, LockData]) -> None:
async_add_entities(
diff --git a/homeassistant/components/schluter/manifest.json b/homeassistant/components/schluter/manifest.json
index e96058cc146..0302ce09440 100644
--- a/homeassistant/components/schluter/manifest.json
+++ b/homeassistant/components/schluter/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/schluter",
"iot_class": "cloud_polling",
"loggers": ["schluter"],
+ "quality_scale": "legacy",
"requirements": ["py-schluter==0.1.7"]
}
diff --git a/homeassistant/components/scsgate/manifest.json b/homeassistant/components/scsgate/manifest.json
index 3f20762cf73..a3b08f86719 100644
--- a/homeassistant/components/scsgate/manifest.json
+++ b/homeassistant/components/scsgate/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/scsgate",
"iot_class": "local_polling",
"loggers": ["scsgate"],
+ "quality_scale": "legacy",
"requirements": ["scsgate==0.1.0"]
}
diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json
index c38952e1a04..ec89ae0a363 100644
--- a/homeassistant/components/sendgrid/manifest.json
+++ b/homeassistant/components/sendgrid/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sendgrid",
"iot_class": "cloud_push",
"loggers": ["sendgrid"],
+ "quality_scale": "legacy",
"requirements": ["sendgrid==6.8.2"]
}
diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py
index b2b6ac15958..15ef3def1f5 100644
--- a/homeassistant/components/sensibo/__init__.py
+++ b/homeassistant/components/sensibo/__init__.py
@@ -21,7 +21,7 @@ type SensiboConfigEntry = ConfigEntry[SensiboDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool:
"""Set up Sensibo from a config entry."""
- coordinator = SensiboDataUpdateCoordinator(hass)
+ coordinator = SensiboDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py
index d654a7cb072..cfd40195de3 100644
--- a/homeassistant/components/sensibo/coordinator.py
+++ b/homeassistant/components/sensibo/coordinator.py
@@ -29,11 +29,12 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
config_entry: SensiboConfigEntry
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: SensiboConfigEntry) -> None:
"""Initialize the Sensibo coordinator."""
super().__init__(
hass,
LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
# We don't want an immediate refresh since the device
diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json
index 610695aaf7b..e6398c5076e 100644
--- a/homeassistant/components/sensibo/manifest.json
+++ b/homeassistant/components/sensibo/manifest.json
@@ -14,6 +14,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["pysensibo"],
- "quality_scale": "platinum",
"requirements": ["pysensibo==1.1.0"]
}
diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py
index 31626b0b761..2933d779b4b 100644
--- a/homeassistant/components/sensor/__init__.py
+++ b/homeassistant/components/sensor/__init__.py
@@ -8,7 +8,6 @@ from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal, InvalidOperation as DecimalInvalidOperation
-from functools import partial
import logging
from math import ceil, floor, isfinite, log10
from typing import Any, Final, Self, cast, final, override
@@ -17,34 +16,6 @@ from propcache import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( # noqa: F401
- _DEPRECATED_DEVICE_CLASS_AQI,
- _DEPRECATED_DEVICE_CLASS_BATTERY,
- _DEPRECATED_DEVICE_CLASS_CO,
- _DEPRECATED_DEVICE_CLASS_CO2,
- _DEPRECATED_DEVICE_CLASS_CURRENT,
- _DEPRECATED_DEVICE_CLASS_DATE,
- _DEPRECATED_DEVICE_CLASS_ENERGY,
- _DEPRECATED_DEVICE_CLASS_FREQUENCY,
- _DEPRECATED_DEVICE_CLASS_GAS,
- _DEPRECATED_DEVICE_CLASS_HUMIDITY,
- _DEPRECATED_DEVICE_CLASS_ILLUMINANCE,
- _DEPRECATED_DEVICE_CLASS_MONETARY,
- _DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE,
- _DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE,
- _DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE,
- _DEPRECATED_DEVICE_CLASS_OZONE,
- _DEPRECATED_DEVICE_CLASS_PM1,
- _DEPRECATED_DEVICE_CLASS_PM10,
- _DEPRECATED_DEVICE_CLASS_PM25,
- _DEPRECATED_DEVICE_CLASS_POWER,
- _DEPRECATED_DEVICE_CLASS_POWER_FACTOR,
- _DEPRECATED_DEVICE_CLASS_PRESSURE,
- _DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH,
- _DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE,
- _DEPRECATED_DEVICE_CLASS_TEMPERATURE,
- _DEPRECATED_DEVICE_CLASS_TIMESTAMP,
- _DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
- _DEPRECATED_DEVICE_CLASS_VOLTAGE,
ATTR_UNIT_OF_MEASUREMENT,
CONF_UNIT_OF_MEASUREMENT,
EntityCategory,
@@ -53,11 +24,6 @@ from homeassistant.const import ( # noqa: F401
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.deprecation import (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
@@ -68,9 +34,6 @@ from homeassistant.util.enum import try_parse_enum
from homeassistant.util.hass_dict import HassKey
from .const import ( # noqa: F401
- _DEPRECATED_STATE_CLASS_MEASUREMENT,
- _DEPRECATED_STATE_CLASS_TOTAL,
- _DEPRECATED_STATE_CLASS_TOTAL_INCREASING,
ATTR_LAST_RESET,
ATTR_OPTIONS,
ATTR_STATE_CLASS,
@@ -531,7 +494,20 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
):
return self.hass.config.units.temperature_unit
- # Fourth priority: Native unit
+ # Fourth priority: Unit translation
+ if (translation_key := self._unit_of_measurement_translation_key) and (
+ unit_of_measurement
+ := self.platform.default_language_platform_translations.get(translation_key)
+ ):
+ if native_unit_of_measurement is not None:
+ raise ValueError(
+ f"Sensor {type(self)} from integration '{self.platform.platform_name}' "
+ f"has a translation key for unit_of_measurement '{unit_of_measurement}', "
+ f"but also has a native_unit_of_measurement '{native_unit_of_measurement}'"
+ )
+ return unit_of_measurement
+
+ # Lowest priority: Native unit
return native_unit_of_measurement
@final
@@ -966,13 +942,3 @@ def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> st
value = f"{numerical_value:z.{precision}f}"
return value
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py
index f4573f873a2..4d0454cbff3 100644
--- a/homeassistant/components/sensor/const.py
+++ b/homeassistant/components/sensor/const.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from enum import StrEnum
-from functools import partial
from typing import Final
import voluptuous as vol
@@ -17,6 +16,7 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfApparentPower,
+ UnitOfArea,
UnitOfBloodGlucoseConcentration,
UnitOfConductivity,
UnitOfDataRate,
@@ -40,13 +40,8 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
UnitOfVolumetricFlux,
)
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.util.unit_conversion import (
+ AreaConverter,
BaseUnitConverter,
BloodGlucoseConcentrationConverter,
ConductivityConverter,
@@ -117,6 +112,12 @@ class SensorDeviceClass(StrEnum):
Unit of measurement: `None`
"""
+ AREA = "area"
+ """Area
+
+ Unit of measurement: `UnitOfArea` units
+ """
+
ATMOSPHERIC_PRESSURE = "atmospheric_pressure"
"""Atmospheric pressure.
@@ -391,7 +392,7 @@ class SensorDeviceClass(StrEnum):
VOLTAGE = "voltage"
"""Voltage.
- Unit of measurement: `V`, `mV`
+ Unit of measurement: `V`, `mV`, `µV`
"""
VOLUME = "volume"
@@ -419,7 +420,7 @@ class SensorDeviceClass(StrEnum):
"""Generic flow rate
Unit of measurement: UnitOfVolumeFlowRate
- - SI / metric: `m³/h`, `L/min`
+ - SI / metric: `m³/h`, `L/min`, `mL/s`
- USCS / imperial: `ft³/min`, `gal/min`
"""
@@ -486,20 +487,10 @@ class SensorStateClass(StrEnum):
STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass))
-# STATE_CLASS* is deprecated as of 2021.12
-# use the SensorStateClass enum instead.
-_DEPRECATED_STATE_CLASS_MEASUREMENT: Final = DeprecatedConstantEnum(
- SensorStateClass.MEASUREMENT, "2025.1"
-)
-_DEPRECATED_STATE_CLASS_TOTAL: Final = DeprecatedConstantEnum(
- SensorStateClass.TOTAL, "2025.1"
-)
-_DEPRECATED_STATE_CLASS_TOTAL_INCREASING: Final = DeprecatedConstantEnum(
- SensorStateClass.TOTAL_INCREASING, "2025.1"
-)
STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass]
UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = {
+ SensorDeviceClass.AREA: AreaConverter,
SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter,
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter,
SensorDeviceClass.CONDUCTIVITY: ConductivityConverter,
@@ -531,6 +522,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] =
DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower),
SensorDeviceClass.AQI: {None},
+ SensorDeviceClass.AREA: set(UnitOfArea),
SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure),
SensorDeviceClass.BATTERY: {PERCENTAGE},
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration),
@@ -607,6 +599,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT},
+ SensorDeviceClass.AREA: set(SensorStateClass),
SensorDeviceClass.ATMOSPHERIC_PRESSURE: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.BATTERY: {SensorStateClass.MEASUREMENT},
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: {SensorStateClass.MEASUREMENT},
@@ -672,10 +665,3 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
},
SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT},
}
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py
index 56ecb36adb3..fc25dce18fc 100644
--- a/homeassistant/components/sensor/device_condition.py
+++ b/homeassistant/components/sensor/device_condition.py
@@ -35,6 +35,7 @@ DEVICE_CLASS_NONE = "none"
CONF_IS_APPARENT_POWER = "is_apparent_power"
CONF_IS_AQI = "is_aqi"
+CONF_IS_AREA = "is_area"
CONF_IS_ATMOSPHERIC_PRESSURE = "is_atmospheric_pressure"
CONF_IS_BATTERY_LEVEL = "is_battery_level"
CONF_IS_BLOOD_GLUCOSE_CONCENTRATION = "is_blood_glucose_concentration"
@@ -86,6 +87,7 @@ CONF_IS_WIND_SPEED = "is_wind_speed"
ENTITY_CONDITIONS = {
SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}],
SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}],
+ SensorDeviceClass.AREA: [{CONF_TYPE: CONF_IS_AREA}],
SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_IS_ATMOSPHERIC_PRESSURE}],
SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}],
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [
@@ -153,6 +155,7 @@ CONDITION_SCHEMA = vol.All(
[
CONF_IS_APPARENT_POWER,
CONF_IS_AQI,
+ CONF_IS_AREA,
CONF_IS_ATMOSPHERIC_PRESSURE,
CONF_IS_BATTERY_LEVEL,
CONF_IS_BLOOD_GLUCOSE_CONCENTRATION,
diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py
index ffee10d9f40..d75b3aa6e41 100644
--- a/homeassistant/components/sensor/device_trigger.py
+++ b/homeassistant/components/sensor/device_trigger.py
@@ -34,6 +34,7 @@ DEVICE_CLASS_NONE = "none"
CONF_APPARENT_POWER = "apparent_power"
CONF_AQI = "aqi"
+CONF_AREA = "area"
CONF_ATMOSPHERIC_PRESSURE = "atmospheric_pressure"
CONF_BATTERY_LEVEL = "battery_level"
CONF_BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration"
@@ -85,6 +86,7 @@ CONF_WIND_SPEED = "wind_speed"
ENTITY_TRIGGERS = {
SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}],
SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}],
+ SensorDeviceClass.AREA: [{CONF_TYPE: CONF_AREA}],
SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_ATMOSPHERIC_PRESSURE}],
SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}],
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [
@@ -153,6 +155,7 @@ TRIGGER_SCHEMA = vol.All(
[
CONF_APPARENT_POWER,
CONF_AQI,
+ CONF_AREA,
CONF_ATMOSPHERIC_PRESSURE,
CONF_BATTERY_LEVEL,
CONF_BLOOD_GLUCOSE_CONCENTRATION,
diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json
index ea4c902e665..5f770765ee3 100644
--- a/homeassistant/components/sensor/icons.json
+++ b/homeassistant/components/sensor/icons.json
@@ -9,6 +9,9 @@
"aqi": {
"default": "mdi:air-filter"
},
+ "area": {
+ "default": "mdi:texture-box"
+ },
"atmospheric_pressure": {
"default": "mdi:thermometer-lines"
},
diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json
index 6d529e72c3b..0bc370398b5 100644
--- a/homeassistant/components/sensor/strings.json
+++ b/homeassistant/components/sensor/strings.json
@@ -4,6 +4,7 @@
"condition_type": {
"is_apparent_power": "Current {entity_name} apparent power",
"is_aqi": "Current {entity_name} air quality index",
+ "is_area": "Current {entity_name} area",
"is_atmospheric_pressure": "Current {entity_name} atmospheric pressure",
"is_battery_level": "Current {entity_name} battery level",
"is_blood_glucose_concentration": "Current {entity_name} blood glucose concentration",
@@ -55,6 +56,7 @@
"trigger_type": {
"apparent_power": "{entity_name} apparent power changes",
"aqi": "{entity_name} air quality index changes",
+ "area": "{entity_name} area changes",
"atmospheric_pressure": "{entity_name} atmospheric pressure changes",
"battery_level": "{entity_name} battery level changes",
"blood_glucose_concentration": "{entity_name} blood glucose concentration changes",
@@ -145,6 +147,9 @@
"aqi": {
"name": "Air quality index"
},
+ "area": {
+ "name": "Area"
+ },
"atmospheric_pressure": {
"name": "Atmospheric pressure"
},
diff --git a/homeassistant/components/serial_pm/manifest.json b/homeassistant/components/serial_pm/manifest.json
index 9b61cb3d20b..25b3e61f93d 100644
--- a/homeassistant/components/serial_pm/manifest.json
+++ b/homeassistant/components/serial_pm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/serial_pm",
"iot_class": "local_polling",
"loggers": ["pmsensor"],
+ "quality_scale": "legacy",
"requirements": ["pmsensor==0.4"]
}
diff --git a/homeassistant/components/sesame/manifest.json b/homeassistant/components/sesame/manifest.json
index d2204629cde..7ed370db082 100644
--- a/homeassistant/components/sesame/manifest.json
+++ b/homeassistant/components/sesame/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sesame",
"iot_class": "cloud_polling",
"loggers": ["pysesame2"],
+ "quality_scale": "legacy",
"requirements": ["pysesame2==1.0.1"]
}
diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json
index af00a1fdfed..bf98140a4d6 100644
--- a/homeassistant/components/seven_segments/manifest.json
+++ b/homeassistant/components/seven_segments/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/seven_segments",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["Pillow==11.0.0"]
}
diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py
index 8f0547980c3..997d229e6b9 100644
--- a/homeassistant/components/sharkiq/vacuum.py
+++ b/homeassistant/components/sharkiq/vacuum.py
@@ -150,12 +150,6 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
return None
return self.sharkiq.error_text
- @property
- def operating_mode(self) -> str | None:
- """Operating mode."""
- op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE)
- return OPERATING_STATE_MAP.get(op_mode)
-
@property
def recharging_to_resume(self) -> int | None:
"""Return True if vacuum set to recharge and resume cleaning."""
@@ -171,7 +165,8 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum
"""
if self.sharkiq.get_property_value(Properties.CHARGING_STATUS):
return STATE_DOCKED
- return self.operating_mode
+ op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE)
+ return OPERATING_STATE_MAP.get(op_mode)
@property
def available(self) -> bool:
diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json
index 38437fb2137..3489a2d06d9 100644
--- a/homeassistant/components/shelly/manifest.json
+++ b/homeassistant/components/shelly/manifest.json
@@ -8,8 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioshelly"],
- "quality_scale": "platinum",
- "requirements": ["aioshelly==12.0.1"],
+ "requirements": ["aioshelly==12.1.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/shodan/manifest.json b/homeassistant/components/shodan/manifest.json
index 9155311a2ad..afd75e3fed5 100644
--- a/homeassistant/components/shodan/manifest.json
+++ b/homeassistant/components/shodan/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/shodan",
"iot_class": "cloud_polling",
"loggers": ["shodan"],
+ "quality_scale": "legacy",
"requirements": ["shodan==1.28.0"]
}
diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py
index 20d3078228c..531bbf37980 100644
--- a/homeassistant/components/shopping_list/__init__.py
+++ b/homeassistant/components/shopping_list/__init__.py
@@ -320,15 +320,15 @@ class ShoppingData:
# Remove the item from mapping after it's appended in the result array.
del all_items_mapping[item_id]
# Append the rest of the items
- for key in all_items_mapping:
+ for value in all_items_mapping.values():
# All the unchecked items must be passed in the item_ids array,
# so all items left in the mapping should be checked items.
- if all_items_mapping[key]["complete"] is False:
+ if value["complete"] is False:
raise vol.Invalid(
"The item ids array doesn't contain all the unchecked shopping list"
" items."
)
- new_items.append(all_items_mapping[key])
+ new_items.append(value)
self.items = new_items
self.hass.async_add_executor_job(self.save)
self._async_notify()
diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json
index c184a1d2227..8618d9241b4 100644
--- a/homeassistant/components/shopping_list/strings.json
+++ b/homeassistant/components/shopping_list/strings.json
@@ -62,7 +62,7 @@
},
"clear_completed_items": {
"name": "Clear completed items",
- "description": "Clears completed items from the shopping list."
+ "description": "Removes completed items from the shopping list."
},
"sort": {
"name": "Sort all items",
diff --git a/homeassistant/components/sigfox/manifest.json b/homeassistant/components/sigfox/manifest.json
index 3b581e4a081..f3f44bf8979 100644
--- a/homeassistant/components/sigfox/manifest.json
+++ b/homeassistant/components/sigfox/manifest.json
@@ -3,5 +3,6 @@
"name": "Sigfox",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/sigfox",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json
index 7d08367cf7d..1efd572425b 100644
--- a/homeassistant/components/sighthound/manifest.json
+++ b/homeassistant/components/sighthound/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sighthound",
"iot_class": "cloud_polling",
"loggers": ["simplehound"],
+ "quality_scale": "legacy",
"requirements": ["Pillow==11.0.0", "simplehound==0.3"]
}
diff --git a/homeassistant/components/signal_messenger/manifest.json b/homeassistant/components/signal_messenger/manifest.json
index 217109bfa2c..5ff63052691 100644
--- a/homeassistant/components/signal_messenger/manifest.json
+++ b/homeassistant/components/signal_messenger/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/signal_messenger",
"iot_class": "cloud_push",
"loggers": ["pysignalclirestapi"],
+ "quality_scale": "legacy",
"requirements": ["pysignalclirestapi==0.3.24"]
}
diff --git a/homeassistant/components/sinch/manifest.json b/homeassistant/components/sinch/manifest.json
index 21a80f63b1f..4af90b759ee 100644
--- a/homeassistant/components/sinch/manifest.json
+++ b/homeassistant/components/sinch/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sinch",
"iot_class": "cloud_push",
"loggers": ["clx"],
+ "quality_scale": "legacy",
"requirements": ["clx-sdk-xms==1.0.0"]
}
diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py
index 91456d6fa3b..8fab0dfe96d 100644
--- a/homeassistant/components/siren/__init__.py
+++ b/homeassistant/components/siren/__init__.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from datetime import timedelta
-from functools import partial
import logging
from typing import Any, TypedDict, cast, final
@@ -14,22 +13,12 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.util.hass_dict import HassKey
-from .const import ( # noqa: F401
- _DEPRECATED_SUPPORT_DURATION,
- _DEPRECATED_SUPPORT_TONES,
- _DEPRECATED_SUPPORT_TURN_OFF,
- _DEPRECATED_SUPPORT_TURN_ON,
- _DEPRECATED_SUPPORT_VOLUME_SET,
+from .const import (
ATTR_AVAILABLE_TONES,
ATTR_DURATION,
ATTR_TONE,
@@ -208,13 +197,3 @@ class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self._report_deprecated_supported_features_values(new_features)
return new_features
return features
-
-
-# As we import deprecated constants from the const module, we need to add these two functions
-# otherwise this module will be logged for using deprecated constants and not the custom component
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py
index 9e46d8dc997..26a158bd8ea 100644
--- a/homeassistant/components/siren/const.py
+++ b/homeassistant/components/siren/const.py
@@ -1,16 +1,8 @@
"""Constants for the siren component."""
from enum import IntFlag
-from functools import partial
from typing import Final
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
-
DOMAIN: Final = "siren"
ATTR_TONE: Final = "tone"
@@ -28,29 +20,3 @@ class SirenEntityFeature(IntFlag):
TONES = 4
VOLUME_SET = 8
DURATION = 16
-
-
-# These constants are deprecated as of Home Assistant 2022.5
-# Please use the SirenEntityFeature enum instead.
-_DEPRECATED_SUPPORT_TURN_ON: Final = DeprecatedConstantEnum(
- SirenEntityFeature.TURN_ON, "2025.1"
-)
-_DEPRECATED_SUPPORT_TURN_OFF: Final = DeprecatedConstantEnum(
- SirenEntityFeature.TURN_OFF, "2025.1"
-)
-_DEPRECATED_SUPPORT_TONES: Final = DeprecatedConstantEnum(
- SirenEntityFeature.TONES, "2025.1"
-)
-_DEPRECATED_SUPPORT_VOLUME_SET: Final = DeprecatedConstantEnum(
- SirenEntityFeature.VOLUME_SET, "2025.1"
-)
-_DEPRECATED_SUPPORT_DURATION: Final = DeprecatedConstantEnum(
- SirenEntityFeature.DURATION, "2025.1"
-)
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/sisyphus/manifest.json b/homeassistant/components/sisyphus/manifest.json
index 4e344c0b25e..f62d19b77c1 100644
--- a/homeassistant/components/sisyphus/manifest.json
+++ b/homeassistant/components/sisyphus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sisyphus",
"iot_class": "local_push",
"loggers": ["sisyphus_control"],
+ "quality_scale": "legacy",
"requirements": ["sisyphus-control==3.1.4"]
}
diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json
index 541cc6e0b03..1030da4d0ff 100644
--- a/homeassistant/components/sky_hub/manifest.json
+++ b/homeassistant/components/sky_hub/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sky_hub",
"iot_class": "local_polling",
"loggers": ["pyskyqhub"],
+ "quality_scale": "legacy",
"requirements": ["pyskyqhub==0.1.4"]
}
diff --git a/homeassistant/components/skybeacon/manifest.json b/homeassistant/components/skybeacon/manifest.json
index deda02f64f7..379f10e8873 100644
--- a/homeassistant/components/skybeacon/manifest.json
+++ b/homeassistant/components/skybeacon/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/skybeacon",
"iot_class": "local_polling",
"loggers": ["pygatt"],
+ "quality_scale": "legacy",
"requirements": ["pygatt[GATTTOOL]==4.0.5"]
}
diff --git a/homeassistant/components/slide/manifest.json b/homeassistant/components/slide/manifest.json
index 111bc9bd7a9..2b56185efa1 100644
--- a/homeassistant/components/slide/manifest.json
+++ b/homeassistant/components/slide/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/slide",
"iot_class": "cloud_polling",
"loggers": ["goslideapi"],
+ "quality_scale": "legacy",
"requirements": ["goslide-api==0.7.0"]
}
diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py
index b73d3b43764..8bd0421d2bc 100644
--- a/homeassistant/components/smartthings/sensor.py
+++ b/homeassistant/components/smartthings/sensor.py
@@ -15,11 +15,11 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- AREA_SQUARE_METERS,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
EntityCategory,
+ UnitOfArea,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfMass,
@@ -95,7 +95,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
Map(
Attribute.bmi_measurement,
"Body Mass Index",
- f"{UnitOfMass.KILOGRAMS}/{AREA_SQUARE_METERS}",
+ f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}",
None,
SensorStateClass.MEASUREMENT,
None,
diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json
index 432f6338d9f..d5102f14437 100644
--- a/homeassistant/components/smarttub/manifest.json
+++ b/homeassistant/components/smarttub/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smarttub",
"iot_class": "cloud_polling",
"loggers": ["smarttub"],
- "quality_scale": "platinum",
"requirements": ["python-smarttub==0.0.38"]
}
diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json
index c1eca45871b..cb791ac111b 100644
--- a/homeassistant/components/smlight/manifest.json
+++ b/homeassistant/components/smlight/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["pysmlight==0.1.3"],
+ "requirements": ["pysmlight==0.1.4"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
diff --git a/homeassistant/components/smtp/manifest.json b/homeassistant/components/smtp/manifest.json
index 0e0bba707ac..66954eebccc 100644
--- a/homeassistant/components/smtp/manifest.json
+++ b/homeassistant/components/smtp/manifest.json
@@ -3,5 +3,6 @@
"name": "SMTP",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/smtp",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/snips/manifest.json b/homeassistant/components/snips/manifest.json
index 16620eb4bfb..ec768b2b3d4 100644
--- a/homeassistant/components/snips/manifest.json
+++ b/homeassistant/components/snips/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/snips",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json
index 0b8863c8e58..a2a4405a1b5 100644
--- a/homeassistant/components/snmp/manifest.json
+++ b/homeassistant/components/snmp/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/snmp",
"iot_class": "local_polling",
"loggers": ["pyasn1", "pysmi", "pysnmp"],
+ "quality_scale": "legacy",
"requirements": ["pysnmp==6.2.6"]
}
diff --git a/homeassistant/components/solaredge_local/manifest.json b/homeassistant/components/solaredge_local/manifest.json
index d65aa06ea0a..61c08b3b152 100644
--- a/homeassistant/components/solaredge_local/manifest.json
+++ b/homeassistant/components/solaredge_local/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/solaredge_local",
"iot_class": "local_polling",
"loggers": ["solaredge_local"],
+ "quality_scale": "legacy",
"requirements": ["solaredge-local==0.2.3"]
}
diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py
index a61f825aa5e..767079ea1f8 100644
--- a/homeassistant/components/solarlog/config_flow.py
+++ b/homeassistant/components/solarlog/config_flow.py
@@ -1,7 +1,6 @@
"""Config flow for solarlog integration."""
from collections.abc import Mapping
-import logging
from typing import Any
from urllib.parse import ParseResult, urlparse
@@ -14,12 +13,9 @@ from solarlog_cli.solarlog_exceptions import (
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD
-from homeassistant.util import slugify
+from homeassistant.const import CONF_HOST, CONF_PASSWORD
-from .const import CONF_HAS_PWD, DEFAULT_HOST, DEFAULT_NAME, DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
+from .const import CONF_HAS_PWD, DEFAULT_HOST, DOMAIN
class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -84,24 +80,21 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
- user_input[CONF_NAME] = slugify(user_input[CONF_NAME])
-
if await self._test_connection(user_input[CONF_HOST]):
if user_input[CONF_HAS_PWD]:
self._user_input = user_input
return await self.async_step_password()
return self.async_create_entry(
- title=user_input[CONF_NAME], data=user_input
+ title=user_input[CONF_HOST], data=user_input
)
else:
- user_input = {CONF_NAME: DEFAULT_NAME, CONF_HOST: DEFAULT_HOST}
+ user_input = {CONF_HOST: DEFAULT_HOST}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
- vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str,
vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str,
vol.Required(CONF_HAS_PWD, default=False): bool,
}
@@ -120,7 +113,7 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
):
self._user_input |= user_input
return self.async_create_entry(
- title=self._user_input[CONF_NAME], data=self._user_input
+ title=self._user_input[CONF_HOST], data=self._user_input
)
else:
user_input = {CONF_PASSWORD: ""}
diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py
index f86d103f830..3e814705589 100644
--- a/homeassistant/components/solarlog/const.py
+++ b/homeassistant/components/solarlog/const.py
@@ -6,6 +6,5 @@ DOMAIN = "solarlog"
# Default config for solarlog.
DEFAULT_HOST = "http://solar-log"
-DEFAULT_NAME = "solarlog"
CONF_HAS_PWD = "has_password"
diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py
index 5fdf89c9e74..11f268db32a 100644
--- a/homeassistant/components/solarlog/coordinator.py
+++ b/homeassistant/components/solarlog/coordinator.py
@@ -19,6 +19,7 @@ from solarlog_cli.solarlog_models import SolarlogData
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
@@ -51,13 +52,13 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]):
path = url.path if url.netloc else ""
url = ParseResult("http", netloc, path, *url[3:])
self.unique_id = entry.entry_id
- self.name = entry.title
self.host = url.geturl()
self.solarlog = SolarLogConnector(
self.host,
tz=hass.config.time_zone,
password=password,
+ session=async_get_clientsession(hass),
)
async def _async_setup(self) -> None:
@@ -81,15 +82,27 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]):
await self.solarlog.update_device_list()
data.inverter_data = await self.solarlog.update_inverter_data()
except SolarLogConnectionError as ex:
- raise ConfigEntryNotReady(ex) from ex
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from ex
except SolarLogAuthenticationError as ex:
if await self.renew_authentication():
# login was successful, update availability of extended data, retry data update
await self.solarlog.test_extended_data_available()
- raise ConfigEntryNotReady from ex
- raise ConfigEntryAuthFailed from ex
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from ex
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from ex
except SolarLogUpdateError as ex:
- raise UpdateFailed(ex) from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ ) from ex
_LOGGER.debug("Data successfully updated")
@@ -148,9 +161,15 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]):
try:
logged_in = await self.solarlog.login()
except SolarLogAuthenticationError as ex:
- raise ConfigEntryAuthFailed from ex
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_failed",
+ ) from ex
except (SolarLogConnectionError, SolarLogUpdateError) as ex:
- raise ConfigEntryNotReady from ex
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from ex
_LOGGER.debug("Credentials successfully updated? %s", logged_in)
diff --git a/homeassistant/components/solarlog/entity.py b/homeassistant/components/solarlog/entity.py
index b0f3ddf99f9..bfdc52dccf1 100644
--- a/homeassistant/components/solarlog/entity.py
+++ b/homeassistant/components/solarlog/entity.py
@@ -43,7 +43,7 @@ class SolarLogCoordinatorEntity(SolarLogBaseEntity):
manufacturer="Solar-Log",
model="Controller",
identifiers={(DOMAIN, coordinator.unique_id)},
- name=coordinator.name,
+ name="SolarLog",
configuration_url=coordinator.host,
)
diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json
index 9f80b749d08..486b30edfd3 100644
--- a/homeassistant/components/solarlog/manifest.json
+++ b/homeassistant/components/solarlog/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/solarlog",
"iot_class": "local_polling",
"loggers": ["solarlog_cli"],
- "requirements": ["solarlog_cli==0.3.2"]
+ "quality_scale": "platinum",
+ "requirements": ["solarlog_cli==0.4.0"]
}
diff --git a/homeassistant/components/solarlog/quality_scale.yaml b/homeassistant/components/solarlog/quality_scale.yaml
new file mode 100644
index 00000000000..543889ee18c
--- /dev/null
+++ b/homeassistant/components/solarlog/quality_scale.yaml
@@ -0,0 +1,81 @@
+rules:
+ # Bronze
+ config-flow: done
+ test-before-configure: done
+ unique-config-entry: done
+ config-flow-test-coverage: done
+ runtime-data: done
+ test-before-setup: done
+ appropriate-polling: done
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup:
+ status: exempt
+ comment: No explicit event subscriptions.
+ dependency-transparency: done
+ action-setup:
+ status: exempt
+ comment: No custom action.
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-actions:
+ status: exempt
+ comment: No custom action.
+ brands: done
+
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: exempt
+ comment: No custom action.
+ reauthentication-flow: done
+ parallel-updates:
+ status: exempt
+ comment: Coordinator and sensor only platform.
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options flow.
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery:
+ status: exempt
+ comment: Solar-Log device cannot be discovered.
+ stale-devices: done
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices: done
+ discovery-update-info:
+ status: exempt
+ comment: Solar-Log device cannot be discovered.
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ docs-use-cases: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting:
+ status: exempt
+ comment: |
+ This integration doesn't have known issues that could be resolved by the user.
+ docs-examples: done
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json
index 723af6cb277..bbd9b509ecf 100644
--- a/homeassistant/components/solarlog/strings.json
+++ b/homeassistant/components/solarlog/strings.json
@@ -5,7 +5,6 @@
"title": "Define your Solar-Log connection",
"data": {
"host": "[%key:common::config_flow::data::host%]",
- "name": "The prefix to be used for your Solar-Log sensors",
"has_password": "I have the password for the Solar-Log user account."
},
"data_description": {
@@ -121,5 +120,16 @@
"name": "Usage"
}
}
+ },
+ "exceptions": {
+ "update_error": {
+ "message": "Error while updating data from the API."
+ },
+ "config_entry_not_ready": {
+ "message": "Error while loading the config entry."
+ },
+ "auth_failed": {
+ "message": "Error while logging in to the API."
+ }
}
}
diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json
index 2ca246a4e77..631ace3792f 100644
--- a/homeassistant/components/solax/manifest.json
+++ b/homeassistant/components/solax/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/solax",
"iot_class": "local_polling",
"loggers": ["solax"],
- "requirements": ["solax==3.1.1"]
+ "requirements": ["solax==3.2.1"]
}
diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json
index bfc2b6f787f..c81dc9c3972 100644
--- a/homeassistant/components/sonarr/manifest.json
+++ b/homeassistant/components/sonarr/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sonarr",
"iot_class": "local_polling",
"loggers": ["aiopyarr"],
- "quality_scale": "silver",
"requirements": ["aiopyarr==23.4.0"]
}
diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json
index c4dec6b938d..a04bea0c48d 100644
--- a/homeassistant/components/songpal/manifest.json
+++ b/homeassistant/components/songpal/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/songpal",
"iot_class": "local_push",
"loggers": ["songpal"],
- "quality_scale": "gold",
"requirements": ["python-songpal==0.16.2"],
"ssdp": [
{
diff --git a/homeassistant/components/sony_projector/manifest.json b/homeassistant/components/sony_projector/manifest.json
index 5cf5df4c96f..f674f6fa56b 100644
--- a/homeassistant/components/sony_projector/manifest.json
+++ b/homeassistant/components/sony_projector/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/sony_projector",
"iot_class": "local_polling",
"loggers": ["pysdcp"],
+ "quality_scale": "legacy",
"requirements": ["pySDCP==1"]
}
diff --git a/homeassistant/components/spaceapi/manifest.json b/homeassistant/components/spaceapi/manifest.json
index 84add9bb4ed..798930bbef5 100644
--- a/homeassistant/components/spaceapi/manifest.json
+++ b/homeassistant/components/spaceapi/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@fabaff"],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/spaceapi",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/spc/manifest.json b/homeassistant/components/spc/manifest.json
index a707e1a7804..b3c37ce2e2b 100644
--- a/homeassistant/components/spc/manifest.json
+++ b/homeassistant/components/spc/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/spc",
"iot_class": "local_push",
"loggers": ["pyspcwebgw"],
+ "quality_scale": "legacy",
"requirements": ["pyspcwebgw==0.7.0"]
}
diff --git a/homeassistant/components/splunk/manifest.json b/homeassistant/components/splunk/manifest.json
index 947af317b35..4b287c8950c 100644
--- a/homeassistant/components/splunk/manifest.json
+++ b/homeassistant/components/splunk/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/splunk",
"iot_class": "local_push",
"loggers": ["hass_splunk"],
+ "quality_scale": "legacy",
"requirements": ["hass-splunk==0.1.1"]
}
diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py
index cfcc9011b37..37580ac432d 100644
--- a/homeassistant/components/spotify/__init__.py
+++ b/homeassistant/components/spotify/__init__.py
@@ -29,7 +29,7 @@ from .util import (
spotify_uri_from_media_browser_url,
)
-PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR]
+PLATFORMS = [Platform.MEDIA_PLAYER]
__all__ = [
"async_browse_media",
diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py
index 403ec608a7c..81cdfdfb3cf 100644
--- a/homeassistant/components/spotify/browse_media.py
+++ b/homeassistant/components/spotify/browse_media.py
@@ -14,6 +14,7 @@ from spotifyaio import (
SpotifyClient,
Track,
)
+from spotifyaio.models import ItemType, SimplifiedEpisode
import yarl
from homeassistant.components.media_player import (
@@ -90,6 +91,16 @@ def _get_track_item_payload(
}
+def _get_episode_item_payload(episode: SimplifiedEpisode) -> ItemPayload:
+ return {
+ "id": episode.episode_id,
+ "name": episode.name,
+ "type": MediaType.EPISODE,
+ "uri": episode.uri,
+ "thumbnail": fetch_image_url(episode.images),
+ }
+
+
class BrowsableMedia(StrEnum):
"""Enum of browsable media."""
@@ -101,8 +112,6 @@ class BrowsableMedia(StrEnum):
CURRENT_USER_RECENTLY_PLAYED = "current_user_recently_played"
CURRENT_USER_TOP_ARTISTS = "current_user_top_artists"
CURRENT_USER_TOP_TRACKS = "current_user_top_tracks"
- CATEGORIES = "categories"
- FEATURED_PLAYLISTS = "featured_playlists"
NEW_RELEASES = "new_releases"
@@ -115,8 +124,6 @@ LIBRARY_MAP = {
BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value: "Recently played",
BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value: "Top Artists",
BrowsableMedia.CURRENT_USER_TOP_TRACKS.value: "Top Tracks",
- BrowsableMedia.CATEGORIES.value: "Categories",
- BrowsableMedia.FEATURED_PLAYLISTS.value: "Featured Playlists",
BrowsableMedia.NEW_RELEASES.value: "New Releases",
}
@@ -153,18 +160,6 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str, Any] = {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.TRACK,
},
- BrowsableMedia.FEATURED_PLAYLISTS.value: {
- "parent": MediaClass.DIRECTORY,
- "children": MediaClass.PLAYLIST,
- },
- BrowsableMedia.CATEGORIES.value: {
- "parent": MediaClass.DIRECTORY,
- "children": MediaClass.GENRE,
- },
- "category_playlists": {
- "parent": MediaClass.DIRECTORY,
- "children": MediaClass.PLAYLIST,
- },
BrowsableMedia.NEW_RELEASES.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.ALBUM,
@@ -354,32 +349,6 @@ async def build_item_response( # noqa: C901
elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS:
if top_tracks := await spotify.get_top_tracks():
items = [_get_track_item_payload(track) for track in top_tracks]
- elif media_content_type == BrowsableMedia.FEATURED_PLAYLISTS:
- if featured_playlists := await spotify.get_featured_playlists():
- items = [
- _get_playlist_item_payload(playlist) for playlist in featured_playlists
- ]
- elif media_content_type == BrowsableMedia.CATEGORIES:
- if categories := await spotify.get_categories():
- items = [
- {
- "id": category.category_id,
- "name": category.name,
- "type": "category_playlists",
- "uri": category.category_id,
- "thumbnail": category.icons[0].url if category.icons else None,
- }
- for category in categories
- ]
- elif media_content_type == "category_playlists":
- if (
- playlists := await spotify.get_category_playlists(
- category_id=media_content_id
- )
- ) and (category := await spotify.get_category(media_content_id)):
- title = category.name
- image = category.icons[0].url if category.icons else None
- items = [_get_playlist_item_payload(playlist) for playlist in playlists]
elif media_content_type == BrowsableMedia.NEW_RELEASES:
if new_releases := await spotify.get_new_releases():
items = [_get_album_item_payload(album) for album in new_releases]
@@ -387,10 +356,15 @@ async def build_item_response( # noqa: C901
if playlist := await spotify.get_playlist(media_content_id):
title = playlist.name
image = playlist.images[0].url if playlist.images else None
- items = [
- _get_track_item_payload(playlist_track.track)
- for playlist_track in playlist.tracks.items
- ]
+ for playlist_item in playlist.tracks.items:
+ if playlist_item.track.type is ItemType.TRACK:
+ if TYPE_CHECKING:
+ assert isinstance(playlist_item.track, Track)
+ items.append(_get_track_item_payload(playlist_item.track))
+ elif playlist_item.track.type is ItemType.EPISODE:
+ if TYPE_CHECKING:
+ assert isinstance(playlist_item.track, SimplifiedEpisode)
+ items.append(_get_episode_item_payload(playlist_item.track))
elif media_content_type == MediaType.ALBUM:
if album := await spotify.get_album(media_content_id):
title = album.name
@@ -412,16 +386,7 @@ async def build_item_response( # noqa: C901
):
title = show.name
image = show.images[0].url if show.images else None
- items = [
- {
- "id": episode.episode_id,
- "name": episode.name,
- "type": MediaType.EPISODE,
- "uri": episode.uri,
- "thumbnail": fetch_image_url(episode.images),
- }
- for episode in show_episodes
- ]
+ items = [_get_episode_item_payload(episode) for episode in show_episodes]
try:
media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type]
@@ -429,36 +394,6 @@ async def build_item_response( # noqa: C901
_LOGGER.debug("Unknown media type received: %s", media_content_type)
return None
- if media_content_type == BrowsableMedia.CATEGORIES:
- media_item = BrowseMedia(
- can_expand=True,
- can_play=False,
- children_media_class=media_class["children"],
- media_class=media_class["parent"],
- media_content_id=media_content_id,
- media_content_type=f"{MEDIA_PLAYER_PREFIX}{media_content_type}",
- title=LIBRARY_MAP.get(media_content_id, "Unknown"),
- )
-
- media_item.children = []
- for item in items:
- if (item_id := item["id"]) is None:
- _LOGGER.debug("Missing ID for media item: %s", item)
- continue
- media_item.children.append(
- BrowseMedia(
- can_expand=True,
- can_play=False,
- children_media_class=MediaClass.TRACK,
- media_class=MediaClass.PLAYLIST,
- media_content_id=item_id,
- media_content_type=f"{MEDIA_PLAYER_PREFIX}category_playlists",
- thumbnail=item["thumbnail"],
- title=item["name"],
- )
- )
- return media_item
-
if title is None:
title = LIBRARY_MAP.get(media_content_id, "Unknown")
diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py
index 9e62d5f137e..099b1cb3ca8 100644
--- a/homeassistant/components/spotify/coordinator.py
+++ b/homeassistant/components/spotify/coordinator.py
@@ -7,14 +7,13 @@ from typing import TYPE_CHECKING
from spotifyaio import (
ContextType,
- ItemType,
PlaybackState,
Playlist,
SpotifyClient,
SpotifyConnectionError,
+ SpotifyNotFoundError,
UserProfile,
)
-from spotifyaio.models import AudioFeatures
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -39,7 +38,6 @@ class SpotifyCoordinatorData:
current_playback: PlaybackState | None
position_updated_at: datetime | None
playlist: Playlist | None
- audio_features: AudioFeatures | None
dj_playlist: bool = False
@@ -65,7 +63,7 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
)
self.client = client
self._playlist: Playlist | None = None
- self._currently_loaded_track: str | None = None
+ self._checked_playlist_id: str | None = None
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -84,39 +82,36 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
current_playback=None,
position_updated_at=None,
playlist=None,
- audio_features=None,
)
# Record the last updated time, because Spotify's timestamp property is unreliable
# and doesn't actually return the fetch time as is mentioned in the API description
position_updated_at = dt_util.utcnow()
- audio_features: AudioFeatures | None = None
- if (item := current.item) is not None and item.type == ItemType.TRACK:
- if item.uri != self._currently_loaded_track:
- try:
- audio_features = await self.client.get_audio_features(item.uri)
- except SpotifyConnectionError:
- _LOGGER.debug(
- "Unable to load audio features for track '%s'. "
- "Continuing without audio features",
- item.uri,
- )
- audio_features = None
- else:
- self._currently_loaded_track = item.uri
- else:
- audio_features = self.data.audio_features
dj_playlist = False
if (context := current.context) is not None:
- if self._playlist is None or self._playlist.uri != context.uri:
+ dj_playlist = context.uri == SPOTIFY_DJ_PLAYLIST_URI
+ if not (
+ context.uri
+ in (
+ self._checked_playlist_id,
+ SPOTIFY_DJ_PLAYLIST_URI,
+ )
+ or (self._playlist is None and context.uri == self._checked_playlist_id)
+ ):
+ self._checked_playlist_id = context.uri
self._playlist = None
- if context.uri == SPOTIFY_DJ_PLAYLIST_URI:
- dj_playlist = True
- elif context.context_type == ContextType.PLAYLIST:
+ if context.context_type == ContextType.PLAYLIST:
# Make sure any playlist lookups don't break the current
# playback state update
try:
self._playlist = await self.client.get_playlist(context.uri)
+ except SpotifyNotFoundError:
+ _LOGGER.debug(
+ "Spotify playlist '%s' not found. "
+ "Most likely a Spotify-created playlist",
+ context.uri,
+ )
+ self._playlist = None
except SpotifyConnectionError:
_LOGGER.debug(
"Unable to load spotify playlist '%s'. "
@@ -124,10 +119,10 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
context.uri,
)
self._playlist = None
+ self._checked_playlist_id = None
return SpotifyCoordinatorData(
current_playback=current,
position_updated_at=position_updated_at,
playlist=self._playlist,
- audio_features=audio_features,
dj_playlist=dj_playlist,
)
diff --git a/homeassistant/components/spotify/icons.json b/homeassistant/components/spotify/icons.json
index e1b08127e43..00c63141eae 100644
--- a/homeassistant/components/spotify/icons.json
+++ b/homeassistant/components/spotify/icons.json
@@ -4,41 +4,6 @@
"spotify": {
"default": "mdi:spotify"
}
- },
- "sensor": {
- "song_tempo": {
- "default": "mdi:metronome"
- },
- "danceability": {
- "default": "mdi:dance-ballroom"
- },
- "energy": {
- "default": "mdi:lightning-bolt"
- },
- "mode": {
- "default": "mdi:music"
- },
- "speechiness": {
- "default": "mdi:speaker-message"
- },
- "acousticness": {
- "default": "mdi:guitar-acoustic"
- },
- "instrumentalness": {
- "default": "mdi:guitar-electric"
- },
- "valence": {
- "default": "mdi:emoticon-happy"
- },
- "liveness": {
- "default": "mdi:music-note"
- },
- "time_signature": {
- "default": "mdi:music-clef-treble"
- },
- "key": {
- "default": "mdi:music-clef-treble"
- }
}
}
}
diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json
index 8f8f7e0d588..27b8da7cecf 100644
--- a/homeassistant/components/spotify/manifest.json
+++ b/homeassistant/components/spotify/manifest.json
@@ -7,8 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/spotify",
"integration_type": "service",
"iot_class": "cloud_polling",
- "loggers": ["spotipy"],
- "quality_scale": "silver",
- "requirements": ["spotifyaio==0.8.8"],
+ "loggers": ["spotifyaio"],
+ "requirements": ["spotifyaio==0.8.11"],
"zeroconf": ["_spotify-connect._tcp.local."]
}
diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py
index 7687936fe4c..20a634efb42 100644
--- a/homeassistant/components/spotify/media_player.py
+++ b/homeassistant/components/spotify/media_player.py
@@ -361,6 +361,8 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity):
"""Select playback device."""
for device in self.devices.data:
if device.name == source:
+ if TYPE_CHECKING:
+ assert device.device_id is not None
await self.coordinator.client.transfer_playback(device.device_id)
return
diff --git a/homeassistant/components/spotify/sensor.py b/homeassistant/components/spotify/sensor.py
deleted file mode 100644
index 3486a911b0d..00000000000
--- a/homeassistant/components/spotify/sensor.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""Sensor platform for Spotify."""
-
-from collections.abc import Callable
-from dataclasses import dataclass
-
-from spotifyaio.models import AudioFeatures, Key
-
-from homeassistant.components.sensor import (
- SensorDeviceClass,
- SensorEntity,
- SensorEntityDescription,
-)
-from homeassistant.const import PERCENTAGE
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-
-from .coordinator import SpotifyConfigEntry, SpotifyCoordinator
-from .entity import SpotifyEntity
-
-
-@dataclass(frozen=True, kw_only=True)
-class SpotifyAudioFeaturesSensorEntityDescription(SensorEntityDescription):
- """Describes Spotify sensor entity."""
-
- value_fn: Callable[[AudioFeatures], float | str | None]
-
-
-KEYS: dict[Key, str] = {
- Key.C: "C",
- Key.C_SHARP_D_FLAT: "C♯/D♭",
- Key.D: "D",
- Key.D_SHARP_E_FLAT: "D♯/E♭",
- Key.E: "E",
- Key.F: "F",
- Key.F_SHARP_G_FLAT: "F♯/G♭",
- Key.G: "G",
- Key.G_SHARP_A_FLAT: "G♯/A♭",
- Key.A: "A",
- Key.A_SHARP_B_FLAT: "A♯/B♭",
- Key.B: "B",
-}
-
-KEY_OPTIONS = list(KEYS.values())
-
-
-def _get_key(audio_features: AudioFeatures) -> str | None:
- if audio_features.key is None:
- return None
- return KEYS[audio_features.key]
-
-
-AUDIO_FEATURE_SENSORS: tuple[SpotifyAudioFeaturesSensorEntityDescription, ...] = (
- SpotifyAudioFeaturesSensorEntityDescription(
- key="bpm",
- translation_key="song_tempo",
- native_unit_of_measurement="bpm",
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.tempo,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="danceability",
- translation_key="danceability",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.danceability * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="energy",
- translation_key="energy",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.energy * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="mode",
- translation_key="mode",
- device_class=SensorDeviceClass.ENUM,
- options=["major", "minor"],
- value_fn=lambda audio_features: audio_features.mode.name.lower(),
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="speechiness",
- translation_key="speechiness",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.speechiness * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="acousticness",
- translation_key="acousticness",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.acousticness * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="instrumentalness",
- translation_key="instrumentalness",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.instrumentalness * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="liveness",
- translation_key="liveness",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.liveness * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="valence",
- translation_key="valence",
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda audio_features: audio_features.valence * 100,
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="time_signature",
- translation_key="time_signature",
- device_class=SensorDeviceClass.ENUM,
- options=["3/4", "4/4", "5/4", "6/4", "7/4"],
- value_fn=lambda audio_features: f"{audio_features.time_signature}/4",
- entity_registry_enabled_default=False,
- ),
- SpotifyAudioFeaturesSensorEntityDescription(
- key="key",
- translation_key="key",
- device_class=SensorDeviceClass.ENUM,
- options=KEY_OPTIONS,
- value_fn=_get_key,
- entity_registry_enabled_default=False,
- ),
-)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: SpotifyConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Set up Spotify sensor based on a config entry."""
- coordinator = entry.runtime_data.coordinator
-
- async_add_entities(
- SpotifyAudioFeatureSensor(coordinator, description)
- for description in AUDIO_FEATURE_SENSORS
- )
-
-
-class SpotifyAudioFeatureSensor(SpotifyEntity, SensorEntity):
- """Representation of a Spotify sensor."""
-
- entity_description: SpotifyAudioFeaturesSensorEntityDescription
-
- def __init__(
- self,
- coordinator: SpotifyCoordinator,
- entity_description: SpotifyAudioFeaturesSensorEntityDescription,
- ) -> None:
- """Initialize."""
- super().__init__(coordinator)
- self._attr_unique_id = (
- f"{coordinator.current_user.user_id}_{entity_description.key}"
- )
- self.entity_description = entity_description
-
- @property
- def native_value(self) -> float | str | None:
- """Return the state of the sensor."""
- if (audio_features := self.coordinator.data.audio_features) is None:
- return None
- return self.entity_description.value_fn(audio_features)
diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json
index faf20d740d9..90e573a1706 100644
--- a/homeassistant/components/spotify/strings.json
+++ b/homeassistant/components/spotify/strings.json
@@ -30,46 +30,5 @@
"info": {
"api_endpoint_reachable": "Spotify API endpoint reachable"
}
- },
- "entity": {
- "sensor": {
- "song_tempo": {
- "name": "Song tempo"
- },
- "danceability": {
- "name": "Song danceability"
- },
- "energy": {
- "name": "Song energy"
- },
- "mode": {
- "name": "Song mode",
- "state": {
- "minor": "Minor",
- "major": "Major"
- }
- },
- "speechiness": {
- "name": "Song speechiness"
- },
- "acousticness": {
- "name": "Song acousticness"
- },
- "instrumentalness": {
- "name": "Song instrumentalness"
- },
- "valence": {
- "name": "Song valence"
- },
- "liveness": {
- "name": "Song liveness"
- },
- "time_signature": {
- "name": "Song time signature"
- },
- "key": {
- "name": "Song key"
- }
- }
}
}
diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json
index dcb5f47829c..01c95d6c5e4 100644
--- a/homeassistant/components/sql/manifest.json
+++ b/homeassistant/components/sql/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sql",
"iot_class": "local_polling",
- "requirements": ["SQLAlchemy==2.0.31", "sqlparse==0.5.0"]
+ "requirements": ["SQLAlchemy==2.0.36", "sqlparse==0.5.0"]
}
diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py
index ff9f86ccf1f..0ca33179f9f 100644
--- a/homeassistant/components/squeezebox/sensor.py
+++ b/homeassistant/components/squeezebox/sensor.py
@@ -33,12 +33,10 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_ALBUMS,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="albums",
),
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_ARTISTS,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="artists",
),
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_DURATION,
@@ -49,12 +47,10 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_GENRES,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="genres",
),
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_SONGS,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="songs",
),
SensorEntityDescription(
key=STATUS_SENSOR_LASTSCAN,
@@ -63,13 +59,11 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=STATUS_SENSOR_PLAYER_COUNT,
state_class=SensorStateClass.TOTAL,
- native_unit_of_measurement="players",
),
SensorEntityDescription(
key=STATUS_SENSOR_OTHER_PLAYER_COUNT,
state_class=SensorStateClass.TOTAL,
entity_registry_visible_default=False,
- native_unit_of_measurement="players",
),
)
diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json
index b1b71cd8c1d..406c7243a1a 100644
--- a/homeassistant/components/squeezebox/strings.json
+++ b/homeassistant/components/squeezebox/strings.json
@@ -76,25 +76,31 @@
"name": "Last scan"
},
"info_total_albums": {
- "name": "Total albums"
+ "name": "Total albums",
+ "unit_of_measurement": "albums"
},
"info_total_artists": {
- "name": "Total artists"
+ "name": "Total artists",
+ "unit_of_measurement": "artists"
},
"info_total_duration": {
"name": "Total duration"
},
"info_total_genres": {
- "name": "Total genres"
+ "name": "Total genres",
+ "unit_of_measurement": "genres"
},
"info_total_songs": {
- "name": "Total songs"
+ "name": "Total songs",
+ "unit_of_measurement": "songs"
},
"player_count": {
- "name": "Player count"
+ "name": "Player count",
+ "unit_of_measurement": "players"
},
"other_player_count": {
- "name": "Player count off service"
+ "name": "Player count off service",
+ "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]"
}
}
}
diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py
index ea1a27adc15..6fb307cda74 100644
--- a/homeassistant/components/starline/button.py
+++ b/homeassistant/components/starline/button.py
@@ -16,6 +16,20 @@ BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = (
key="poke",
translation_key="horn",
),
+ ButtonEntityDescription(
+ key="panic",
+ translation_key="panic",
+ entity_registry_enabled_default=False,
+ ),
+ *[
+ ButtonEntityDescription(
+ key=f"flex_{i}",
+ translation_key="flex",
+ translation_placeholders={"num": str(i)},
+ entity_registry_enabled_default=False,
+ )
+ for i in range(1, 10)
+ ],
)
diff --git a/homeassistant/components/starline/icons.json b/homeassistant/components/starline/icons.json
index e240978ce74..d7d20ae03bd 100644
--- a/homeassistant/components/starline/icons.json
+++ b/homeassistant/components/starline/icons.json
@@ -20,6 +20,12 @@
"button": {
"horn": {
"default": "mdi:bullhorn-outline"
+ },
+ "flex": {
+ "default": "mdi:star-circle-outline"
+ },
+ "panic": {
+ "default": "mdi:alarm-note"
}
},
"device_tracker": {
@@ -63,9 +69,6 @@
"on": "mdi:access-point-network"
}
},
- "horn": {
- "default": "mdi:bullhorn-outline"
- },
"service_mode": {
"default": "mdi:car-wrench",
"state": {
diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json
index a330354e5a9..0a30ea5b5be 100644
--- a/homeassistant/components/starline/strings.json
+++ b/homeassistant/components/starline/strings.json
@@ -124,6 +124,12 @@
"button": {
"horn": {
"name": "Horn"
+ },
+ "flex": {
+ "name": "Flex logic {num}"
+ },
+ "panic": {
+ "name": "Panic mode"
}
}
},
diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py
index 1b48a72c732..05193d98c8a 100644
--- a/homeassistant/components/starline/switch.py
+++ b/homeassistant/components/starline/switch.py
@@ -78,8 +78,6 @@ class StarlineSwitch(StarlineEntity, SwitchEntity):
@property
def is_on(self):
"""Return True if entity is on."""
- if self._key == "poke":
- return False
return self._device.car_state.get(self._key)
def turn_on(self, **kwargs: Any) -> None:
@@ -88,6 +86,4 @@ class StarlineSwitch(StarlineEntity, SwitchEntity):
def turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
- if self._key == "poke":
- return
self._account.api.set_car_state(self._device.device_id, self._key, False)
diff --git a/homeassistant/components/starlingbank/manifest.json b/homeassistant/components/starlingbank/manifest.json
index ef9be6d6da8..f7ab72c4379 100644
--- a/homeassistant/components/starlingbank/manifest.json
+++ b/homeassistant/components/starlingbank/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/starlingbank",
"iot_class": "cloud_polling",
"loggers": ["starlingbank"],
+ "quality_scale": "legacy",
"requirements": ["starlingbank==3.2"]
}
diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json
index ab5e2345795..070cbf1b44c 100644
--- a/homeassistant/components/starlink/manifest.json
+++ b/homeassistant/components/starlink/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/starlink",
"iot_class": "local_polling",
- "quality_scale": "silver",
"requirements": ["starlink-grpc-core==1.2.0"]
}
diff --git a/homeassistant/components/startca/manifest.json b/homeassistant/components/startca/manifest.json
index 8c74a655ce3..958477c193b 100644
--- a/homeassistant/components/startca/manifest.json
+++ b/homeassistant/components/startca/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/startca",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["xmltodict==0.13.0"]
}
diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json
index 3e6fec9d986..91aead261ff 100644
--- a/homeassistant/components/statistics/strings.json
+++ b/homeassistant/components/statistics/strings.json
@@ -10,7 +10,7 @@
},
"step": {
"user": {
- "description": "Add a statistics sensor",
+ "description": "Create a statistics sensor",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"entity_id": "Entity"
diff --git a/homeassistant/components/statsd/manifest.json b/homeassistant/components/statsd/manifest.json
index 73296a23dd9..4f0ea93eb98 100644
--- a/homeassistant/components/statsd/manifest.json
+++ b/homeassistant/components/statsd/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/statsd",
"iot_class": "local_push",
"loggers": ["statsd"],
+ "quality_scale": "legacy",
"requirements": ["statsd==3.2.1"]
}
diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py
index 6e7bdf4b91c..81a3bb0d898 100644
--- a/homeassistant/components/steam_online/coordinator.py
+++ b/homeassistant/components/steam_online/coordinator.py
@@ -60,9 +60,9 @@ class SteamDataUpdateCoordinator(
for player in response["response"]["players"]["player"]
if player["steamid"] in _ids
}
- for k in players:
- data = self.player_interface.GetSteamLevel(steamid=players[k]["steamid"])
- players[k]["level"] = data["response"].get("player_level")
+ for value in players.values():
+ data = self.player_interface.GetSteamLevel(steamid=value["steamid"])
+ value["level"] = data["response"].get("player_level")
return players
async def _async_update_data(self) -> dict[str, dict[str, str | int]]:
diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json
index 6592851d641..9580cd4d4ca 100644
--- a/homeassistant/components/stiebel_eltron/manifest.json
+++ b/homeassistant/components/stiebel_eltron/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/stiebel_eltron",
"iot_class": "local_polling",
"loggers": ["pymodbus", "pystiebeleltron"],
+ "quality_scale": "legacy",
"requirements": ["pystiebeleltron==0.0.1.dev2"]
}
diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py
index a714e3bd368..d8b9561bde9 100644
--- a/homeassistant/components/stookwijzer/__init__.py
+++ b/homeassistant/components/stookwijzer/__init__.py
@@ -2,29 +2,89 @@
from __future__ import annotations
+from typing import Any
+
from stookwijzer import Stookwijzer
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_registry as er, issue_registry as ir
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN
+from .const import DOMAIN, LOGGER
+from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator
PLATFORMS = [Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool:
"""Set up Stookwijzer from a config entry."""
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = Stookwijzer(
- entry.data[CONF_LOCATION][CONF_LATITUDE],
- entry.data[CONF_LOCATION][CONF_LONGITUDE],
- )
+ await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry)
+
+ coordinator = StookwijzerCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: StookwijzerConfigEntry
+) -> bool:
"""Unload Stookwijzer config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- del hass.data[DOMAIN][entry.entry_id]
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_migrate_entry(
+ hass: HomeAssistant, entry: StookwijzerConfigEntry
+) -> bool:
+ """Migrate old entry."""
+ LOGGER.debug("Migrating from version %s", entry.version)
+
+ if entry.version == 1:
+ latitude, longitude = await Stookwijzer.async_transform_coordinates(
+ async_get_clientsession(hass),
+ entry.data[CONF_LOCATION][CONF_LATITUDE],
+ entry.data[CONF_LOCATION][CONF_LONGITUDE],
+ )
+
+ if not latitude or not longitude:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "location_migration_failed",
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="location_migration_failed",
+ translation_placeholders={
+ "entry_title": entry.title,
+ },
+ )
+ return False
+
+ hass.config_entries.async_update_entry(
+ entry,
+ version=2,
+ data={
+ CONF_LATITUDE: latitude,
+ CONF_LONGITUDE: longitude,
+ },
+ )
+
+ LOGGER.debug("Migration to version %s successful", entry.version)
+
+ return True
+
+
+@callback
+def async_migrate_entity_entry(entity_entry: er.RegistryEntry) -> dict[str, Any] | None:
+ """Migrate Stookwijzer entity entries.
+
+ - Migrates unique ID for the old Stookwijzer sensors to the new unique ID.
+ """
+ if entity_entry.unique_id == entity_entry.config_entry_id:
+ return {"new_unique_id": f"{entity_entry.config_entry_id}_advice"}
+
+ # No migration needed
+ return None
diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py
index be53ce56390..32b4836763f 100644
--- a/homeassistant/components/stookwijzer/config_flow.py
+++ b/homeassistant/components/stookwijzer/config_flow.py
@@ -4,10 +4,12 @@ from __future__ import annotations
from typing import Any
+from stookwijzer import Stookwijzer
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import LocationSelector
from .const import DOMAIN
@@ -16,21 +18,29 @@ from .const import DOMAIN
class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Stookwijzer."""
- VERSION = 1
+ VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
-
+ errors = {}
if user_input is not None:
- return self.async_create_entry(
- title="Stookwijzer",
- data=user_input,
+ latitude, longitude = await Stookwijzer.async_transform_coordinates(
+ async_get_clientsession(self.hass),
+ user_input[CONF_LOCATION][CONF_LATITUDE],
+ user_input[CONF_LOCATION][CONF_LONGITUDE],
)
+ if latitude and longitude:
+ return self.async_create_entry(
+ title="Stookwijzer",
+ data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude},
+ )
+ errors["base"] = "unknown"
return self.async_show_form(
step_id="user",
+ errors=errors,
data_schema=vol.Schema(
{
vol.Required(
diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py
index e8cb3d818e6..1b0be86d375 100644
--- a/homeassistant/components/stookwijzer/const.py
+++ b/homeassistant/components/stookwijzer/const.py
@@ -1,16 +1,7 @@
"""Constants for the Stookwijzer integration."""
-from enum import StrEnum
import logging
from typing import Final
DOMAIN: Final = "stookwijzer"
LOGGER = logging.getLogger(__package__)
-
-
-class StookwijzerState(StrEnum):
- """Stookwijzer states for sensor entity."""
-
- BLUE = "blauw"
- ORANGE = "oranje"
- RED = "rood"
diff --git a/homeassistant/components/stookwijzer/coordinator.py b/homeassistant/components/stookwijzer/coordinator.py
new file mode 100644
index 00000000000..23092bed66e
--- /dev/null
+++ b/homeassistant/components/stookwijzer/coordinator.py
@@ -0,0 +1,44 @@
+"""Class representing a Stookwijzer update coordinator."""
+
+from datetime import timedelta
+
+from stookwijzer import Stookwijzer
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN, LOGGER
+
+SCAN_INTERVAL = timedelta(minutes=60)
+
+type StookwijzerConfigEntry = ConfigEntry[StookwijzerCoordinator]
+
+
+class StookwijzerCoordinator(DataUpdateCoordinator[None]):
+ """Stookwijzer update coordinator."""
+
+ def __init__(self, hass: HomeAssistant, entry: StookwijzerConfigEntry) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+ self.client = Stookwijzer(
+ async_get_clientsession(hass),
+ entry.data[CONF_LATITUDE],
+ entry.data[CONF_LONGITUDE],
+ )
+
+ async def _async_update_data(self) -> None:
+ """Fetch data from API endpoint."""
+ await self.client.async_update()
+ if self.client.advice is None:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="no_data_received",
+ )
diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py
index c7bf4fad14d..2849e0e976a 100644
--- a/homeassistant/components/stookwijzer/diagnostics.py
+++ b/homeassistant/components/stookwijzer/diagnostics.py
@@ -4,29 +4,18 @@ from __future__ import annotations
from typing import Any
-from stookwijzer import Stookwijzer
-
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
+from .coordinator import StookwijzerConfigEntry
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: StookwijzerConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- client: Stookwijzer = hass.data[DOMAIN][entry.entry_id]
-
- last_updated = None
- if client.last_updated:
- last_updated = client.last_updated.isoformat()
-
+ client = entry.runtime_data.client
return {
- "state": client.state,
- "last_updated": last_updated,
- "lqi": client.lqi,
- "windspeed": client.windspeed,
- "weather": client.weather,
- "concentrations": client.concentrations,
+ "advice": client.advice,
+ "air_quality_index": client.lki,
+ "windspeed_ms": client.windspeed_ms,
}
diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json
index dbf902b1e1e..3fe16fb3d33 100644
--- a/homeassistant/components/stookwijzer/manifest.json
+++ b/homeassistant/components/stookwijzer/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["stookwijzer==1.3.0"]
+ "requirements": ["stookwijzer==1.5.1"]
}
diff --git a/homeassistant/components/stookwijzer/quality_scale.yaml b/homeassistant/components/stookwijzer/quality_scale.yaml
new file mode 100644
index 00000000000..67fadc00b64
--- /dev/null
+++ b/homeassistant/components/stookwijzer/quality_scale.yaml
@@ -0,0 +1,89 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration doesn't provide any additional service actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ The integration doesn't provide any additional service actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ The integration doesn't subscribe to any events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: todo
+ test-before-setup: done
+ unique-config-entry: todo
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration is read-only and doesn't provide any actions.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration is read-only and doesn't provide any actions. Querying
+ the service for data is handled centrally using a data update coordinator.
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration doesn't require re-authentication.
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ The integration cannot be discovered, as it is an external service.
+ discovery:
+ status: exempt
+ comment: |
+ The integration cannot be discovered, as it is an external service.
+ 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:
+ status: exempt
+ comment: |
+ This integration provides a single device entry for the service.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: done
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration provides a single device entry for the service.
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py
index b8f9a660598..2660ff2ddb2 100644
--- a/homeassistant/components/stookwijzer/sensor.py
+++ b/homeassistant/components/stookwijzer/sensor.py
@@ -2,65 +2,95 @@
from __future__ import annotations
-from datetime import timedelta
+from collections.abc import Callable
+from dataclasses import dataclass
from stookwijzer import Stookwijzer
-from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import UnitOfSpeed
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN, StookwijzerState
+from .const import DOMAIN
+from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator
-SCAN_INTERVAL = timedelta(minutes=60)
+
+@dataclass(kw_only=True, frozen=True)
+class StookwijzerSensorDescription(SensorEntityDescription):
+ """Class describing Stookwijzer sensor entities."""
+
+ value_fn: Callable[[Stookwijzer], int | float | str | None]
+
+
+STOOKWIJZER_SENSORS = [
+ StookwijzerSensorDescription(
+ key="windspeed",
+ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
+ suggested_unit_of_measurement=UnitOfSpeed.BEAUFORT,
+ device_class=SensorDeviceClass.WIND_SPEED,
+ suggested_display_precision=0,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda client: client.windspeed_ms,
+ ),
+ StookwijzerSensorDescription(
+ key="air_quality_index",
+ device_class=SensorDeviceClass.AQI,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda client: client.lki,
+ ),
+ StookwijzerSensorDescription(
+ key="advice",
+ translation_key="advice",
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda client: client.advice,
+ options=["code_yellow", "code_orange", "code_red"],
+ ),
+]
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: StookwijzerConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Stookwijzer sensor from a config entry."""
- client = hass.data[DOMAIN][entry.entry_id]
- async_add_entities([StookwijzerSensor(client, entry)], update_before_add=True)
+ async_add_entities(
+ StookwijzerSensor(description, entry) for description in STOOKWIJZER_SENSORS
+ )
-class StookwijzerSensor(SensorEntity):
+class StookwijzerSensor(CoordinatorEntity[StookwijzerCoordinator], SensorEntity):
"""Defines a Stookwijzer binary sensor."""
- _attr_attribution = "Data provided by stookwijzer.nu"
- _attr_device_class = SensorDeviceClass.ENUM
+ entity_description: StookwijzerSensorDescription
+ _attr_attribution = "Data provided by atlasleefomgeving.nl"
_attr_has_entity_name = True
- _attr_name = None
- _attr_translation_key = "stookwijzer"
- def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None:
+ def __init__(
+ self,
+ description: StookwijzerSensorDescription,
+ entry: StookwijzerConfigEntry,
+ ) -> None:
"""Initialize a Stookwijzer device."""
- self._client = client
- self._attr_options = [cls.value for cls in StookwijzerState]
- self._attr_unique_id = entry.entry_id
+ super().__init__(entry.runtime_data)
+ self.entity_description = description
+ self._attr_unique_id = f"{entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, f"{entry.entry_id}")},
- name="Stookwijzer",
- manufacturer="stookwijzer.nu",
+ identifiers={(DOMAIN, entry.entry_id)},
+ manufacturer="Atlas Leefomgeving",
entry_type=DeviceEntryType.SERVICE,
- configuration_url="https://www.stookwijzer.nu",
+ configuration_url="https://www.atlasleefomgeving.nl/stookwijzer",
)
- def update(self) -> None:
- """Update the data from the Stookwijzer handler."""
- self._client.update()
-
@property
- def available(self) -> bool:
- """Return if entity is available."""
- return self._client.state is not None
-
- @property
- def native_value(self) -> str | None:
+ def native_value(self) -> int | float | str | None:
"""Return the state of the device."""
- if self._client.state is None:
- return None
- return StookwijzerState(self._client.state).value
+ return self.entity_description.value_fn(self.coordinator.client)
diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json
index 549673165ec..189af89b282 100644
--- a/homeassistant/components/stookwijzer/strings.json
+++ b/homeassistant/components/stookwijzer/strings.json
@@ -5,19 +5,37 @@
"description": "Select the location you want to recieve the Stookwijzer information for.",
"data": {
"location": "[%key:common::config_flow::data::location%]"
+ },
+ "data_description": {
+ "location": "Use the map to set the location for Stookwijzer."
}
}
+ },
+ "error": {
+ "unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"entity": {
"sensor": {
- "stookwijzer": {
+ "advice": {
+ "name": "Advice code",
"state": {
- "blauw": "Blue",
- "oranje": "Orange",
- "rood": "Red"
+ "code_yellow": "Yellow",
+ "code_orange": "Orange",
+ "code_red": "Red"
}
}
}
+ },
+ "issues": {
+ "location_migration_failed": {
+ "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.",
+ "title": "Migration of your location failed"
+ }
+ },
+ "exceptions": {
+ "no_data_received": {
+ "message": "No data received from Stookwijzer."
+ }
}
}
diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json
index 5eb05b9acb7..240be0f37bd 100644
--- a/homeassistant/components/suez_water/manifest.json
+++ b/homeassistant/components/suez_water/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/suez_water",
"iot_class": "cloud_polling",
"loggers": ["pysuez", "regex"],
- "requirements": ["pysuezV2==1.3.1"]
+ "requirements": ["pysuezV2==1.3.2"]
}
diff --git a/homeassistant/components/supervisord/manifest.json b/homeassistant/components/supervisord/manifest.json
index 7586a435ed7..3cdbdd230aa 100644
--- a/homeassistant/components/supervisord/manifest.json
+++ b/homeassistant/components/supervisord/manifest.json
@@ -3,5 +3,6 @@
"name": "Supervisord",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/supervisord",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/supla/manifest.json b/homeassistant/components/supla/manifest.json
index 6927c92c6e1..803a321c0d6 100644
--- a/homeassistant/components/supla/manifest.json
+++ b/homeassistant/components/supla/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/supla",
"iot_class": "cloud_polling",
"loggers": ["asyncpysupla"],
+ "quality_scale": "legacy",
"requirements": ["asyncpysupla==0.0.5"]
}
diff --git a/homeassistant/components/swiss_hydrological_data/manifest.json b/homeassistant/components/swiss_hydrological_data/manifest.json
index 14e2882804e..11b49a42e3f 100644
--- a/homeassistant/components/swiss_hydrological_data/manifest.json
+++ b/homeassistant/components/swiss_hydrological_data/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/swiss_hydrological_data",
"iot_class": "cloud_polling",
"loggers": ["swisshydrodata"],
+ "quality_scale": "legacy",
"requirements": ["swisshydrodata==0.1.0"]
}
diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py
index bceac6007a2..628f6e95c2a 100644
--- a/homeassistant/components/swiss_public_transport/__init__.py
+++ b/homeassistant/components/swiss_public_transport/__init__.py
@@ -19,12 +19,22 @@ from homeassistant.helpers import (
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
-from .const import CONF_DESTINATION, CONF_START, CONF_VIA, DOMAIN, PLACEHOLDERS
+from .const import (
+ CONF_DESTINATION,
+ CONF_START,
+ CONF_TIME_FIXED,
+ CONF_TIME_OFFSET,
+ CONF_TIME_STATION,
+ CONF_VIA,
+ DEFAULT_TIME_STATION,
+ DOMAIN,
+ PLACEHOLDERS,
+)
from .coordinator import (
SwissPublicTransportConfigEntry,
SwissPublicTransportDataUpdateCoordinator,
)
-from .helper import unique_id_from_config
+from .helper import offset_opendata, unique_id_from_config
from .services import setup_services
_LOGGER = logging.getLogger(__name__)
@@ -50,8 +60,19 @@ async def async_setup_entry(
start = config[CONF_START]
destination = config[CONF_DESTINATION]
+ time_offset: dict[str, int] | None = config.get(CONF_TIME_OFFSET)
+
session = async_get_clientsession(hass)
- opendata = OpendataTransport(start, destination, session, via=config.get(CONF_VIA))
+ opendata = OpendataTransport(
+ start,
+ destination,
+ session,
+ via=config.get(CONF_VIA),
+ time=config.get(CONF_TIME_FIXED),
+ isArrivalTime=config.get(CONF_TIME_STATION, DEFAULT_TIME_STATION) == "arrival",
+ )
+ if time_offset:
+ offset_opendata(opendata, time_offset)
try:
await opendata.async_get_data()
@@ -75,7 +96,7 @@ async def async_setup_entry(
},
) from e
- coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata)
+ coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata, time_offset)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
@@ -96,7 +117,7 @@ async def async_migrate_entry(
"""Migrate config entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
- if config_entry.version > 2:
+ if config_entry.version > 3:
# This means the user has downgraded from a future version
return False
@@ -131,9 +152,9 @@ async def async_migrate_entry(
config_entry, unique_id=new_unique_id, minor_version=2
)
- if config_entry.version < 2:
- # Via stations now available, which are not backwards compatible if used, changes unique id
- hass.config_entries.async_update_entry(config_entry, version=2, minor_version=1)
+ if config_entry.version < 3:
+ # Via stations and time/offset settings now available, which are not backwards compatible if used, changes unique id
+ hass.config_entries.async_update_entry(config_entry, version=3, minor_version=1)
_LOGGER.debug(
"Migration to version %s.%s successful",
diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py
index 74c6223f1d9..58d674f0c26 100644
--- a/homeassistant/components/swiss_public_transport/config_flow.py
+++ b/homeassistant/components/swiss_public_transport/config_flow.py
@@ -14,15 +14,35 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
+ DurationSelector,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
+ TimeSelector,
)
-from .const import CONF_DESTINATION, CONF_START, CONF_VIA, DOMAIN, MAX_VIA, PLACEHOLDERS
-from .helper import unique_id_from_config
+from .const import (
+ CONF_DESTINATION,
+ CONF_START,
+ CONF_TIME_FIXED,
+ CONF_TIME_MODE,
+ CONF_TIME_OFFSET,
+ CONF_TIME_STATION,
+ CONF_VIA,
+ DEFAULT_TIME_MODE,
+ DEFAULT_TIME_STATION,
+ DOMAIN,
+ IS_ARRIVAL_OPTIONS,
+ MAX_VIA,
+ PLACEHOLDERS,
+ TIME_MODE_OPTIONS,
+)
+from .helper import offset_opendata, unique_id_from_config
-DATA_SCHEMA = vol.Schema(
+USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_START): cv.string,
vol.Optional(CONF_VIA): TextSelector(
@@ -32,8 +52,25 @@ DATA_SCHEMA = vol.Schema(
),
),
vol.Required(CONF_DESTINATION): cv.string,
+ vol.Optional(CONF_TIME_MODE, default=DEFAULT_TIME_MODE): SelectSelector(
+ SelectSelectorConfig(
+ options=TIME_MODE_OPTIONS,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="time_mode",
+ ),
+ ),
+ vol.Optional(CONF_TIME_STATION, default=DEFAULT_TIME_STATION): SelectSelector(
+ SelectSelectorConfig(
+ options=IS_ARRIVAL_OPTIONS,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="time_station",
+ ),
+ ),
}
)
+ADVANCED_TIME_DATA_SCHEMA = {vol.Optional(CONF_TIME_FIXED): TimeSelector()}
+ADVANCED_TIME_OFFSET_DATA_SCHEMA = {vol.Optional(CONF_TIME_OFFSET): DurationSelector()}
+
_LOGGER = logging.getLogger(__name__)
@@ -41,39 +78,33 @@ _LOGGER = logging.getLogger(__name__)
class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN):
"""Swiss public transport config flow."""
- VERSION = 2
+ VERSION = 3
MINOR_VERSION = 1
+ user_input: dict[str, Any]
+
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Async user step to set up the connection."""
errors: dict[str, str] = {}
if user_input is not None:
- unique_id = unique_id_from_config(user_input)
- await self.async_set_unique_id(unique_id)
- self._abort_if_unique_id_configured()
-
if CONF_VIA in user_input and len(user_input[CONF_VIA]) > MAX_VIA:
errors["base"] = "too_many_via_stations"
else:
- session = async_get_clientsession(self.hass)
- opendata = OpendataTransport(
- user_input[CONF_START],
- user_input[CONF_DESTINATION],
- session,
- via=user_input.get(CONF_VIA),
- )
- try:
- await opendata.async_get_data()
- except OpendataTransportConnectionError:
- errors["base"] = "cannot_connect"
- except OpendataTransportError:
- errors["base"] = "bad_config"
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception("Unknown error")
- errors["base"] = "unknown"
+ err = await self.fetch_connections(user_input)
+ if err:
+ errors["base"] = err
else:
+ self.user_input = user_input
+ if user_input[CONF_TIME_MODE] == "fixed":
+ return await self.async_step_time_fixed()
+ if user_input[CONF_TIME_MODE] == "offset":
+ return await self.async_step_time_offset()
+
+ unique_id = unique_id_from_config(user_input)
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
return self.async_create_entry(
title=unique_id,
data=user_input,
@@ -81,7 +112,85 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
- data_schema=DATA_SCHEMA,
+ data_schema=self.add_suggested_values_to_schema(
+ data_schema=USER_DATA_SCHEMA,
+ suggested_values=user_input,
+ ),
errors=errors,
description_placeholders=PLACEHOLDERS,
)
+
+ async def async_step_time_fixed(
+ self, time_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Async time step to set up the connection."""
+ return await self._async_step_time_mode(
+ CONF_TIME_FIXED, vol.Schema(ADVANCED_TIME_DATA_SCHEMA), time_input
+ )
+
+ async def async_step_time_offset(
+ self, time_offset_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Async time offset step to set up the connection."""
+ return await self._async_step_time_mode(
+ CONF_TIME_OFFSET,
+ vol.Schema(ADVANCED_TIME_OFFSET_DATA_SCHEMA),
+ time_offset_input,
+ )
+
+ async def _async_step_time_mode(
+ self,
+ step_id: str,
+ time_mode_schema: vol.Schema,
+ time_mode_input: dict[str, Any] | None = None,
+ ) -> ConfigFlowResult:
+ """Async time mode step to set up the connection."""
+ errors: dict[str, str] = {}
+ if time_mode_input is not None:
+ unique_id = unique_id_from_config({**self.user_input, **time_mode_input})
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+
+ err = await self.fetch_connections(
+ {**self.user_input, **time_mode_input},
+ time_mode_input.get(CONF_TIME_OFFSET),
+ )
+ if err:
+ errors["base"] = err
+ else:
+ return self.async_create_entry(
+ title=unique_id,
+ data={**self.user_input, **time_mode_input},
+ )
+
+ return self.async_show_form(
+ step_id=step_id,
+ data_schema=time_mode_schema,
+ errors=errors,
+ description_placeholders=PLACEHOLDERS,
+ )
+
+ async def fetch_connections(
+ self, input: dict[str, Any], time_offset: dict[str, int] | None = None
+ ) -> str | None:
+ """Fetch the connections and advancedly return an error."""
+ try:
+ session = async_get_clientsession(self.hass)
+ opendata = OpendataTransport(
+ input[CONF_START],
+ input[CONF_DESTINATION],
+ session,
+ via=input.get(CONF_VIA),
+ time=input.get(CONF_TIME_FIXED),
+ )
+ if time_offset:
+ offset_opendata(opendata, time_offset)
+ await opendata.async_get_data()
+ except OpendataTransportConnectionError:
+ return "cannot_connect"
+ except OpendataTransportError:
+ return "bad_config"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unknown error")
+ return "unknown"
+ return None
diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py
index c02f36f2f25..10bfc0d0355 100644
--- a/homeassistant/components/swiss_public_transport/const.py
+++ b/homeassistant/components/swiss_public_transport/const.py
@@ -7,13 +7,21 @@ DOMAIN = "swiss_public_transport"
CONF_DESTINATION: Final = "to"
CONF_START: Final = "from"
CONF_VIA: Final = "via"
+CONF_TIME_STATION: Final = "time_station"
+CONF_TIME_MODE: Final = "time_mode"
+CONF_TIME_FIXED: Final = "time_fixed"
+CONF_TIME_OFFSET: Final = "time_offset"
DEFAULT_NAME = "Next Destination"
DEFAULT_UPDATE_TIME = 90
+DEFAULT_TIME_STATION = "departure"
+DEFAULT_TIME_MODE = "now"
MAX_VIA = 5
CONNECTIONS_COUNT = 3
CONNECTIONS_MAX = 15
+IS_ARRIVAL_OPTIONS = ["departure", "arrival"]
+TIME_MODE_OPTIONS = ["now", "fixed", "offset"]
PLACEHOLDERS = {
diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py
index e6413e6f772..59602e7b982 100644
--- a/homeassistant/components/swiss_public_transport/coordinator.py
+++ b/homeassistant/components/swiss_public_transport/coordinator.py
@@ -19,6 +19,7 @@ import homeassistant.util.dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN
+from .helper import offset_opendata
_LOGGER = logging.getLogger(__name__)
@@ -57,7 +58,12 @@ class SwissPublicTransportDataUpdateCoordinator(
config_entry: SwissPublicTransportConfigEntry
- def __init__(self, hass: HomeAssistant, opendata: OpendataTransport) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ opendata: OpendataTransport,
+ time_offset: dict[str, int] | None,
+ ) -> None:
"""Initialize the SwissPublicTransport data coordinator."""
super().__init__(
hass,
@@ -66,6 +72,7 @@ class SwissPublicTransportDataUpdateCoordinator(
update_interval=timedelta(seconds=DEFAULT_UPDATE_TIME),
)
self._opendata = opendata
+ self._time_offset = time_offset
def remaining_time(self, departure) -> timedelta | None:
"""Calculate the remaining time for the departure."""
@@ -81,6 +88,9 @@ class SwissPublicTransportDataUpdateCoordinator(
async def fetch_connections(self, limit: int) -> list[DataConnection]:
"""Fetch connections using the opendata api."""
self._opendata.limit = limit
+ if self._time_offset:
+ offset_opendata(self._opendata, self._time_offset)
+
try:
await self._opendata.async_get_data()
except OpendataTransportConnectionError as e:
diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py
index af03f7ad193..704479b77d6 100644
--- a/homeassistant/components/swiss_public_transport/helper.py
+++ b/homeassistant/components/swiss_public_transport/helper.py
@@ -1,15 +1,59 @@
"""Helper functions for swiss_public_transport."""
+from datetime import timedelta
from types import MappingProxyType
from typing import Any
-from .const import CONF_DESTINATION, CONF_START, CONF_VIA
+from opendata_transport import OpendataTransport
+
+import homeassistant.util.dt as dt_util
+
+from .const import (
+ CONF_DESTINATION,
+ CONF_START,
+ CONF_TIME_FIXED,
+ CONF_TIME_OFFSET,
+ CONF_TIME_STATION,
+ CONF_VIA,
+ DEFAULT_TIME_STATION,
+)
+
+
+def offset_opendata(opendata: OpendataTransport, offset: dict[str, int]) -> None:
+ """In place offset the opendata connector."""
+
+ duration = timedelta(**offset)
+ if duration:
+ now_offset = dt_util.as_local(dt_util.now() + duration)
+ opendata.date = now_offset.date()
+ opendata.time = now_offset.time()
+
+
+def dict_duration_to_str_duration(
+ d: dict[str, int],
+) -> str:
+ """Build a string from a dict duration."""
+ return f"{d['hours']:02d}:{d['minutes']:02d}:{d['seconds']:02d}"
def unique_id_from_config(config: MappingProxyType[str, Any] | dict[str, Any]) -> str:
"""Build a unique id from a config entry."""
- return f"{config[CONF_START]} {config[CONF_DESTINATION]}" + (
- " via " + ", ".join(config[CONF_VIA])
- if CONF_VIA in config and len(config[CONF_VIA]) > 0
- else ""
+ return (
+ f"{config[CONF_START]} {config[CONF_DESTINATION]}"
+ + (
+ " via " + ", ".join(config[CONF_VIA])
+ if CONF_VIA in config and len(config[CONF_VIA]) > 0
+ else ""
+ )
+ + (
+ " arrival"
+ if config.get(CONF_TIME_STATION, DEFAULT_TIME_STATION) == "arrival"
+ else ""
+ )
+ + (" at " + config[CONF_TIME_FIXED] if CONF_TIME_FIXED in config else "")
+ + (
+ " in " + dict_duration_to_str_duration(config[CONF_TIME_OFFSET])
+ if CONF_TIME_OFFSET in config
+ else ""
+ )
)
diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json
index b3bfd9aea8f..91645b2fee4 100644
--- a/homeassistant/components/swiss_public_transport/strings.json
+++ b/homeassistant/components/swiss_public_transport/strings.json
@@ -17,10 +17,30 @@
"data": {
"from": "Start station",
"to": "End station",
- "via": "List of up to 5 via stations"
+ "via": "List of up to 5 via stations",
+ "time_station": "Select the relevant station",
+ "time_mode": "Select a time mode"
+ },
+ "data_description": {
+ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.",
+ "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)."
},
"description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.",
"title": "Swiss Public Transport"
+ },
+ "time_fixed": {
+ "data": {
+ "time_fixed": "Time of day"
+ },
+ "description": "Please select the relevant time for the connection (e.g. 7:12:00 AM every morning).",
+ "title": "Swiss Public Transport"
+ },
+ "time_offset": {
+ "data": {
+ "time_offset": "Time offset"
+ },
+ "description": "Please select the relevant offset to add to the earliest possible connection (e.g. add +00:05:00 offset, taking into account the time to walk to the station)",
+ "title": "Swiss Public Transport"
}
}
},
@@ -84,5 +104,20 @@
"config_entry_not_found": {
"message": "Swiss public transport integration instance \"{target}\" not found."
}
+ },
+ "selector": {
+ "time_station": {
+ "options": {
+ "departure": "Show departure time from start station",
+ "arrival": "Show arrival time at end station"
+ }
+ },
+ "time_mode": {
+ "options": {
+ "now": "Now",
+ "fixed": "At a fixed time of day",
+ "offset": "At an offset from now"
+ }
+ }
}
}
diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json
index cb0e674570e..cf1ea01ea9c 100644
--- a/homeassistant/components/swisscom/manifest.json
+++ b/homeassistant/components/swisscom/manifest.json
@@ -3,5 +3,6 @@
"name": "Swisscom Internet-Box",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/swisscom",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py
index 9838d9501f7..61ee2908009 100644
--- a/homeassistant/components/switch/__init__.py
+++ b/homeassistant/components/switch/__init__.py
@@ -4,7 +4,6 @@ from __future__ import annotations
from datetime import timedelta
from enum import StrEnum
-from functools import partial
import logging
from propcache import cached_property
@@ -19,12 +18,6 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -52,16 +45,8 @@ class SwitchDeviceClass(StrEnum):
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(SwitchDeviceClass))
-
-# DEVICE_CLASS* below are deprecated as of 2021.12
-# use the SwitchDeviceClass enum instead.
DEVICE_CLASSES = [cls.value for cls in SwitchDeviceClass]
-_DEPRECATED_DEVICE_CLASS_OUTLET = DeprecatedConstantEnum(
- SwitchDeviceClass.OUTLET, "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_SWITCH = DeprecatedConstantEnum(
- SwitchDeviceClass.SWITCH, "2025.1"
-)
+
# mypy: disallow-any-generics
@@ -124,11 +109,3 @@ class SwitchEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
if hasattr(self, "entity_description"):
return self.entity_description.device_class
return None
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py
index 19b264bd46f..b8cf4e8e1ab 100644
--- a/homeassistant/components/switchbot/const.py
+++ b/homeassistant/components/switchbot/const.py
@@ -74,8 +74,3 @@ CONF_RETRY_COUNT = "retry_count"
CONF_KEY_ID = "key_id"
CONF_ENCRYPTION_KEY = "encryption_key"
CONF_LOCK_NIGHTLATCH = "lock_force_nightlatch"
-
-# Deprecated config Entry Options to be removed in 2023.4
-CONF_TIME_BETWEEN_UPDATE_COMMAND = "update_time"
-CONF_RETRY_TIMEOUT = "retry_timeout"
-CONF_SCAN_TIMEOUT = "scan_timeout"
diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json
index 64a2ec75633..5a328650aca 100644
--- a/homeassistant/components/switchbot/manifest.json
+++ b/homeassistant/components/switchbot/manifest.json
@@ -39,5 +39,5 @@
"documentation": "https://www.home-assistant.io/integrations/switchbot",
"iot_class": "local_push",
"loggers": ["switchbot"],
- "requirements": ["PySwitchbot==0.53.2"]
+ "requirements": ["PySwitchbot==0.54.0"]
}
diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json
index bdedab03f16..987dac65077 100644
--- a/homeassistant/components/switcher_kis/manifest.json
+++ b/homeassistant/components/switcher_kis/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/switcher_kis",
"iot_class": "local_push",
"loggers": ["aioswitcher"],
- "quality_scale": "platinum",
"requirements": ["aioswitcher==5.0.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/switchmate/manifest.json b/homeassistant/components/switchmate/manifest.json
index 5467dc512c3..f21819e1bc0 100644
--- a/homeassistant/components/switchmate/manifest.json
+++ b/homeassistant/components/switchmate/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/switchmate",
"iot_class": "local_polling",
"loggers": ["switchmate"],
+ "quality_scale": "legacy",
"requirements": ["PySwitchmate==0.5.1"]
}
diff --git a/homeassistant/components/syncthing/manifest.json b/homeassistant/components/syncthing/manifest.json
index f7fd2b7ece6..612665913d0 100644
--- a/homeassistant/components/syncthing/manifest.json
+++ b/homeassistant/components/syncthing/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/syncthing",
"iot_class": "local_polling",
"loggers": ["aiosyncthing"],
- "quality_scale": "silver",
"requirements": ["aiosyncthing==0.5.1"]
}
diff --git a/homeassistant/components/synology_chat/manifest.json b/homeassistant/components/synology_chat/manifest.json
index 3ac663ff91e..c9bd3396097 100644
--- a/homeassistant/components/synology_chat/manifest.json
+++ b/homeassistant/components/synology_chat/manifest.json
@@ -3,5 +3,6 @@
"name": "Synology Chat",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/synology_chat",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/synology_srm/manifest.json b/homeassistant/components/synology_srm/manifest.json
index 9980f37969e..0d712b6742b 100644
--- a/homeassistant/components/synology_srm/manifest.json
+++ b/homeassistant/components/synology_srm/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/synology_srm",
"iot_class": "local_polling",
"loggers": ["synology_srm"],
+ "quality_scale": "legacy",
"requirements": ["synology-srm==0.2.0"]
}
diff --git a/homeassistant/components/syslog/manifest.json b/homeassistant/components/syslog/manifest.json
index 380628ffa66..bf327baec10 100644
--- a/homeassistant/components/syslog/manifest.json
+++ b/homeassistant/components/syslog/manifest.json
@@ -3,5 +3,6 @@
"name": "Syslog",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/syslog",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json
index e886bcad150..2799cf31fdd 100644
--- a/homeassistant/components/system_bridge/manifest.json
+++ b/homeassistant/components/system_bridge/manifest.json
@@ -9,7 +9,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["systembridgeconnector"],
- "quality_scale": "silver",
"requirements": ["systembridgeconnector==4.1.5", "systembridgemodels==4.2.4"],
"zeroconf": ["_system-bridge._tcp.local."]
}
diff --git a/homeassistant/components/tailscale/manifest.json b/homeassistant/components/tailscale/manifest.json
index 24f485fcdbd..7d571fe0675 100644
--- a/homeassistant/components/tailscale/manifest.json
+++ b/homeassistant/components/tailscale/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tailscale",
"integration_type": "hub",
"iot_class": "cloud_polling",
- "quality_scale": "platinum",
"requirements": ["tailscale==0.6.1"]
}
diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json
index 97d08737a87..705f591785f 100644
--- a/homeassistant/components/tailwind/manifest.json
+++ b/homeassistant/components/tailwind/manifest.json
@@ -11,7 +11,6 @@
"documentation": "https://www.home-assistant.io/integrations/tailwind",
"integration_type": "device",
"iot_class": "local_polling",
- "quality_scale": "platinum",
"requirements": ["gotailwind==0.2.4"],
"zeroconf": [
{
diff --git a/homeassistant/components/tank_utility/manifest.json b/homeassistant/components/tank_utility/manifest.json
index d73c62fa5ec..76240252696 100644
--- a/homeassistant/components/tank_utility/manifest.json
+++ b/homeassistant/components/tank_utility/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tank_utility",
"iot_class": "cloud_polling",
"loggers": ["tank_utility"],
+ "quality_scale": "legacy",
"requirements": ["tank-utility==1.5.0"]
}
diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json
index eeb8646bea7..72248d006e0 100644
--- a/homeassistant/components/tankerkoenig/manifest.json
+++ b/homeassistant/components/tankerkoenig/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tankerkoenig",
"iot_class": "cloud_polling",
"loggers": ["aiotankerkoenig"],
- "quality_scale": "platinum",
"requirements": ["aiotankerkoenig==0.4.2"]
}
diff --git a/homeassistant/components/tapsaff/manifest.json b/homeassistant/components/tapsaff/manifest.json
index 861329827d7..c4853ca1c8d 100644
--- a/homeassistant/components/tapsaff/manifest.json
+++ b/homeassistant/components/tapsaff/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tapsaff",
"iot_class": "local_polling",
"loggers": ["tapsaff"],
+ "quality_scale": "legacy",
"requirements": ["tapsaff==0.2.1"]
}
diff --git a/homeassistant/components/tcp/manifest.json b/homeassistant/components/tcp/manifest.json
index e15200f49f8..7eacff6c50a 100644
--- a/homeassistant/components/tcp/manifest.json
+++ b/homeassistant/components/tcp/manifest.json
@@ -3,5 +3,6 @@
"name": "TCP",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/tcp",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json
index ae0e491235f..722aa4004e1 100644
--- a/homeassistant/components/technove/manifest.json
+++ b/homeassistant/components/technove/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/technove",
"integration_type": "device",
"iot_class": "local_polling",
- "quality_scale": "platinum",
"requirements": ["python-technove==1.3.1"],
"zeroconf": ["_technove-stations._tcp.local."]
}
diff --git a/homeassistant/components/ted5000/manifest.json b/homeassistant/components/ted5000/manifest.json
index b2aa68f884b..3e28d963957 100644
--- a/homeassistant/components/ted5000/manifest.json
+++ b/homeassistant/components/ted5000/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ted5000",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["xmltodict==0.13.0"]
}
diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py
index 528a5052678..95348053805 100644
--- a/homeassistant/components/tedee/__init__.py
+++ b/homeassistant/components/tedee/__init__.py
@@ -16,7 +16,6 @@ from homeassistant.components.webhook import (
async_register as webhook_register,
async_unregister as webhook_unregister,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -99,7 +98,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TedeeConfigEntry) -> boo
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: TedeeConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -131,7 +130,9 @@ def get_webhook_handler(
return async_webhook_handler
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: TedeeConfigEntry
+) -> bool:
"""Migrate old entry."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py
index 445585a1a2c..4012b6d07c5 100644
--- a/homeassistant/components/tedee/coordinator.py
+++ b/homeassistant/components/tedee/coordinator.py
@@ -99,14 +99,19 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
await update_fn()
except TedeeLocalAuthException as ex:
raise ConfigEntryAuthFailed(
- "Authentication failed. Local access token is invalid"
+ translation_domain=DOMAIN,
+ translation_key="authentification_failed",
) from ex
except TedeeDataUpdateException as ex:
_LOGGER.debug("Error while updating data: %s", str(ex))
- raise UpdateFailed(f"Error while updating data: {ex!s}") from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_failed"
+ ) from ex
except (TedeeClientException, TimeoutError) as ex:
- raise UpdateFailed(f"Querying API failed. Error: {ex!s}") from ex
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="api_error"
+ ) from ex
def webhook_received(self, message: dict[str, Any]) -> None:
"""Handle webhook message."""
diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py
index 6e89a48f2a0..38df85a9cdb 100644
--- a/homeassistant/components/tedee/lock.py
+++ b/homeassistant/components/tedee/lock.py
@@ -13,6 +13,8 @@ from .const import DOMAIN
from .coordinator import TedeeApiCoordinator, TedeeConfigEntry
from .entity import TedeeEntity
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
hass: HomeAssistant,
diff --git a/homeassistant/components/tedee/quality_scale.yaml b/homeassistant/components/tedee/quality_scale.yaml
new file mode 100644
index 00000000000..974c8f82ec9
--- /dev/null
+++ b/homeassistant/components/tedee/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ No explicit event subscriptions
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ No custom actions
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ Options flow not documented, doesn't have one
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ Handled by coordinator
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No discovery
+ discovery:
+ status: exempt
+ comment: |
+ No discovery supported atm
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices: done
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: |
+ Currently no repairs/issues
+ stale-devices: done
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json
index b6966fa2933..78cacd706d3 100644
--- a/homeassistant/components/tedee/strings.json
+++ b/homeassistant/components/tedee/strings.json
@@ -66,12 +66,21 @@
}
},
"exceptions": {
+ "api_error": {
+ "message": "Error while communicating with the API"
+ },
+ "authentication_failed": {
+ "message": "Authentication failed. Local access token is invalid"
+ },
"lock_failed": {
"message": "Failed to lock the door. Lock {lock_id}"
},
"unlock_failed": {
"message": "Failed to unlock the door. Lock {lock_id}"
},
+ "update_failed": {
+ "message": "Error while updating data"
+ },
"open_failed": {
"message": "Failed to unlatch the door. Lock {lock_id}"
}
diff --git a/homeassistant/components/telegram/manifest.json b/homeassistant/components/telegram/manifest.json
index ce4457b3129..9022f357970 100644
--- a/homeassistant/components/telegram/manifest.json
+++ b/homeassistant/components/telegram/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["telegram_bot"],
"documentation": "https://www.home-assistant.io/integrations/telegram",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json
index b432c88762f..3474d39b1d6 100644
--- a/homeassistant/components/telegram_bot/manifest.json
+++ b/homeassistant/components/telegram_bot/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/telegram_bot",
"iot_class": "cloud_push",
"loggers": ["telegram"],
+ "quality_scale": "legacy",
"requirements": ["python-telegram-bot[socks]==21.5"]
}
diff --git a/homeassistant/components/tellduslive/manifest.json b/homeassistant/components/tellduslive/manifest.json
index dc1389c15c5..4ebf1a334bd 100644
--- a/homeassistant/components/tellduslive/manifest.json
+++ b/homeassistant/components/tellduslive/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tellduslive",
"iot_class": "cloud_polling",
- "quality_scale": "silver",
"requirements": ["tellduslive==0.10.12"]
}
diff --git a/homeassistant/components/tellstick/manifest.json b/homeassistant/components/tellstick/manifest.json
index c64a51b09e4..40956b06ac6 100644
--- a/homeassistant/components/tellstick/manifest.json
+++ b/homeassistant/components/tellstick/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tellstick",
"iot_class": "assumed_state",
"loggers": ["tellcore"],
+ "quality_scale": "legacy",
"requirements": ["tellcore-net==0.4", "tellcore-py==1.1.2"]
}
diff --git a/homeassistant/components/telnet/manifest.json b/homeassistant/components/telnet/manifest.json
index 48a79afc528..68353104839 100644
--- a/homeassistant/components/telnet/manifest.json
+++ b/homeassistant/components/telnet/manifest.json
@@ -3,5 +3,6 @@
"name": "Telnet",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/telnet",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/temper/manifest.json b/homeassistant/components/temper/manifest.json
index dbad8827877..ad1fcd40525 100644
--- a/homeassistant/components/temper/manifest.json
+++ b/homeassistant/components/temper/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/temper",
"iot_class": "local_polling",
"loggers": ["pyusb", "temperusb"],
+ "quality_scale": "legacy",
"requirements": ["temperusb==1.6.1"]
}
diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py
index c1c023c0ea4..8ecef8539d3 100644
--- a/homeassistant/components/template/config_flow.py
+++ b/homeassistant/components/template/config_flow.py
@@ -157,7 +157,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
type=selector.TextSelectorType.TEXT, multiline=False
)
),
- vol.Optional(CONF_SET_VALUE): selector.ActionSelector(),
+ vol.Required(CONF_SET_VALUE): selector.ActionSelector(),
}
if domain == Platform.SELECT:
diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py
index 6ea8aff4c1a..d7bb30dbba0 100644
--- a/homeassistant/components/template/lock.py
+++ b/homeassistant/components/template/lock.py
@@ -2,13 +2,14 @@
from __future__ import annotations
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.lock import (
PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA,
LockEntity,
+ LockEntityFeature,
LockState,
)
from homeassistant.const import (
@@ -36,6 +37,7 @@ from .template_entity import (
CONF_CODE_FORMAT_TEMPLATE = "code_format_template"
CONF_LOCK = "lock"
CONF_UNLOCK = "unlock"
+CONF_OPEN = "open"
DEFAULT_NAME = "Template Lock"
DEFAULT_OPTIMISTIC = False
@@ -45,6 +47,7 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend(
vol.Optional(CONF_NAME): cv.string,
vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA,
vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA,
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template,
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
@@ -53,7 +56,9 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend(
).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema)
-async def _async_create_entities(hass, config):
+async def _async_create_entities(
+ hass: HomeAssistant, config: dict[str, Any]
+) -> list[TemplateLock]:
"""Create the Template lock."""
config = rewrite_common_legacy_to_modern_conf(hass, config)
return [TemplateLock(hass, config, config.get(CONF_UNIQUE_ID))]
@@ -76,22 +81,26 @@ class TemplateLock(TemplateEntity, LockEntity):
def __init__(
self,
- hass,
- config,
- unique_id,
- ):
+ hass: HomeAssistant,
+ config: dict[str, Any],
+ unique_id: str | None,
+ ) -> None:
"""Initialize the lock."""
super().__init__(
hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id
)
- self._state = None
+ self._state: str | bool | LockState | None = None
name = self._attr_name
+ assert name
self._state_template = config.get(CONF_VALUE_TEMPLATE)
self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN)
self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN)
+ if CONF_OPEN in config:
+ self._command_open = Script(hass, config[CONF_OPEN], name, DOMAIN)
+ self._attr_supported_features |= LockEntityFeature.OPEN
self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE)
- self._code_format = None
- self._code_format_template_error = None
+ self._code_format: str | None = None
+ self._code_format_template_error: TemplateError | None = None
self._optimistic = config.get(CONF_OPTIMISTIC)
self._attr_assumed_state = bool(self._optimistic)
@@ -115,6 +124,11 @@ class TemplateLock(TemplateEntity, LockEntity):
"""Return true if lock is locking."""
return self._state == LockState.LOCKING
+ @property
+ def is_open(self) -> bool:
+ """Return true if lock is open."""
+ return self._state == LockState.OPEN
+
@callback
def _update_state(self, result):
"""Update the state from the template."""
@@ -141,6 +155,8 @@ class TemplateLock(TemplateEntity, LockEntity):
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
+ if TYPE_CHECKING:
+ assert self._state_template is not None
self.add_template_attribute(
"_state", self._state_template, None, self._update_state
)
@@ -168,6 +184,8 @@ class TemplateLock(TemplateEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
+ # Check if we need to raise for incorrect code format
+ # template before processing the action.
self._raise_template_error_if_available()
if self._optimistic:
@@ -182,6 +200,8 @@ class TemplateLock(TemplateEntity, LockEntity):
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
+ # Check if we need to raise for incorrect code format
+ # template before processing the action.
self._raise_template_error_if_available()
if self._optimistic:
@@ -194,7 +214,24 @@ class TemplateLock(TemplateEntity, LockEntity):
self._command_unlock, run_variables=tpl_vars, context=self._context
)
+ async def async_open(self, **kwargs: Any) -> None:
+ """Open the device."""
+ # Check if we need to raise for incorrect code format
+ # template before processing the action.
+ self._raise_template_error_if_available()
+
+ if self._optimistic:
+ self._state = LockState.OPEN
+ self.async_write_ha_state()
+
+ tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
+
+ await self.async_run_script(
+ self._command_open, run_variables=tpl_vars, context=self._context
+ )
+
def _raise_template_error_if_available(self):
+ """Raise an error if the rendered code format is not valid."""
if self._code_format_template_error is not None:
raise ServiceValidationError(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json
index 86fd83ad088..1ddfa188c0a 100644
--- a/homeassistant/components/tensorflow/manifest.json
+++ b/homeassistant/components/tensorflow/manifest.json
@@ -5,6 +5,7 @@
"documentation": "https://www.home-assistant.io/integrations/tensorflow",
"iot_class": "local_polling",
"loggers": ["tensorflow"],
+ "quality_scale": "legacy",
"requirements": [
"tensorflow==2.5.0",
"tf-models-official==2.5.0",
diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json
index 8d6e5f11068..f27929032d7 100644
--- a/homeassistant/components/tesla_fleet/manifest.json
+++ b/homeassistant/components/tesla_fleet/manifest.json
@@ -7,6 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "quality_scale": "gold",
"requirements": ["tesla-fleet-api==0.8.4"]
}
diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json
index 6b667094d62..fc82dea6445 100644
--- a/homeassistant/components/teslemetry/manifest.json
+++ b/homeassistant/components/teslemetry/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "quality_scale": "platinum",
"requirements": ["tesla-fleet-api==0.8.4", "teslemetry-stream==0.4.2"]
}
diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py
index 90862eff969..4731f5168a2 100644
--- a/homeassistant/components/tessie/const.py
+++ b/homeassistant/components/tessie/const.py
@@ -13,6 +13,16 @@ MODELS = {
"models": "Model S",
}
+TRANSLATED_ERRORS = {
+ "unknown": "unknown",
+ "not supported": "not_supported",
+ "cable connected": "cable_connected",
+ "already active": "already_active",
+ "already inactive": "already_inactive",
+ "incorrect pin": "incorrect_pin",
+ "no cable": "no_cable",
+}
+
class TessieState(StrEnum):
"""Tessie status."""
diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py
index 42a3c92b2be..a2b6d3c9761 100644
--- a/homeassistant/components/tessie/entity.py
+++ b/homeassistant/components/tessie/entity.py
@@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
+from .const import DOMAIN, TRANSLATED_ERRORS
from .coordinator import (
TessieEnergySiteInfoCoordinator,
TessieEnergySiteLiveCoordinator,
@@ -107,10 +107,11 @@ class TessieEntity(TessieBaseEntity):
if response["result"] is False:
name: str = getattr(self, "name", self.entity_id)
reason: str = response.get("reason", "unknown")
+ translation_key = TRANSLATED_ERRORS.get(reason, "command_failed")
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key=reason.replace(" ", "_"),
- translation_placeholders={"name": name},
+ translation_key=translation_key,
+ translation_placeholders={"name": name, "message": reason},
)
def _async_update_attrs(self) -> None:
diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json
index 92aa289ca47..cab9f4c706d 100644
--- a/homeassistant/components/tessie/manifest.json
+++ b/homeassistant/components/tessie/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
- "quality_scale": "platinum",
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.8.4"]
}
diff --git a/homeassistant/components/tfiac/manifest.json b/homeassistant/components/tfiac/manifest.json
index 243710241a2..94f82c99d21 100644
--- a/homeassistant/components/tfiac/manifest.json
+++ b/homeassistant/components/tfiac/manifest.json
@@ -5,5 +5,6 @@
"disabled": "This integration is disabled because we cannot build a valid wheel.",
"documentation": "https://www.home-assistant.io/integrations/tfiac",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["pytfiac==0.4"]
}
diff --git a/homeassistant/components/thermoworks_smoke/manifest.json b/homeassistant/components/thermoworks_smoke/manifest.json
index 7baec9cdb74..f67b041b1e5 100644
--- a/homeassistant/components/thermoworks_smoke/manifest.json
+++ b/homeassistant/components/thermoworks_smoke/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/thermoworks_smoke",
"iot_class": "cloud_polling",
"loggers": ["thermoworks_smoke"],
+ "quality_scale": "legacy",
"requirements": ["stringcase==1.2.0", "thermoworks-smoke==0.1.8"]
}
diff --git a/homeassistant/components/thingspeak/manifest.json b/homeassistant/components/thingspeak/manifest.json
index ffdc11d9214..aac0ca06426 100644
--- a/homeassistant/components/thingspeak/manifest.json
+++ b/homeassistant/components/thingspeak/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/thingspeak",
"iot_class": "cloud_push",
"loggers": ["thingspeak"],
+ "quality_scale": "legacy",
"requirements": ["thingspeak==1.0.0"]
}
diff --git a/homeassistant/components/thinkingcleaner/manifest.json b/homeassistant/components/thinkingcleaner/manifest.json
index f480340fcf8..048fcfffa05 100644
--- a/homeassistant/components/thinkingcleaner/manifest.json
+++ b/homeassistant/components/thinkingcleaner/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/thinkingcleaner",
"iot_class": "local_polling",
"loggers": ["pythinkingcleaner"],
+ "quality_scale": "legacy",
"requirements": ["pythinkingcleaner==0.0.3"]
}
diff --git a/homeassistant/components/thomson/manifest.json b/homeassistant/components/thomson/manifest.json
index 08961cb2746..7f49b57d724 100644
--- a/homeassistant/components/thomson/manifest.json
+++ b/homeassistant/components/thomson/manifest.json
@@ -3,5 +3,6 @@
"name": "Thomson",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/thomson",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/threshold/strings.json b/homeassistant/components/threshold/strings.json
index fc9ee8fb7bf..94a1932cbbc 100644
--- a/homeassistant/components/threshold/strings.json
+++ b/homeassistant/components/threshold/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Threshold Sensor",
+ "title": "Create Threshold Sensor",
"description": "Create a binary sensor that turns on and off depending on the value of a sensor\n\nOnly lower limit configured - Turn on when the input sensor's value is less than the lower limit.\nOnly upper limit configured - Turn on when the input sensor's value is greater than the upper limit.\nBoth lower and upper limit configured - Turn on when the input sensor's value is in the range [lower limit .. upper limit].",
"data": {
"entity_id": "Input sensor",
diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json
index bc9304ab59d..3a3a772a934 100644
--- a/homeassistant/components/tibber/manifest.json
+++ b/homeassistant/components/tibber/manifest.json
@@ -7,6 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
- "quality_scale": "silver",
"requirements": ["pyTibber==0.30.8"]
}
diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py
index 125dc8eae6f..c1ec7bf2a9e 100644
--- a/homeassistant/components/tibber/sensor.py
+++ b/homeassistant/components/tibber/sensor.py
@@ -397,7 +397,7 @@ class TibberSensorElPrice(TibberSensor):
if (
not self._tibber_home.last_data_timestamp
or (self._tibber_home.last_data_timestamp - now).total_seconds()
- < 11 * 3600 + self._spread_load_constant
+ < 10 * 3600 - self._spread_load_constant
or not self.available
):
_LOGGER.debug("Asking for new data")
diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py
index 72943a0215a..5033cda11d0 100644
--- a/homeassistant/components/tibber/services.py
+++ b/homeassistant/components/tibber/services.py
@@ -79,7 +79,6 @@ def __get_date(date_input: str | None, mode: str | None) -> datetime:
return dt_util.as_local(value)
raise ServiceValidationError(
- "Invalid datetime provided.",
translation_domain=DOMAIN,
translation_key="invalid_date",
translation_placeholders={
diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json
index 8d73d435c8c..05b98b97995 100644
--- a/homeassistant/components/tibber/strings.json
+++ b/homeassistant/components/tibber/strings.json
@@ -119,6 +119,9 @@
}
},
"exceptions": {
+ "invalid_date": {
+ "message": "Invalid datetime provided {date}"
+ },
"send_message_timeout": {
"message": "Timeout sending message with Tibber"
}
diff --git a/homeassistant/components/tikteck/manifest.json b/homeassistant/components/tikteck/manifest.json
index 067dd6f92cf..57e5269d3b0 100644
--- a/homeassistant/components/tikteck/manifest.json
+++ b/homeassistant/components/tikteck/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tikteck",
"iot_class": "local_polling",
"loggers": ["tikteck"],
+ "quality_scale": "legacy",
"requirements": ["tikteck==0.4"]
}
diff --git a/homeassistant/components/tmb/manifest.json b/homeassistant/components/tmb/manifest.json
index 16efc870504..0e0324a62f4 100644
--- a/homeassistant/components/tmb/manifest.json
+++ b/homeassistant/components/tmb/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tmb",
"iot_class": "local_polling",
"loggers": ["tmb"],
+ "quality_scale": "legacy",
"requirements": ["tmb==0.0.4"]
}
diff --git a/homeassistant/components/tod/strings.json b/homeassistant/components/tod/strings.json
index bd4a48df915..c32b996c29a 100644
--- a/homeassistant/components/tod/strings.json
+++ b/homeassistant/components/tod/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Times of the Day Sensor",
+ "title": "Create Times of the Day Sensor",
"description": "Create a binary sensor that turns on or off depending on the time.",
"data": {
"after_time": "On time",
diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json
index 717aa310ecd..245e5c82fc8 100644
--- a/homeassistant/components/todo/strings.json
+++ b/homeassistant/components/todo/strings.json
@@ -44,11 +44,11 @@
"fields": {
"item": {
"name": "Item name",
- "description": "The name for the to-do list item."
+ "description": "The current name of the to-do item."
},
"rename": {
"name": "Rename item",
- "description": "The new name of the to-do item"
+ "description": "The new name for the to-do item"
},
"status": {
"name": "Set status",
@@ -78,7 +78,7 @@
"fields": {
"item": {
"name": "Item name",
- "description": "The name for the to-do list items."
+ "description": "The name for the to-do list item."
}
}
}
diff --git a/homeassistant/components/tomato/manifest.json b/homeassistant/components/tomato/manifest.json
index 6db69d50d82..081d55bc46d 100644
--- a/homeassistant/components/tomato/manifest.json
+++ b/homeassistant/components/tomato/manifest.json
@@ -3,5 +3,6 @@
"name": "Tomato",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/tomato",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/torque/manifest.json b/homeassistant/components/torque/manifest.json
index b966365bdd4..44047c67dd2 100644
--- a/homeassistant/components/torque/manifest.json
+++ b/homeassistant/components/torque/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/torque",
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/touchline/manifest.json b/homeassistant/components/touchline/manifest.json
index 340edb8381a..c003cca97a4 100644
--- a/homeassistant/components/touchline/manifest.json
+++ b/homeassistant/components/touchline/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/touchline",
"iot_class": "local_polling",
"loggers": ["pytouchline"],
+ "quality_scale": "legacy",
"requirements": ["pytouchline==0.7"]
}
diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py
index 93328823749..8a0ffc4cd86 100644
--- a/homeassistant/components/touchline_sl/climate.py
+++ b/homeassistant/components/touchline_sl/climate.py
@@ -2,22 +2,19 @@
from typing import Any
-from pytouchlinesl import Zone
-
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
+ HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TouchlineSLConfigEntry
-from .const import DOMAIN
from .coordinator import TouchlineSLModuleCoordinator
+from .entity import TouchlineSLZoneEntity
async def async_setup_entry(
@@ -37,10 +34,10 @@ async def async_setup_entry(
CONSTANT_TEMPERATURE = "constant_temperature"
-class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEntity):
+class TouchlineSLZone(TouchlineSLZoneEntity, ClimateEntity):
"""Roth Touchline SL Zone."""
- _attr_has_entity_name = True
+ _attr_hvac_action = HVACAction.IDLE
_attr_hvac_mode = HVACMode.HEAT
_attr_hvac_modes = [HVACMode.HEAT]
_attr_name = None
@@ -52,22 +49,12 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn
def __init__(self, coordinator: TouchlineSLModuleCoordinator, zone_id: int) -> None:
"""Construct a Touchline SL climate zone."""
- super().__init__(coordinator)
- self.zone_id: int = zone_id
+ super().__init__(coordinator, zone_id)
self._attr_unique_id = (
f"module-{self.coordinator.data.module.id}-zone-{self.zone_id}"
)
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, str(zone_id))},
- name=self.zone.name,
- manufacturer="Roth",
- via_device=(DOMAIN, coordinator.data.module.id),
- model="zone",
- suggested_area=self.zone.name,
- )
-
# Call this in __init__ so data is populated right away, since it's
# already available in the coordinator data.
self.set_attr()
@@ -78,16 +65,6 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn
self.set_attr()
super()._handle_coordinator_update()
- @property
- def zone(self) -> Zone:
- """Return the device object from the coordinator data."""
- return self.coordinator.data.zones[self.zone_id]
-
- @property
- def available(self) -> bool:
- """Return if the device is available."""
- return super().available and self.zone_id in self.coordinator.data.zones
-
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
@@ -124,3 +101,16 @@ class TouchlineSLZone(CoordinatorEntity[TouchlineSLModuleCoordinator], ClimateEn
elif self.zone.mode == "globalSchedule":
schedule = self.zone.schedule
self._attr_preset_mode = schedule.name
+
+ if self.zone.algorithm == "heating":
+ self._attr_hvac_action = (
+ HVACAction.HEATING if self.zone.relay_on else HVACAction.IDLE
+ )
+ self._attr_hvac_mode = HVACMode.HEAT
+ self._attr_hvac_modes = [HVACMode.HEAT]
+ elif self.zone.algorithm == "cooling":
+ self._attr_hvac_action = (
+ HVACAction.COOLING if self.zone.relay_on else HVACAction.IDLE
+ )
+ self._attr_hvac_mode = HVACMode.COOL
+ self._attr_hvac_modes = [HVACMode.COOL]
diff --git a/homeassistant/components/touchline_sl/entity.py b/homeassistant/components/touchline_sl/entity.py
new file mode 100644
index 00000000000..637ad8955eb
--- /dev/null
+++ b/homeassistant/components/touchline_sl/entity.py
@@ -0,0 +1,38 @@
+"""Base class for Touchline SL zone entities."""
+
+from pytouchlinesl import Zone
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import TouchlineSLModuleCoordinator
+
+
+class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]):
+ """Defines a base Touchline SL zone entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: TouchlineSLModuleCoordinator, zone_id: int) -> None:
+ """Initialize touchline entity."""
+ super().__init__(coordinator)
+ self.zone_id = zone_id
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, str(zone_id))},
+ name=self.zone.name,
+ manufacturer="Roth",
+ via_device=(DOMAIN, coordinator.data.module.id),
+ model="zone",
+ suggested_area=self.zone.name,
+ )
+
+ @property
+ def zone(self) -> Zone:
+ """Return the device object from the coordinator data."""
+ return self.coordinator.data.zones[self.zone_id]
+
+ @property
+ def available(self) -> bool:
+ """Return if the device is available."""
+ return super().available and self.zone_id in self.coordinator.data.zones
diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json
index 063f7726587..ab07ae770fd 100644
--- a/homeassistant/components/touchline_sl/manifest.json
+++ b/homeassistant/components/touchline_sl/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/touchline_sl",
"integration_type": "hub",
"iot_class": "cloud_polling",
- "requirements": ["pytouchlinesl==0.1.9"]
+ "requirements": ["pytouchlinesl==0.3.0"]
}
diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py
index ee1d90e70b4..a7ffce686be 100644
--- a/homeassistant/components/tplink/__init__.py
+++ b/homeassistant/components/tplink/__init__.py
@@ -148,7 +148,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
if conn_params_dict := entry.data.get(CONF_CONNECTION_PARAMETERS):
try:
conn_params = Device.ConnectionParameters.from_dict(conn_params_dict)
- except KasaException:
+ except (KasaException, TypeError, ValueError, LookupError):
_LOGGER.warning(
"Invalid connection parameters dict for %s: %s", host, conn_params_dict
)
diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py
index 34375bccf4f..e14ecf01749 100644
--- a/homeassistant/components/tplink/binary_sensor.py
+++ b/homeassistant/components/tplink/binary_sensor.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Final
+from typing import Final, cast
from kasa import Feature
@@ -98,4 +98,4 @@ class TPLinkBinarySensorEntity(CoordinatedTPLinkFeatureEntity, BinarySensorEntit
@callback
def _async_update_attrs(self) -> None:
"""Update the entity's attributes."""
- self._attr_is_on = self._feature.value
+ self._attr_is_on = cast(bool | None, self._feature.value)
diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py
index f86992ea0cf..0bd25d9f80c 100644
--- a/homeassistant/components/tplink/climate.py
+++ b/homeassistant/components/tplink/climate.py
@@ -116,8 +116,8 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity):
@callback
def _async_update_attrs(self) -> None:
"""Update the entity's attributes."""
- self._attr_current_temperature = self._temp_feature.value
- self._attr_target_temperature = self._target_feature.value
+ self._attr_current_temperature = cast(float | None, self._temp_feature.value)
+ self._attr_target_temperature = cast(float | None, self._target_feature.value)
self._attr_hvac_mode = (
HVACMode.HEAT if self._state_feature.value else HVACMode.OFF
@@ -134,7 +134,9 @@ class TPLinkClimateEntity(CoordinatedTPLinkEntity, ClimateEntity):
self._attr_hvac_action = HVACAction.OFF
return
- self._attr_hvac_action = STATE_TO_ACTION[self._mode_feature.value]
+ self._attr_hvac_action = STATE_TO_ACTION[
+ cast(ThermostatState, self._mode_feature.value)
+ ]
def _get_unique_id(self) -> str:
"""Return unique id."""
diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json
index cb8a55b3db2..3f19f50cdb6 100644
--- a/homeassistant/components/tplink/manifest.json
+++ b/homeassistant/components/tplink/manifest.json
@@ -300,6 +300,5 @@
"documentation": "https://www.home-assistant.io/integrations/tplink",
"iot_class": "local_polling",
"loggers": ["kasa"],
- "quality_scale": "platinum",
- "requirements": ["python-kasa[speedups]==0.7.7"]
+ "requirements": ["python-kasa[speedups]==0.8.0"]
}
diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py
index 5f80d5479d2..b51c00db7c0 100644
--- a/homeassistant/components/tplink/number.py
+++ b/homeassistant/components/tplink/number.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
-from typing import Final
+from typing import Final, cast
from kasa import Device, Feature
@@ -108,4 +108,4 @@ class TPLinkNumberEntity(CoordinatedTPLinkFeatureEntity, NumberEntity):
@callback
def _async_update_attrs(self) -> None:
"""Update the entity's attributes."""
- self._attr_native_value = self._feature.value
+ self._attr_native_value = cast(float | None, self._feature.value)
diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py
index 41e3224215b..3755a1d0be2 100644
--- a/homeassistant/components/tplink/select.py
+++ b/homeassistant/components/tplink/select.py
@@ -93,4 +93,4 @@ class TPLinkSelectEntity(CoordinatedTPLinkFeatureEntity, SelectEntity):
@callback
def _async_update_attrs(self) -> None:
"""Update the entity's attributes."""
- self._attr_current_option = self._feature.value
+ self._attr_current_option = cast(str | None, self._feature.value)
diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py
index 809d9002768..8b7351f8d7d 100644
--- a/homeassistant/components/tplink/sensor.py
+++ b/homeassistant/components/tplink/sensor.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import cast
+from typing import TYPE_CHECKING, cast
from kasa import Feature
@@ -161,6 +161,12 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity):
# We probably do not need this, when we are rounding already?
self._attr_suggested_display_precision = self._feature.precision_hint
+ if TYPE_CHECKING:
+ # pylint: disable-next=import-outside-toplevel
+ from datetime import date, datetime
+
+ assert isinstance(value, str | int | float | date | datetime | None)
+
self._attr_native_value = value
# Map to homeassistant units and fallback to upstream one if none found
if (unit := self._feature.unit) is not None:
diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py
index c9285d86ba6..7e223752665 100644
--- a/homeassistant/components/tplink/switch.py
+++ b/homeassistant/components/tplink/switch.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
-from typing import Any
+from typing import Any, cast
from kasa import Feature
@@ -99,4 +99,4 @@ class TPLinkSwitch(CoordinatedTPLinkFeatureEntity, SwitchEntity):
@callback
def _async_update_attrs(self) -> None:
"""Update the entity's attributes."""
- self._attr_is_on = self._feature.value
+ self._attr_is_on = cast(bool | None, self._feature.value)
diff --git a/homeassistant/components/tplink_lte/manifest.json b/homeassistant/components/tplink_lte/manifest.json
index 63640628e35..a880594e683 100644
--- a/homeassistant/components/tplink_lte/manifest.json
+++ b/homeassistant/components/tplink_lte/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/tplink_lte",
"iot_class": "local_polling",
"loggers": ["tp_connected"],
+ "quality_scale": "legacy",
"requirements": ["tp-connected==0.0.4"]
}
diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py
index 938bfce2318..614072cc706 100644
--- a/homeassistant/components/trafikverket_camera/__init__.py
+++ b/homeassistant/components/trafikverket_camera/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
-from pytrafikverket.trafikverket_camera import TrafikverketCamera
+from pytrafikverket import TrafikverketCamera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION
@@ -25,7 +25,7 @@ TVCameraConfigEntry = ConfigEntry[TVDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) -> bool:
"""Set up Trafikverket Camera from a config entry."""
- coordinator = TVDataUpdateCoordinator(hass)
+ coordinator = TVDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py
index 18e210beb16..29f3db7beac 100644
--- a/homeassistant/components/trafikverket_camera/config_flow.py
+++ b/homeassistant/components/trafikverket_camera/config_flow.py
@@ -5,9 +5,13 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
-from pytrafikverket.exceptions import InvalidAuthentication, NoCameraFound, UnknownError
-from pytrafikverket.models import CameraInfoModel
-from pytrafikverket.trafikverket_camera import TrafikverketCamera
+from pytrafikverket import (
+ CameraInfoModel,
+ InvalidAuthentication,
+ NoCameraFound,
+ TrafikverketCamera,
+ UnknownError,
+)
import voluptuous as vol
from homeassistant.config_entries import (
diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py
index 7bc5c556c00..649eb102575 100644
--- a/homeassistant/components/trafikverket_camera/coordinator.py
+++ b/homeassistant/components/trafikverket_camera/coordinator.py
@@ -9,14 +9,14 @@ import logging
from typing import TYPE_CHECKING
import aiohttp
-from pytrafikverket.exceptions import (
+from pytrafikverket import (
+ CameraInfoModel,
InvalidAuthentication,
MultipleCamerasFound,
NoCameraFound,
+ TrafikverketCamera,
UnknownError,
)
-from pytrafikverket.models import CameraInfoModel
-from pytrafikverket.trafikverket_camera import TrafikverketCamera
from homeassistant.const import CONF_API_KEY, CONF_ID
from homeassistant.core import HomeAssistant
@@ -44,21 +44,20 @@ class CameraData:
class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]):
"""A Trafikverket Data Update Coordinator."""
- config_entry: TVCameraConfigEntry
-
- def __init__(self, hass: HomeAssistant) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: TVCameraConfigEntry) -> None:
"""Initialize the Trafikverket coordinator."""
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=TIME_BETWEEN_UPDATES,
)
self.session = async_get_clientsession(hass)
self._camera_api = TrafikverketCamera(
- self.session, self.config_entry.data[CONF_API_KEY]
+ self.session, config_entry.data[CONF_API_KEY]
)
- self._id = self.config_entry.data[CONF_ID]
+ self._id = config_entry.data[CONF_ID]
async def _async_update_data(self) -> CameraData:
"""Fetch data from Trafikverket."""
diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json
index f424f47f7c5..08d945e0a0c 100644
--- a/homeassistant/components/trafikverket_camera/manifest.json
+++ b/homeassistant/components/trafikverket_camera/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_camera",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==1.0.0"]
+ "requirements": ["pytrafikverket==1.1.1"]
}
diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json
index 0b7b056754c..4177587db7e 100644
--- a/homeassistant/components/trafikverket_ferry/manifest.json
+++ b/homeassistant/components/trafikverket_ferry/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==1.0.0"]
+ "requirements": ["pytrafikverket==1.1.1"]
}
diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py
index 3e807df9301..23aee50d816 100644
--- a/homeassistant/components/trafikverket_train/__init__.py
+++ b/homeassistant/components/trafikverket_train/__init__.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import logging
+
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -11,6 +13,8 @@ from .coordinator import TVDataUpdateCoordinator
TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator]
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool:
"""Set up Trafikverket Train from a config entry."""
@@ -42,3 +46,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool:
+ """Migrate config entry."""
+ _LOGGER.debug("Migrating from version %s", entry.version)
+
+ if entry.version > 1:
+ # This means the user has downgraded from a future version
+ return False
+
+ if entry.version == 1 and entry.minor_version == 1:
+ # Remove unique id
+ hass.config_entries.async_update_entry(entry, unique_id=None, minor_version=2)
+
+ _LOGGER.debug(
+ "Migration to version %s.%s successful",
+ entry.version,
+ entry.minor_version,
+ )
+
+ return True
diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py
index f498a7b0d0e..363b9bb2542 100644
--- a/homeassistant/components/trafikverket_train/config_flow.py
+++ b/homeassistant/components/trafikverket_train/config_flow.py
@@ -37,7 +37,7 @@ from homeassistant.helpers.selector import (
import homeassistant.util.dt as dt_util
from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN
-from .util import create_unique_id, next_departuredate
+from .util import next_departuredate
_LOGGER = logging.getLogger(__name__)
@@ -93,8 +93,8 @@ async def validate_input(
try:
web_session = async_get_clientsession(hass)
train_api = TrafikverketTrain(web_session, api_key)
- from_station = await train_api.async_get_train_station(train_from)
- to_station = await train_api.async_get_train_station(train_to)
+ from_station = await train_api.async_search_train_station(train_from)
+ to_station = await train_api.async_search_train_station(train_to)
if train_time:
await train_api.async_get_train_stop(
from_station, to_station, when, product_filter
@@ -125,6 +125,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Trafikverket Train integration."""
VERSION = 1
+ MINOR_VERSION = 2
@staticmethod
@callback
@@ -202,11 +203,16 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
filter_product,
)
if not errors:
- unique_id = create_unique_id(
- train_from, train_to, train_time, train_days
+ self._async_abort_entries_match(
+ {
+ CONF_API_KEY: api_key,
+ CONF_FROM: train_from,
+ CONF_TO: train_to,
+ CONF_TIME: train_time,
+ CONF_WEEKDAY: train_days,
+ CONF_FILTER_PRODUCT: filter_product,
+ }
)
- await self.async_set_unique_id(unique_id)
- self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data={
diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py
index 16a7a649b85..49d4e1ded74 100644
--- a/homeassistant/components/trafikverket_train/coordinator.py
+++ b/homeassistant/components/trafikverket_train/coordinator.py
@@ -94,10 +94,10 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]):
async def _async_setup(self) -> None:
"""Initiate stations."""
try:
- self.to_station = await self._train_api.async_get_train_station(
+ self.to_station = await self._train_api.async_search_train_station(
self.config_entry.data[CONF_TO]
)
- self.from_station = await self._train_api.async_get_train_station(
+ self.from_station = await self._train_api.async_search_train_station(
self.config_entry.data[CONF_FROM]
)
except InvalidAuthentication as error:
diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json
index 222b23dbe9a..40f3a39a2bb 100644
--- a/homeassistant/components/trafikverket_train/manifest.json
+++ b/homeassistant/components/trafikverket_train/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_train",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==1.0.0"]
+ "requirements": ["pytrafikverket==1.1.1"]
}
diff --git a/homeassistant/components/trafikverket_train/util.py b/homeassistant/components/trafikverket_train/util.py
index 9648436f1e5..9a8dd9ea237 100644
--- a/homeassistant/components/trafikverket_train/util.py
+++ b/homeassistant/components/trafikverket_train/util.py
@@ -2,22 +2,11 @@
from __future__ import annotations
-from datetime import date, time, timedelta
+from datetime import date, timedelta
from homeassistant.const import WEEKDAYS
-def create_unique_id(
- from_station: str, to_station: str, depart_time: time | str | None, weekdays: list
-) -> str:
- """Create unique id."""
- timestr = str(depart_time) if depart_time else ""
- return (
- f"{from_station.casefold().replace(' ', '')}-{to_station.casefold().replace(' ', '')}"
- f"-{timestr.casefold().replace(' ', '')}-{weekdays!s}"
- )
-
-
def next_weekday(fromdate: date, weekday: int) -> date:
"""Return the date of the next time a specific weekday happen."""
days_ahead = weekday - fromdate.weekday()
diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json
index 85838726178..3996379540f 100644
--- a/homeassistant/components/trafikverket_weatherstation/manifest.json
+++ b/homeassistant/components/trafikverket_weatherstation/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation",
"iot_class": "cloud_polling",
"loggers": ["pytrafikverket"],
- "requirements": ["pytrafikverket==1.0.0"]
+ "requirements": ["pytrafikverket==1.1.1"]
}
diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py
index 737520adb5f..652f5d51fbb 100644
--- a/homeassistant/components/transmission/sensor.py
+++ b/homeassistant/components/transmission/sensor.py
@@ -83,7 +83,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="active_torrents",
translation_key="active_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: coordinator.data.active_torrent_count,
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
coordinator=coordinator, key="active_torrents"
@@ -92,7 +91,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="paused_torrents",
translation_key="paused_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: coordinator.data.paused_torrent_count,
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
coordinator=coordinator, key="paused_torrents"
@@ -101,7 +99,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="total_torrents",
translation_key="total_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: coordinator.data.torrent_count,
extra_state_attr_func=lambda coordinator: _torrents_info_attr(
coordinator=coordinator, key="total_torrents"
@@ -110,7 +107,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="completed_torrents",
translation_key="completed_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: len(
_filter_torrents(coordinator.torrents, MODES["completed_torrents"])
),
@@ -121,7 +117,6 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
TransmissionSensorEntityDescription(
key="started_torrents",
translation_key="started_torrents",
- native_unit_of_measurement="torrents",
val_func=lambda coordinator: len(
_filter_torrents(coordinator.torrents, MODES["started_torrents"])
),
diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json
index 20ae6ca723d..578bc262589 100644
--- a/homeassistant/components/transmission/strings.json
+++ b/homeassistant/components/transmission/strings.json
@@ -60,19 +60,24 @@
}
},
"active_torrents": {
- "name": "Active torrents"
+ "name": "Active torrents",
+ "unit_of_measurement": "torrents"
},
"paused_torrents": {
- "name": "Paused torrents"
+ "name": "Paused torrents",
+ "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]"
},
"total_torrents": {
- "name": "Total torrents"
+ "name": "Total torrents",
+ "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]"
},
"completed_torrents": {
- "name": "Completed torrents"
+ "name": "Completed torrents",
+ "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]"
},
"started_torrents": {
- "name": "Started torrents"
+ "name": "Started torrents",
+ "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]"
}
},
"switch": {
diff --git a/homeassistant/components/transport_nsw/manifest.json b/homeassistant/components/transport_nsw/manifest.json
index 9d535b99aa1..83c138a4f91 100644
--- a/homeassistant/components/transport_nsw/manifest.json
+++ b/homeassistant/components/transport_nsw/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/transport_nsw",
"iot_class": "cloud_polling",
"loggers": ["TransportNSW"],
+ "quality_scale": "legacy",
"requirements": ["PyTransportNSW==0.1.1"]
}
diff --git a/homeassistant/components/travisci/manifest.json b/homeassistant/components/travisci/manifest.json
index e61a987c86f..be30cf8e1f9 100644
--- a/homeassistant/components/travisci/manifest.json
+++ b/homeassistant/components/travisci/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/travisci",
"iot_class": "cloud_polling",
"loggers": ["travispy"],
+ "quality_scale": "legacy",
"requirements": ["TravisPy==0.3.5"]
}
diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py
index 681680f180f..9691ecf0744 100644
--- a/homeassistant/components/trend/binary_sensor.py
+++ b/homeassistant/components/trend/binary_sensor.py
@@ -227,10 +227,15 @@ class SensorTrend(BinarySensorEntity, RestoreEntity):
state = new_state.attributes.get(self._attribute)
else:
state = new_state.state
- if state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
+
+ if state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
+ self._attr_available = False
+ else:
+ self._attr_available = True
sample = (new_state.last_updated.timestamp(), float(state)) # type: ignore[arg-type]
self.samples.append(sample)
- self.async_schedule_update_ha_state(True)
+
+ self.async_schedule_update_ha_state(True)
except (ValueError, TypeError) as ex:
_LOGGER.error(ex)
diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py
index ad267b9106b..e7d1091719b 100644
--- a/homeassistant/components/tts/__init__.py
+++ b/homeassistant/components/tts/__init__.py
@@ -13,6 +13,7 @@ import logging
import mimetypes
import os
import re
+import secrets
import subprocess
import tempfile
from typing import Any, Final, TypedDict, final
@@ -540,6 +541,10 @@ class SpeechManager:
self.file_cache: dict[str, str] = {}
self.mem_cache: dict[str, TTSCache] = {}
+ # filename <-> token
+ self.filename_to_token: dict[str, str] = {}
+ self.token_to_filename: dict[str, str] = {}
+
def _init_cache(self) -> dict[str, str]:
"""Init cache folder and fetch files."""
try:
@@ -656,7 +661,17 @@ class SpeechManager:
engine_instance, cache_key, message, use_cache, language, options
)
- return f"/api/tts_proxy/{filename}"
+ # Use a randomly generated token instead of exposing the filename
+ token = self.filename_to_token.get(filename)
+ if not token:
+ # Keep extension (.mp3, etc.)
+ token = secrets.token_urlsafe(16) + os.path.splitext(filename)[1]
+
+ # Map token <-> filename
+ self.filename_to_token[filename] = token
+ self.token_to_filename[token] = filename
+
+ return f"/api/tts_proxy/{token}"
async def async_get_tts_audio(
self,
@@ -910,11 +925,15 @@ class SpeechManager:
),
)
- async def async_read_tts(self, filename: str) -> tuple[str | None, bytes]:
+ async def async_read_tts(self, token: str) -> tuple[str | None, bytes]:
"""Read a voice file and return binary.
This method is a coroutine.
"""
+ filename = self.token_to_filename.get(token)
+ if not filename:
+ raise HomeAssistantError(f"{token} was not recognized!")
+
if not (record := _RE_VOICE_FILE.match(filename.lower())) and not (
record := _RE_LEGACY_VOICE_FILE.match(filename.lower())
):
@@ -1076,6 +1095,7 @@ class TextToSpeechView(HomeAssistantView):
async def get(self, request: web.Request, filename: str) -> web.Response:
"""Start a get request."""
try:
+ # filename is actually token, but we keep its name for compatibility
content, data = await self.tts.async_read_tts(filename)
except HomeAssistantError as err:
_LOGGER.error("Error on load tts: %s", err)
diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py
index d2e381d9982..8d5b5dbfa19 100644
--- a/homeassistant/components/tuya/number.py
+++ b/homeassistant/components/tuya/number.py
@@ -292,6 +292,17 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
device_class=NumberDeviceClass.TEMPERATURE,
),
),
+ # CO2 Detector
+ # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
+ "co2bj": (
+ NumberEntityDescription(
+ key=DPCode.ALARM_TIME,
+ translation_key="alarm_duration",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ device_class=NumberDeviceClass.DURATION,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
}
diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py
index abc5e4c496b..831d3cb3e0c 100644
--- a/homeassistant/components/tuya/select.py
+++ b/homeassistant/components/tuya/select.py
@@ -307,6 +307,15 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
+ # CO2 Detector
+ # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
+ "co2bj": (
+ SelectEntityDescription(
+ key=DPCode.ALARM_VOLUME,
+ translation_key="volume",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
}
# Socket (duplicate of `kg`)
diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py
index b9677037b7e..f766c744998 100644
--- a/homeassistant/components/tuya/sensor.py
+++ b/homeassistant/components/tuya/sensor.py
@@ -214,6 +214,12 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
state_class=SensorStateClass.MEASUREMENT,
),
+ TuyaSensorEntityDescription(
+ key=DPCode.PM25_VALUE,
+ translation_key="pm25",
+ device_class=SensorDeviceClass.PM25,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
*BATTERY_SENSORS,
),
# Two-way temperature and humidity switch
@@ -254,6 +260,31 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
entity_registry_enabled_default=False,
),
),
+ # Single Phase power meter
+ # Note: Undocumented
+ "aqcz": (
+ TuyaSensorEntityDescription(
+ key=DPCode.CUR_CURRENT,
+ translation_key="current",
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.CUR_POWER,
+ translation_key="power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.CUR_VOLTAGE,
+ translation_key="voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ ),
+ ),
# CO Detector
# https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v
"cobj": (
diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py
index 334dced134d..6f7dfe4c96c 100644
--- a/homeassistant/components/tuya/siren.py
+++ b/homeassistant/components/tuya/siren.py
@@ -11,6 +11,7 @@ from homeassistant.components.siren import (
SirenEntityDescription,
SirenEntityFeature,
)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -43,6 +44,14 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = {
key=DPCode.SIREN_SWITCH,
),
),
+ # CO2 Detector
+ # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
+ "co2bj": (
+ SirenEntityDescription(
+ key=DPCode.ALARM_SWITCH,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
}
diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json
index 0f005821cbb..8ec61cc8aa5 100644
--- a/homeassistant/components/tuya/strings.json
+++ b/homeassistant/components/tuya/strings.json
@@ -119,6 +119,9 @@
}
},
"number": {
+ "alarm_duration": {
+ "name": "Alarm duration"
+ },
"temperature": {
"name": "[%key:component::sensor::entity_component::temperature::name%]"
},
diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py
index 77432c5b9a5..2b5e6fec4a6 100644
--- a/homeassistant/components/tuya/switch.py
+++ b/homeassistant/components/tuya/switch.py
@@ -528,6 +528,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
translation_key="switch",
),
),
+ # Hejhome whitelabel Fingerbot
+ "znjxs": (
+ SwitchEntityDescription(
+ key=DPCode.SWITCH,
+ translation_key="switch",
+ ),
+ ),
# IoT Switch?
# Note: Undocumented
"tdq": (
diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py
index b6728b96536..2796e9916f1 100644
--- a/homeassistant/components/twentemilieu/__init__.py
+++ b/homeassistant/components/twentemilieu/__init__.py
@@ -29,7 +29,9 @@ type TwenteMilieuDataUpdateCoordinator = DataUpdateCoordinator[
type TwenteMilieuConfigEntry = ConfigEntry[TwenteMilieuDataUpdateCoordinator]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, entry: TwenteMilieuConfigEntry
+) -> bool:
"""Set up Twente Milieu from a config entry."""
session = async_get_clientsession(hass)
twentemilieu = TwenteMilieu(
@@ -49,18 +51,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await coordinator.async_config_entry_first_refresh()
- # For backwards compat, set unique ID
- if entry.unique_id is None:
- hass.config_entries.async_update_entry(
- entry, unique_id=str(entry.data[CONF_ID])
- )
-
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: TwenteMilieuConfigEntry
+) -> bool:
"""Unload Twente Milieu config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/twentemilieu/diagnostics.py b/homeassistant/components/twentemilieu/diagnostics.py
index 9de3f9bfaff..75775303eb6 100644
--- a/homeassistant/components/twentemilieu/diagnostics.py
+++ b/homeassistant/components/twentemilieu/diagnostics.py
@@ -4,12 +4,13 @@ from __future__ import annotations
from typing import Any
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from . import TwenteMilieuConfigEntry
+
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: TwenteMilieuConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
diff --git a/homeassistant/components/twentemilieu/entity.py b/homeassistant/components/twentemilieu/entity.py
index 896a8e32de9..0a2473f4524 100644
--- a/homeassistant/components/twentemilieu/entity.py
+++ b/homeassistant/components/twentemilieu/entity.py
@@ -2,13 +2,12 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from . import TwenteMilieuDataUpdateCoordinator
+from . import TwenteMilieuConfigEntry, TwenteMilieuDataUpdateCoordinator
from .const import DOMAIN
@@ -17,7 +16,7 @@ class TwenteMilieuEntity(CoordinatorEntity[TwenteMilieuDataUpdateCoordinator], E
_attr_has_entity_name = True
- def __init__(self, entry: ConfigEntry) -> None:
+ def __init__(self, entry: TwenteMilieuConfigEntry) -> None:
"""Initialize the Twente Milieu entity."""
super().__init__(coordinator=entry.runtime_data)
self._attr_device_info = DeviceInfo(
diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json
index 8ba4f3b760e..a89091948c2 100644
--- a/homeassistant/components/twentemilieu/manifest.json
+++ b/homeassistant/components/twentemilieu/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["twentemilieu"],
- "quality_scale": "platinum",
"requirements": ["twentemilieu==2.1.0"]
}
diff --git a/homeassistant/components/twentemilieu/quality_scale.yaml b/homeassistant/components/twentemilieu/quality_scale.yaml
new file mode 100644
index 00000000000..f8fd813b03d
--- /dev/null
+++ b/homeassistant/components/twentemilieu/quality_scale.yaml
@@ -0,0 +1,118 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules:
+ status: todo
+ comment: |
+ The coordinator isn't in the common module yet.
+ config-flow-test-coverage: done
+ config-flow:
+ status: todo
+ comment: |
+ data_description's are missing.
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description:
+ status: todo
+ comment: |
+ The introduction can be improved and is missing links to the provider.
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration only polls data using a coordinator.
+ Since the integration is read-only and poll-only (only provide sensor
+ data), there is no need to implement parallel updates.
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: todo
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have an options flow.
+
+ # Gold
+ entity-translations:
+ status: todo
+ comment: |
+ The calendar entity name isn't translated yet.
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users home address to get the data.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device which represents the service.
+ diagnostics: done
+ exception-translations:
+ status: todo
+ comment: |
+ The coordinator raises, and currently, doesn't provide a translation for it.
+ icon-translations: done
+ reconfiguration-flow: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device which represents the service.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration cannot be discovered, it is a connecting to a service
+ provider, which uses the users home address to get the data.
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ docs-use-cases: todo
+ docs-supported-devices:
+ status: exempt
+ comment: |
+ This is an service, which doesn't integrate with any devices.
+ docs-supported-functions: done
+ docs-data-update: todo
+ docs-known-limitations: todo
+ docs-troubleshooting: todo
+ docs-examples: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py
index 2d2e3de0f0e..f5f91ce7080 100644
--- a/homeassistant/components/twentemilieu/sensor.py
+++ b/homeassistant/components/twentemilieu/sensor.py
@@ -12,11 +12,11 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from . import TwenteMilieuConfigEntry
from .const import DOMAIN
from .entity import TwenteMilieuEntity
@@ -64,7 +64,7 @@ SENSORS: tuple[TwenteMilieuSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: TwenteMilieuConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Twente Milieu sensor based on a config entry."""
@@ -80,7 +80,7 @@ class TwenteMilieuSensor(TwenteMilieuEntity, SensorEntity):
def __init__(
self,
- entry: ConfigEntry,
+ entry: TwenteMilieuConfigEntry,
description: TwenteMilieuSensorDescription,
) -> None:
"""Initialize the Twente Milieu entity."""
diff --git a/homeassistant/components/twilio_call/manifest.json b/homeassistant/components/twilio_call/manifest.json
index 88f09efdeed..f4389e1c7d7 100644
--- a/homeassistant/components/twilio_call/manifest.json
+++ b/homeassistant/components/twilio_call/manifest.json
@@ -5,5 +5,6 @@
"dependencies": ["twilio"],
"documentation": "https://www.home-assistant.io/integrations/twilio_call",
"iot_class": "cloud_push",
- "loggers": ["twilio"]
+ "loggers": ["twilio"],
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/twilio_sms/manifest.json b/homeassistant/components/twilio_sms/manifest.json
index 8736d58c0da..eed5a1113c6 100644
--- a/homeassistant/components/twilio_sms/manifest.json
+++ b/homeassistant/components/twilio_sms/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["twilio"],
"documentation": "https://www.home-assistant.io/integrations/twilio_sms",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json
index 44e8712b029..af4dff4486d 100644
--- a/homeassistant/components/twitter/manifest.json
+++ b/homeassistant/components/twitter/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/twitter",
"iot_class": "cloud_push",
"loggers": ["TwitterAPI"],
+ "quality_scale": "legacy",
"requirements": ["TwitterAPI==2.7.12"]
}
diff --git a/homeassistant/components/ubus/manifest.json b/homeassistant/components/ubus/manifest.json
index 902b7c9bb82..6053199b4ce 100644
--- a/homeassistant/components/ubus/manifest.json
+++ b/homeassistant/components/ubus/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/ubus",
"iot_class": "local_polling",
"loggers": ["openwrt"],
+ "quality_scale": "legacy",
"requirements": ["openwrt-ubus-rpc==0.0.2"]
}
diff --git a/homeassistant/components/uk_transport/manifest.json b/homeassistant/components/uk_transport/manifest.json
index f3511e71bfa..d855a04ee29 100644
--- a/homeassistant/components/uk_transport/manifest.json
+++ b/homeassistant/components/uk_transport/manifest.json
@@ -3,5 +3,6 @@
"name": "UK Transport",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/uk_transport",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json
index 6f92dec5361..66d0a53284b 100644
--- a/homeassistant/components/unifi/manifest.json
+++ b/homeassistant/components/unifi/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiounifi"],
- "quality_scale": "platinum",
"requirements": ["aiounifi==80"],
"ssdp": [
{
diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py
index 144cbd4dec7..d5e2e926114 100644
--- a/homeassistant/components/unifi_direct/device_tracker.py
+++ b/homeassistant/components/unifi_direct/device_tracker.py
@@ -67,11 +67,11 @@ class UnifiDeviceScanner(DeviceScanner):
"""Update the client info from AP."""
try:
self.clients = self.ap.get_clients()
- except UniFiAPConnectionException:
- _LOGGER.error("Failed to connect to accesspoint")
+ except UniFiAPConnectionException as e:
+ _LOGGER.error("Failed to connect to accesspoint: %s", str(e))
return False
- except UniFiAPDataException:
- _LOGGER.error("Failed to get proper response from accesspoint")
+ except UniFiAPDataException as e:
+ _LOGGER.error("Failed to get proper response from accesspoint: %s", str(e))
return False
return True
diff --git a/homeassistant/components/unifi_direct/manifest.json b/homeassistant/components/unifi_direct/manifest.json
index 8ca8ef27bb2..aa696985dbe 100644
--- a/homeassistant/components/unifi_direct/manifest.json
+++ b/homeassistant/components/unifi_direct/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/unifi_direct",
"iot_class": "local_polling",
"loggers": ["unifi_ap"],
- "requirements": ["unifi_ap==0.0.1"]
+ "quality_scale": "legacy",
+ "requirements": ["unifi_ap==0.0.2"]
}
diff --git a/homeassistant/components/unifiled/manifest.json b/homeassistant/components/unifiled/manifest.json
index c75efb2053b..a2179c76fd9 100644
--- a/homeassistant/components/unifiled/manifest.json
+++ b/homeassistant/components/unifiled/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/unifiled",
"iot_class": "local_polling",
"loggers": ["unifiled"],
+ "quality_scale": "legacy",
"requirements": ["unifiled==0.11"]
}
diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py
index 394a7f43329..ed409a6eea0 100644
--- a/homeassistant/components/unifiprotect/__init__.py
+++ b/homeassistant/components/unifiprotect/__init__.py
@@ -45,7 +45,7 @@ from .utils import (
async_create_api_client,
async_get_devices,
)
-from .views import ThumbnailProxyView, VideoProxyView
+from .views import ThumbnailProxyView, VideoEventProxyView, VideoProxyView
_LOGGER = logging.getLogger(__name__)
@@ -174,6 +174,7 @@ async def _async_setup_entry(
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
hass.http.register_view(ThumbnailProxyView(hass))
hass.http.register_view(VideoProxyView(hass))
+ hass.http.register_view(VideoEventProxyView(hass))
async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None:
diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py
index ad251ba6153..7d1e5b55d3f 100644
--- a/homeassistant/components/unifiprotect/const.py
+++ b/homeassistant/components/unifiprotect/const.py
@@ -1,5 +1,7 @@
"""Constant definitions for UniFi Protect Integration."""
+from typing import Final
+
from uiprotect.data import ModelType, Version
from homeassistant.const import Platform
@@ -75,3 +77,8 @@ PLATFORMS = [
DISPATCH_ADD = "add_device"
DISPATCH_ADOPT = "adopt_device"
DISPATCH_CHANNELS = "new_camera_channels"
+
+EVENT_TYPE_FINGERPRINT_IDENTIFIED: Final = "identified"
+EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED: Final = "not_identified"
+EVENT_TYPE_NFC_SCANNED: Final = "scanned"
+EVENT_TYPE_DOORBELL_RING: Final = "ring"
diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py
index 4ad8892ca01..baecc7f8323 100644
--- a/homeassistant/components/unifiprotect/data.py
+++ b/homeassistant/components/unifiprotect/data.py
@@ -349,6 +349,7 @@ def async_ufp_instance_for_config_entry_ids(
entry.runtime_data.api
for entry_id in config_entry_ids
if (entry := hass.config_entries.async_get_entry(entry_id))
+ and entry.domain == DOMAIN
and hasattr(entry, "runtime_data")
),
None,
diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py
index 8bbe568242b..f126920fb18 100644
--- a/homeassistant/components/unifiprotect/event.py
+++ b/homeassistant/components/unifiprotect/event.py
@@ -14,7 +14,13 @@ from homeassistant.components.event import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from .const import ATTR_EVENT_ID
+from .const import (
+ ATTR_EVENT_ID,
+ EVENT_TYPE_DOORBELL_RING,
+ EVENT_TYPE_FINGERPRINT_IDENTIFIED,
+ EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED,
+ EVENT_TYPE_NFC_SCANNED,
+)
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin
@@ -23,22 +29,10 @@ from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin
class ProtectEventEntityDescription(ProtectEventMixin, EventEntityDescription):
"""Describes UniFi Protect event entity."""
-
-EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
- ProtectEventEntityDescription(
- key="doorbell",
- translation_key="doorbell",
- name="Doorbell",
- device_class=EventDeviceClass.DOORBELL,
- icon="mdi:doorbell-video",
- ufp_required_field="feature_flags.is_doorbell",
- ufp_event_obj="last_ring_event",
- event_types=[EventType.RING],
- ),
-)
+ entity_class: type[ProtectDeviceEntity]
-class ProtectDeviceEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity):
+class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity):
"""A UniFi Protect event entity."""
entity_description: ProtectEventEntityDescription
@@ -57,26 +51,128 @@ class ProtectDeviceEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntit
if (
event
and not self._event_already_ended(prev_event, prev_event_end)
- and (event_types := description.event_types)
- and (event_type := event.type) in event_types
+ and event.type is EventType.RING
):
- self._trigger_event(event_type, {ATTR_EVENT_ID: event.id})
+ self._trigger_event(EVENT_TYPE_DOORBELL_RING, {ATTR_EVENT_ID: event.id})
self.async_write_ha_state()
+class ProtectDeviceNFCEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity):
+ """A UniFi Protect NFC event entity."""
+
+ entity_description: ProtectEventEntityDescription
+
+ @callback
+ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
+ description = self.entity_description
+
+ prev_event = self._event
+ prev_event_end = self._event_end
+ super()._async_update_device_from_protect(device)
+ if event := description.get_event_obj(device):
+ self._event = event
+ self._event_end = event.end if event else None
+
+ if (
+ event
+ and not self._event_already_ended(prev_event, prev_event_end)
+ and event.type is EventType.NFC_CARD_SCANNED
+ ):
+ event_data = {ATTR_EVENT_ID: event.id}
+ if event.metadata and event.metadata.nfc and event.metadata.nfc.nfc_id:
+ event_data["nfc_id"] = event.metadata.nfc.nfc_id
+
+ self._trigger_event(EVENT_TYPE_NFC_SCANNED, event_data)
+ self.async_write_ha_state()
+
+
+class ProtectDeviceFingerprintEventEntity(
+ EventEntityMixin, ProtectDeviceEntity, EventEntity
+):
+ """A UniFi Protect fingerprint event entity."""
+
+ entity_description: ProtectEventEntityDescription
+
+ @callback
+ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
+ description = self.entity_description
+
+ prev_event = self._event
+ prev_event_end = self._event_end
+ super()._async_update_device_from_protect(device)
+ if event := description.get_event_obj(device):
+ self._event = event
+ self._event_end = event.end if event else None
+
+ if (
+ event
+ and not self._event_already_ended(prev_event, prev_event_end)
+ and event.type is EventType.FINGERPRINT_IDENTIFIED
+ ):
+ event_data = {ATTR_EVENT_ID: event.id}
+ if (
+ event.metadata
+ and event.metadata.fingerprint
+ and event.metadata.fingerprint.ulp_id
+ ):
+ event_data["ulp_id"] = event.metadata.fingerprint.ulp_id
+ event_identified = EVENT_TYPE_FINGERPRINT_IDENTIFIED
+ else:
+ event_data["ulp_id"] = ""
+ event_identified = EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED
+
+ self._trigger_event(event_identified, event_data)
+ self.async_write_ha_state()
+
+
+EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
+ ProtectEventEntityDescription(
+ key="doorbell",
+ translation_key="doorbell",
+ device_class=EventDeviceClass.DOORBELL,
+ icon="mdi:doorbell-video",
+ ufp_required_field="feature_flags.is_doorbell",
+ ufp_event_obj="last_ring_event",
+ event_types=[EVENT_TYPE_DOORBELL_RING],
+ entity_class=ProtectDeviceRingEventEntity,
+ ),
+ ProtectEventEntityDescription(
+ key="nfc",
+ translation_key="nfc",
+ device_class=EventDeviceClass.DOORBELL,
+ icon="mdi:nfc",
+ ufp_required_field="feature_flags.support_nfc",
+ ufp_event_obj="last_nfc_card_scanned_event",
+ event_types=[EVENT_TYPE_NFC_SCANNED],
+ entity_class=ProtectDeviceNFCEventEntity,
+ ),
+ ProtectEventEntityDescription(
+ key="fingerprint",
+ translation_key="fingerprint",
+ device_class=EventDeviceClass.DOORBELL,
+ icon="mdi:fingerprint",
+ ufp_required_field="feature_flags.has_fingerprint_sensor",
+ ufp_event_obj="last_fingerprint_identified_event",
+ event_types=[
+ EVENT_TYPE_FINGERPRINT_IDENTIFIED,
+ EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED,
+ ],
+ entity_class=ProtectDeviceFingerprintEventEntity,
+ ),
+)
+
+
@callback
def _async_event_entities(
data: ProtectData,
ufp_device: ProtectAdoptableDeviceModel | None = None,
) -> list[ProtectDeviceEntity]:
- entities: list[ProtectDeviceEntity] = []
- for device in data.get_cameras() if ufp_device is None else [ufp_device]:
- entities.extend(
- ProtectDeviceEventEntity(data, device, description)
- for description in EVENT_DESCRIPTIONS
- if description.has_required(device)
- )
- return entities
+ return [
+ description.entity_class(data, device, description)
+ for device in (data.get_cameras() if ufp_device is None else [ufp_device])
+ for description in EVENT_DESCRIPTIONS
+ if description.has_required(device)
+ ]
async def async_setup_entry(
diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json
index 8ba35aad93b..e8a8c062800 100644
--- a/homeassistant/components/unifiprotect/manifest.json
+++ b/homeassistant/components/unifiprotect/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "unifiprotect",
"name": "UniFi Protect",
- "codeowners": [],
+ "codeowners": ["@RaHehl"],
"config_flow": true,
"dependencies": ["http", "repairs"],
"dhcp": [
@@ -40,7 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
- "requirements": ["uiprotect==6.6.0", "unifi-discovery==1.2.0"],
+ "requirements": ["uiprotect==6.6.5", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py
index f6aacf81161..767128337ba 100644
--- a/homeassistant/components/unifiprotect/number.py
+++ b/homeassistant/components/unifiprotect/number.py
@@ -124,7 +124,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
name="Infrared custom lux trigger",
icon="mdi:white-balance-sunny",
entity_category=EntityCategory.CONFIG,
- ufp_min=1,
+ ufp_min=0,
ufp_max=30,
ufp_step=1,
ufp_required_field="feature_flags.has_led_ir",
diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py
index a91a94aa629..09187e023a1 100644
--- a/homeassistant/components/unifiprotect/sensor.py
+++ b/homeassistant/components/unifiprotect/sensor.py
@@ -245,7 +245,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
name="Recording mode",
icon="mdi:video-outline",
entity_category=EntityCategory.DIAGNOSTIC,
- ufp_value="recording_settings.mode",
+ ufp_value="recording_settings.mode.value",
ufp_perm=PermRequired.NO_WRITE,
),
ProtectSensorEntityDescription(
@@ -254,7 +254,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
icon="mdi:circle-opacity",
entity_category=EntityCategory.DIAGNOSTIC,
ufp_required_field="feature_flags.has_led_ir",
- ufp_value="isp_settings.ir_led_mode",
+ ufp_value="isp_settings.ir_led_mode.value",
ufp_perm=PermRequired.NO_WRITE,
),
ProtectSensorEntityDescription(
diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json
index 9238c825390..8ecb4076409 100644
--- a/homeassistant/components/unifiprotect/strings.json
+++ b/homeassistant/components/unifiprotect/strings.json
@@ -137,6 +137,7 @@
},
"event": {
"doorbell": {
+ "name": "Doorbell",
"state_attributes": {
"event_type": {
"state": {
@@ -144,6 +145,27 @@
}
}
}
+ },
+ "nfc": {
+ "name": "NFC",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "scanned": "Scanned"
+ }
+ }
+ }
+ },
+ "fingerprint": {
+ "name": "Fingerprint",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "identified": "Identified",
+ "not_identified": "Not identified"
+ }
+ }
+ }
}
}
},
@@ -182,7 +204,7 @@
"fields": {
"device_id": {
"name": "Chime",
- "description": "The chimes to link to the doorbells to."
+ "description": "The chimes to link to the doorbells."
},
"doorbells": {
"name": "Doorbells",
diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py
index 00128492c67..9bf6ed024f5 100644
--- a/homeassistant/components/unifiprotect/views.py
+++ b/homeassistant/components/unifiprotect/views.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
from aiohttp import web
@@ -30,7 +30,9 @@ def async_generate_thumbnail_url(
) -> str:
"""Generate URL for event thumbnail."""
- url_format = ThumbnailProxyView.url or "{nvr_id}/{event_id}"
+ url_format = ThumbnailProxyView.url
+ if TYPE_CHECKING:
+ assert url_format is not None
url = url_format.format(nvr_id=nvr_id, event_id=event_id)
params = {}
@@ -50,7 +52,9 @@ def async_generate_event_video_url(event: Event) -> str:
if event.start is None or event.end is None:
raise ValueError("Event is ongoing")
- url_format = VideoProxyView.url or "{nvr_id}/{camera_id}/{start}/{end}"
+ url_format = VideoProxyView.url
+ if TYPE_CHECKING:
+ assert url_format is not None
return url_format.format(
nvr_id=event.api.bootstrap.nvr.id,
camera_id=event.camera_id,
@@ -59,6 +63,19 @@ def async_generate_event_video_url(event: Event) -> str:
)
+@callback
+def async_generate_proxy_event_video_url(
+ nvr_id: str,
+ event_id: str,
+) -> str:
+ """Generate proxy URL for event video."""
+
+ url_format = VideoEventProxyView.url
+ if TYPE_CHECKING:
+ assert url_format is not None
+ return url_format.format(nvr_id=nvr_id, event_id=event_id)
+
+
@callback
def _client_error(message: Any, code: HTTPStatus) -> web.Response:
_LOGGER.warning("Client error (%s): %s", code.value, message)
@@ -107,6 +124,27 @@ class ProtectProxyView(HomeAssistantView):
return data
return _404("Invalid NVR ID")
+ @callback
+ def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None:
+ if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None:
+ return camera
+
+ entity_registry = er.async_get(self.hass)
+ device_registry = dr.async_get(self.hass)
+
+ if (entity := entity_registry.async_get(camera_id)) is None or (
+ device := device_registry.async_get(entity.device_id or "")
+ ) is None:
+ return None
+
+ macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC]
+ for mac in macs:
+ if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None:
+ if isinstance(ufp_device, Camera):
+ camera = ufp_device
+ break
+ return camera
+
class ThumbnailProxyView(ProtectProxyView):
"""View to proxy event thumbnails from UniFi Protect."""
@@ -156,27 +194,6 @@ class VideoProxyView(ProtectProxyView):
url = "/api/unifiprotect/video/{nvr_id}/{camera_id}/{start}/{end}"
name = "api:unifiprotect_thumbnail"
- @callback
- def _async_get_camera(self, data: ProtectData, camera_id: str) -> Camera | None:
- if (camera := data.api.bootstrap.cameras.get(camera_id)) is not None:
- return camera
-
- entity_registry = er.async_get(self.hass)
- device_registry = dr.async_get(self.hass)
-
- if (entity := entity_registry.async_get(camera_id)) is None or (
- device := device_registry.async_get(entity.device_id or "")
- ) is None:
- return None
-
- macs = [c[1] for c in device.connections if c[0] == dr.CONNECTION_NETWORK_MAC]
- for mac in macs:
- if (ufp_device := data.api.bootstrap.get_device_from_mac(mac)) is not None:
- if isinstance(ufp_device, Camera):
- camera = ufp_device
- break
- return camera
-
async def get(
self, request: web.Request, nvr_id: str, camera_id: str, start: str, end: str
) -> web.StreamResponse:
@@ -226,3 +243,56 @@ class VideoProxyView(ProtectProxyView):
if response.prepared:
await response.write_eof()
return response
+
+
+class VideoEventProxyView(ProtectProxyView):
+ """View to proxy video clips for events from UniFi Protect."""
+
+ url = "/api/unifiprotect/video/{nvr_id}/{event_id}"
+ name = "api:unifiprotect_videoEventView"
+
+ async def get(
+ self, request: web.Request, nvr_id: str, event_id: str
+ ) -> web.StreamResponse:
+ """Get Camera Video clip for an event."""
+
+ data = self._get_data_or_404(nvr_id)
+ if isinstance(data, web.Response):
+ return data
+
+ try:
+ event = await data.api.get_event(event_id)
+ except ClientError:
+ return _404(f"Invalid event ID: {event_id}")
+ if event.start is None or event.end is None:
+ return _400("Event is still ongoing")
+ camera = self._async_get_camera(data, str(event.camera_id))
+ if camera is None:
+ return _404(f"Invalid camera ID: {event.camera_id}")
+ if not camera.can_read_media(data.api.bootstrap.auth_user):
+ return _403(f"User cannot read media from camera: {camera.id}")
+
+ response = web.StreamResponse(
+ status=200,
+ reason="OK",
+ headers={
+ "Content-Type": "video/mp4",
+ },
+ )
+
+ async def iterator(total: int, chunk: bytes | None) -> None:
+ if not response.prepared:
+ response.content_length = total
+ await response.prepare(request)
+
+ if chunk is not None:
+ await response.write(chunk)
+
+ try:
+ await camera.get_video(event.start, event.end, iterator_callback=iterator)
+ except ClientError as err:
+ return _404(err)
+
+ if response.prepared:
+ await response.write_eof()
+ return response
diff --git a/homeassistant/components/upc_connect/manifest.json b/homeassistant/components/upc_connect/manifest.json
index 02b852ec3a6..1874e5db028 100644
--- a/homeassistant/components/upc_connect/manifest.json
+++ b/homeassistant/components/upc_connect/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/upc_connect",
"iot_class": "local_polling",
"loggers": ["connect_box"],
+ "quality_scale": "legacy",
"requirements": ["connect-box==0.3.1"]
}
diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json
index 254409cff7e..67e57f46986 100644
--- a/homeassistant/components/uptimerobot/manifest.json
+++ b/homeassistant/components/uptimerobot/manifest.json
@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/uptimerobot",
"iot_class": "cloud_polling",
"loggers": ["pyuptimerobot"],
- "quality_scale": "platinum",
"requirements": ["pyuptimerobot==22.2.0"]
}
diff --git a/homeassistant/components/usgs_earthquakes_feed/manifest.json b/homeassistant/components/usgs_earthquakes_feed/manifest.json
index ffb9412703f..ea68d00e2a9 100644
--- a/homeassistant/components/usgs_earthquakes_feed/manifest.json
+++ b/homeassistant/components/usgs_earthquakes_feed/manifest.json
@@ -6,5 +6,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aio_geojson_usgs_earthquakes"],
+ "quality_scale": "legacy",
"requirements": ["aio-geojson-usgs-earthquakes==0.3"]
}
diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py
index 19ef3c1f3a8..9c13aa1984a 100644
--- a/homeassistant/components/utility_meter/sensor.py
+++ b/homeassistant/components/utility_meter/sensor.py
@@ -27,6 +27,7 @@ from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
CONF_UNIQUE_ID,
+ EVENT_CORE_CONFIG_UPDATE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
@@ -404,6 +405,10 @@ class UtilityMeterSensor(RestoreSensor):
self._tariff = tariff
self._tariff_entity = tariff_entity
self._next_reset = None
+ self._current_tz = None
+ self._config_scheduler()
+
+ def _config_scheduler(self):
self.scheduler = (
CronSim(
self._cron_pattern,
@@ -565,6 +570,7 @@ class UtilityMeterSensor(RestoreSensor):
self._next_reset,
)
)
+ self.async_write_ha_state()
async def _async_reset_meter(self, event):
"""Reset the utility meter status."""
@@ -601,6 +607,10 @@ class UtilityMeterSensor(RestoreSensor):
"""Handle entity which will be added."""
await super().async_added_to_hass()
+ # track current timezone in case it changes
+ # and we need to reconfigure the scheduler
+ self._current_tz = self.hass.config.time_zone
+
await self._program_reset()
self.async_on_remove(
@@ -655,6 +665,19 @@ class UtilityMeterSensor(RestoreSensor):
self.async_on_remove(async_at_started(self.hass, async_source_tracking))
+ async def async_track_time_zone(event):
+ """Reconfigure Scheduler after time zone changes."""
+
+ if self._current_tz != self.hass.config.time_zone:
+ self._current_tz = self.hass.config.time_zone
+
+ self._config_scheduler()
+ await self._program_reset()
+
+ self.async_on_remove(
+ self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, async_track_time_zone)
+ )
+
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
if self._collecting:
diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json
index e05789aece1..4a8ae415a83 100644
--- a/homeassistant/components/utility_meter/strings.json
+++ b/homeassistant/components/utility_meter/strings.json
@@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
- "title": "Add Utility Meter",
+ "title": "Create Utility Meter",
"description": "Create a sensor which tracks consumption of various utilities (e.g., energy, gas, water, heating) over a configured period of time, typically monthly. The utility meter sensor optionally supports splitting the consumption by tariffs, in that case one sensor for each tariff is created as well as a select entity to choose the current tariff.",
"data": {
"always_available": "Sensor always available",
diff --git a/homeassistant/components/uvc/manifest.json b/homeassistant/components/uvc/manifest.json
index c72b865b5ef..aeb9b6068ea 100644
--- a/homeassistant/components/uvc/manifest.json
+++ b/homeassistant/components/uvc/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/uvc",
"iot_class": "local_polling",
"loggers": ["uvcclient"],
+ "quality_scale": "legacy",
"requirements": ["uvcclient==0.12.1"]
}
diff --git a/homeassistant/components/vasttrafik/manifest.json b/homeassistant/components/vasttrafik/manifest.json
index 336d06e182c..73b773720ad 100644
--- a/homeassistant/components/vasttrafik/manifest.json
+++ b/homeassistant/components/vasttrafik/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/vasttrafik",
"iot_class": "cloud_polling",
"loggers": ["vasttrafik"],
+ "quality_scale": "legacy",
"requirements": ["vtjp==0.2.1"]
}
diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json
index cd93b07f748..84262ebd61c 100644
--- a/homeassistant/components/velbus/manifest.json
+++ b/homeassistant/components/velbus/manifest.json
@@ -13,7 +13,7 @@
"velbus-packet",
"velbus-protocol"
],
- "requirements": ["velbus-aio==2024.11.0"],
+ "requirements": ["velbus-aio==2024.11.1"],
"usb": [
{
"vid": "10CF",
diff --git a/homeassistant/components/versasense/manifest.json b/homeassistant/components/versasense/manifest.json
index 421a46bc2f6..1f1ee9e6b9c 100644
--- a/homeassistant/components/versasense/manifest.json
+++ b/homeassistant/components/versasense/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/versasense",
"iot_class": "local_polling",
"loggers": ["pyversasense"],
+ "quality_scale": "legacy",
"requirements": ["pyversasense==0.0.6"]
}
diff --git a/homeassistant/components/viaggiatreno/manifest.json b/homeassistant/components/viaggiatreno/manifest.json
index 904f9c0bebf..584742c8c59 100644
--- a/homeassistant/components/viaggiatreno/manifest.json
+++ b/homeassistant/components/viaggiatreno/manifest.json
@@ -3,5 +3,6 @@
"name": "Trenitalia ViaggiaTreno",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/viaggiatreno",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py
index b787de20773..1800704a16f 100644
--- a/homeassistant/components/vicare/fan.py
+++ b/homeassistant/components/vicare/fan.py
@@ -29,6 +29,7 @@ from homeassistant.util.percentage import (
from .const import DEVICE_LIST, DOMAIN
from .entity import ViCareEntity
+from .types import ViCareDevice
from .utils import get_device_serial
_LOGGER = logging.getLogger(__name__)
@@ -90,6 +91,17 @@ ORDERED_NAMED_FAN_SPEEDS = [
]
+def _build_entities(
+ device_list: list[ViCareDevice],
+) -> list[ViCareFan]:
+ """Create ViCare climate entities for a device."""
+ return [
+ ViCareFan(get_device_serial(device.api), device.config, device.api)
+ for device in device_list
+ if isinstance(device.api, PyViCareVentilationDevice)
+ ]
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -100,27 +112,18 @@ async def async_setup_entry(
device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST]
async_add_entities(
- [
- ViCareFan(get_device_serial(device.api), device.config, device.api)
- for device in device_list
- if isinstance(device.api, PyViCareVentilationDevice)
- ]
+ await hass.async_add_executor_job(
+ _build_entities,
+ device_list,
+ )
)
class ViCareFan(ViCareEntity, FanEntity):
"""Representation of the ViCare ventilation device."""
- _attr_preset_modes = list[str](
- [
- VentilationMode.PERMANENT,
- VentilationMode.VENTILATION,
- VentilationMode.SENSOR_DRIVEN,
- VentilationMode.SENSOR_OVERRIDE,
- ]
- )
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
- _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
+ _attr_supported_features = FanEntityFeature.SET_SPEED
_attr_translation_key = "ventilation"
_enable_turn_on_off_backwards_compatibility = False
@@ -134,6 +137,15 @@ class ViCareFan(ViCareEntity, FanEntity):
super().__init__(
self._attr_translation_key, device_serial, device_config, device
)
+ # init presets
+ supported_modes = list[str](self._api.getAvailableModes())
+ self._attr_preset_modes = [
+ mode
+ for mode in VentilationMode
+ if VentilationMode.to_vicare_mode(mode) in supported_modes
+ ]
+ if len(self._attr_preset_modes) > 0:
+ self._attr_supported_features |= FanEntityFeature.PRESET_MODE
def update(self) -> None:
"""Update state of fan."""
@@ -161,6 +173,30 @@ class ViCareFan(ViCareEntity, FanEntity):
# Viessmann ventilation unit cannot be turned off
return True
+ @property
+ def icon(self) -> str | None:
+ """Return the icon to use in the frontend."""
+ if hasattr(self, "_attr_preset_mode"):
+ if self._attr_preset_mode == VentilationMode.VENTILATION:
+ return "mdi:fan-clock"
+ if self._attr_preset_mode in [
+ VentilationMode.SENSOR_DRIVEN,
+ VentilationMode.SENSOR_OVERRIDE,
+ ]:
+ return "mdi:fan-auto"
+ if self._attr_preset_mode == VentilationMode.PERMANENT:
+ if self._attr_percentage == 0:
+ return "mdi:fan-off"
+ if self._attr_percentage is not None:
+ level = 1 + ORDERED_NAMED_FAN_SPEEDS.index(
+ percentage_to_ordered_list_item(
+ ORDERED_NAMED_FAN_SPEEDS, self._attr_percentage
+ )
+ )
+ if level < 4: # fan-speed- only supports 1-3
+ return f"mdi:fan-speed-{level}"
+ return "mdi:fan"
+
def set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
if self._attr_preset_mode != str(VentilationMode.PERMANENT):
diff --git a/homeassistant/components/vicare/quality_scale.yaml b/homeassistant/components/vicare/quality_scale.yaml
new file mode 100644
index 00000000000..436e046204f
--- /dev/null
+++ b/homeassistant/components/vicare/quality_scale.yaml
@@ -0,0 +1,49 @@
+rules:
+ # Bronze
+ config-flow:
+ status: todo
+ comment: data_description is missing.
+ test-before-configure: done
+ unique-config-entry:
+ status: todo
+ comment: Uniqueness is not checked yet.
+ config-flow-test-coverage: done
+ runtime-data:
+ status: todo
+ comment: runtime_data is not used yet.
+ test-before-setup: done
+ appropriate-polling: done
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup:
+ status: exempt
+ comment: Entities of this integration does not explicitly subscribe to events.
+ dependency-transparency: done
+ action-setup:
+ status: todo
+ comment: service registered in climate async_setup_entry.
+ common-modules:
+ status: done
+ comment: No coordinator is used, data update is centrally handled by the library.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions:
+ status: todo
+ comment: removal instructions missing
+ docs-actions: done
+ brands: done
+ # Silver
+ integration-owner: done
+ reauthentication-flow: done
+ config-entry-unloading: done
+ # Gold
+ devices: done
+ diagnostics: done
+ entity-category: done
+ dynamic-devices: done
+ entity-device-class: done
+ entity-translations: done
+ entity-disabled-by-default: done
+ repair-issues:
+ status: exempt
+ comment: This integration does not raise any repairable issues.
diff --git a/homeassistant/components/vivotek/manifest.json b/homeassistant/components/vivotek/manifest.json
index 5a33ca09908..f0b622afcad 100644
--- a/homeassistant/components/vivotek/manifest.json
+++ b/homeassistant/components/vivotek/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/vivotek",
"iot_class": "local_polling",
"loggers": ["libpyvivotek"],
+ "quality_scale": "legacy",
"requirements": ["libpyvivotek==0.4.0"]
}
diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json
index e6812ed58b1..91b2ff46495 100644
--- a/homeassistant/components/vizio/manifest.json
+++ b/homeassistant/components/vizio/manifest.json
@@ -7,7 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyvizio"],
- "quality_scale": "platinum",
"requirements": ["pyvizio==0.1.61"],
"zeroconf": ["_viziocast._tcp.local."]
}
diff --git a/homeassistant/components/vlc/manifest.json b/homeassistant/components/vlc/manifest.json
index 7e4fb7b2a4f..a31fe49859c 100644
--- a/homeassistant/components/vlc/manifest.json
+++ b/homeassistant/components/vlc/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/vlc",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["python-vlc==3.0.18122"]
}
diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json
index 29cb3c070ab..4acafc8df3a 100644
--- a/homeassistant/components/vodafone_station/manifest.json
+++ b/homeassistant/components/vodafone_station/manifest.json
@@ -7,6 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiovodafone"],
- "quality_scale": "silver",
"requirements": ["aiovodafone==0.6.1"]
}
diff --git a/homeassistant/components/voicerss/manifest.json b/homeassistant/components/voicerss/manifest.json
index bfc61365dc0..1e7da9d220d 100644
--- a/homeassistant/components/voicerss/manifest.json
+++ b/homeassistant/components/voicerss/manifest.json
@@ -3,5 +3,6 @@
"name": "VoiceRSS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/voicerss",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json
index 964193fca53..7dd2e797058 100644
--- a/homeassistant/components/voip/manifest.json
+++ b/homeassistant/components/voip/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/voip",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["voip-utils==0.1.0"]
+ "requirements": ["voip-utils==0.2.1"]
}
diff --git a/homeassistant/components/volkszaehler/manifest.json b/homeassistant/components/volkszaehler/manifest.json
index e9070d0fa87..1427f330e77 100644
--- a/homeassistant/components/volkszaehler/manifest.json
+++ b/homeassistant/components/volkszaehler/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/volkszaehler",
"iot_class": "local_polling",
"loggers": ["volkszaehler"],
+ "quality_scale": "legacy",
"requirements": ["volkszaehler==0.4.0"]
}
diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json
index 47ab7ec53cb..554a82e9c2c 100644
--- a/homeassistant/components/vulcan/manifest.json
+++ b/homeassistant/components/vulcan/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/vulcan",
"iot_class": "cloud_polling",
- "quality_scale": "silver",
"requirements": ["vulcan-api==2.3.2"]
}
diff --git a/homeassistant/components/vultr/manifest.json b/homeassistant/components/vultr/manifest.json
index dc3cd3571eb..713485e7931 100644
--- a/homeassistant/components/vultr/manifest.json
+++ b/homeassistant/components/vultr/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/vultr",
"iot_class": "cloud_polling",
"loggers": ["vultr"],
+ "quality_scale": "legacy",
"requirements": ["vultr==0.1.2"]
}
diff --git a/homeassistant/components/w800rf32/manifest.json b/homeassistant/components/w800rf32/manifest.json
index 769eb96b3c0..4d5074e72c2 100644
--- a/homeassistant/components/w800rf32/manifest.json
+++ b/homeassistant/components/w800rf32/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/w800rf32",
"iot_class": "local_push",
"loggers": ["W800rf32"],
+ "quality_scale": "legacy",
"requirements": ["pyW800rf32==0.4"]
}
diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py
index 4bfe1ce4481..dbd697f2367 100644
--- a/homeassistant/components/water_heater/__init__.py
+++ b/homeassistant/components/water_heater/__init__.py
@@ -25,12 +25,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.temperature import display_temp as show_temp
@@ -70,18 +64,6 @@ class WaterHeaterEntityFeature(IntFlag):
ON_OFF = 8
-# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
-# Please use the WaterHeaterEntityFeature enum instead.
-_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum(
- WaterHeaterEntityFeature.TARGET_TEMPERATURE, "2025.1"
-)
-_DEPRECATED_SUPPORT_OPERATION_MODE = DeprecatedConstantEnum(
- WaterHeaterEntityFeature.OPERATION_MODE, "2025.1"
-)
-_DEPRECATED_SUPPORT_AWAY_MODE = DeprecatedConstantEnum(
- WaterHeaterEntityFeature.AWAY_MODE, "2025.1"
-)
-
ATTR_MAX_TEMP = "max_temp"
ATTR_MIN_TEMP = "min_temp"
ATTR_AWAY_MODE = "away_mode"
@@ -437,11 +419,3 @@ async def async_service_temperature_set(
kwargs[value] = temp
await entity.async_set_temperature(**kwargs)
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = ft.partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json
index 9e01f7e6a05..2bf72acb047 100644
--- a/homeassistant/components/waterfurnace/manifest.json
+++ b/homeassistant/components/waterfurnace/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/waterfurnace",
"iot_class": "cloud_polling",
"loggers": ["waterfurnace"],
+ "quality_scale": "legacy",
"requirements": ["waterfurnace==1.1.0"]
}
diff --git a/homeassistant/components/watson_iot/manifest.json b/homeassistant/components/watson_iot/manifest.json
index 702c5492246..a457dcc44b1 100644
--- a/homeassistant/components/watson_iot/manifest.json
+++ b/homeassistant/components/watson_iot/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/watson_iot",
"iot_class": "cloud_push",
"loggers": ["ibmiotf", "paho_mqtt"],
+ "quality_scale": "legacy",
"requirements": ["ibmiotf==0.3.4"]
}
diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json
index f26fc006561..ecc3d97be46 100644
--- a/homeassistant/components/watson_tts/manifest.json
+++ b/homeassistant/components/watson_tts/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/watson_tts",
"iot_class": "cloud_push",
"loggers": ["ibm_cloud_sdk_core", "ibm_watson"],
+ "quality_scale": "legacy",
"requirements": ["ibm-watson==5.2.2"]
}
diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json
index 679bad9b9f5..6c826c2f997 100644
--- a/homeassistant/components/webostv/manifest.json
+++ b/homeassistant/components/webostv/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/webostv",
"iot_class": "local_push",
"loggers": ["aiowebostv"],
- "quality_scale": "platinum",
"requirements": ["aiowebostv==0.4.2"],
"ssdp": [
{
diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json
index ef89a2f1acb..61d6a110dbd 100644
--- a/homeassistant/components/weheat/manifest.json
+++ b/homeassistant/components/weheat/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/weheat",
"iot_class": "cloud_polling",
- "requirements": ["weheat==2024.11.02"]
+ "requirements": ["weheat==2024.11.26"]
}
diff --git a/homeassistant/components/wilight/manifest.json b/homeassistant/components/wilight/manifest.json
index 8da0ffd9241..7f7e16d55fb 100644
--- a/homeassistant/components/wilight/manifest.json
+++ b/homeassistant/components/wilight/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/wilight",
"iot_class": "local_polling",
"loggers": ["pywilight"],
- "quality_scale": "silver",
"requirements": ["pywilight==0.0.74"],
"ssdp": [
{
diff --git a/homeassistant/components/wirelesstag/manifest.json b/homeassistant/components/wirelesstag/manifest.json
index 9735c833453..1ff9403d3bc 100644
--- a/homeassistant/components/wirelesstag/manifest.json
+++ b/homeassistant/components/wirelesstag/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/wirelesstag",
"iot_class": "cloud_push",
"loggers": ["wirelesstagpy"],
+ "quality_scale": "legacy",
"requirements": ["wirelesstagpy==0.8.1"]
}
diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json
index f9e8328ae53..57d4bafdc7b 100644
--- a/homeassistant/components/withings/manifest.json
+++ b/homeassistant/components/withings/manifest.json
@@ -8,6 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/withings",
"iot_class": "cloud_push",
"loggers": ["aiowithings"],
- "quality_scale": "platinum",
"requirements": ["aiowithings==3.1.3"]
}
diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json
index bb5527bc467..7b1ecdcdb6b 100644
--- a/homeassistant/components/wiz/manifest.json
+++ b/homeassistant/components/wiz/manifest.json
@@ -26,6 +26,5 @@
],
"documentation": "https://www.home-assistant.io/integrations/wiz",
"iot_class": "local_push",
- "quality_scale": "platinum",
"requirements": ["pywizlight==0.5.14"]
}
diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json
index 71939127356..c731f8181af 100644
--- a/homeassistant/components/wled/manifest.json
+++ b/homeassistant/components/wled/manifest.json
@@ -6,7 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/wled",
"integration_type": "device",
"iot_class": "local_push",
- "quality_scale": "platinum",
"requirements": ["wled==0.20.2"],
"zeroconf": ["_wled._tcp.local."]
}
diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py
index 727c4340ea3..2036d685d31 100644
--- a/homeassistant/components/workday/config_flow.py
+++ b/homeassistant/components/workday/config_flow.py
@@ -67,12 +67,14 @@ def add_province_and_language_to_schema(
_country = country_holidays(country=country)
if country_default_language := (_country.default_language):
selectable_languages = _country.supported_languages
- new_selectable_languages = [lang[:2] for lang in selectable_languages]
+ new_selectable_languages = list(selectable_languages)
language_schema = {
vol.Optional(
CONF_LANGUAGE, default=country_default_language
): LanguageSelector(
- LanguageSelectorConfig(languages=new_selectable_languages)
+ LanguageSelectorConfig(
+ languages=new_selectable_languages, native_name=True
+ )
)
}
diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json
index b02db734729..ea08bfe1717 100644
--- a/homeassistant/components/workday/manifest.json
+++ b/homeassistant/components/workday/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
- "requirements": ["holidays==0.60"]
+ "requirements": ["holidays==0.61"]
}
diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json
index f3b966e28ea..e74dc0160d9 100644
--- a/homeassistant/components/workday/strings.json
+++ b/homeassistant/components/workday/strings.json
@@ -86,18 +86,19 @@
"options": {
"armed_forces": "Armed forces",
"bank": "Bank",
+ "catholic": "Catholic",
+ "chinese": "Chinese",
+ "christian": "Christian",
"government": "Government",
"half_day": "Half day",
+ "hebrew": "Hebrew",
+ "hindu": "Hindu",
+ "islamic": "Islamic",
"optional": "Optional",
"public": "Public",
"school": "School",
"unofficial": "Unofficial",
- "workday": "Workday",
- "chinese": "Chinese",
- "christian": "Christian",
- "hebrew": "Hebrew",
- "hindu": "Hindu",
- "islamic": "Islamic"
+ "workday": "Workday"
}
},
"days": {
diff --git a/homeassistant/components/worldtidesinfo/manifest.json b/homeassistant/components/worldtidesinfo/manifest.json
index 962e63617f4..c873f2f08f3 100644
--- a/homeassistant/components/worldtidesinfo/manifest.json
+++ b/homeassistant/components/worldtidesinfo/manifest.json
@@ -3,5 +3,6 @@
"name": "World Tides",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/worldtidesinfo",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/worxlandroid/manifest.json b/homeassistant/components/worxlandroid/manifest.json
index a74228295c8..7a65b3b91b6 100644
--- a/homeassistant/components/worxlandroid/manifest.json
+++ b/homeassistant/components/worxlandroid/manifest.json
@@ -3,5 +3,6 @@
"name": "Worx Landroid",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/worxlandroid",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/ws66i/manifest.json b/homeassistant/components/ws66i/manifest.json
index d259823d5af..c465a9f9f37 100644
--- a/homeassistant/components/ws66i/manifest.json
+++ b/homeassistant/components/ws66i/manifest.json
@@ -5,6 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ws66i",
"iot_class": "local_polling",
- "quality_scale": "silver",
"requirements": ["pyws66i==1.1"]
}
diff --git a/homeassistant/components/wsdot/manifest.json b/homeassistant/components/wsdot/manifest.json
index 4444cfbac4a..9b7746eea74 100644
--- a/homeassistant/components/wsdot/manifest.json
+++ b/homeassistant/components/wsdot/manifest.json
@@ -3,5 +3,6 @@
"name": "Washington State Department of Transportation (WSDOT)",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/wsdot",
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/x10/manifest.json b/homeassistant/components/x10/manifest.json
index 258080dc374..517bab07f6c 100644
--- a/homeassistant/components/x10/manifest.json
+++ b/homeassistant/components/x10/manifest.json
@@ -3,5 +3,6 @@
"name": "Heyu X10",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/x10",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/xeoma/manifest.json b/homeassistant/components/xeoma/manifest.json
index d66177ca214..839724cc781 100644
--- a/homeassistant/components/xeoma/manifest.json
+++ b/homeassistant/components/xeoma/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/xeoma",
"iot_class": "local_polling",
"loggers": ["pyxeoma"],
+ "quality_scale": "legacy",
"requirements": ["pyxeoma==1.4.2"]
}
diff --git a/homeassistant/components/xiaomi/manifest.json b/homeassistant/components/xiaomi/manifest.json
index ef7085f2aa4..45540db47f3 100644
--- a/homeassistant/components/xiaomi/manifest.json
+++ b/homeassistant/components/xiaomi/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/xiaomi",
- "iot_class": "local_polling"
+ "iot_class": "local_polling",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py
index 3f6f4e9b50b..aafcba97487 100644
--- a/homeassistant/components/xiaomi_miio/sensor.py
+++ b/homeassistant/components/xiaomi_miio/sensor.py
@@ -24,7 +24,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
- AREA_SQUARE_METERS,
ATTR_BATTERY_LEVEL,
ATTR_TEMPERATURE,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
@@ -37,6 +36,7 @@ from homeassistant.const import (
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
EntityCategory,
+ UnitOfArea,
UnitOfPower,
UnitOfPressure,
UnitOfTemperature,
@@ -622,7 +622,7 @@ VACUUM_SENSORS = {
entity_category=EntityCategory.DIAGNOSTIC,
),
f"last_clean_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription(
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
icon="mdi:texture-box",
key=ATTR_LAST_CLEAN_AREA,
parent_key=VacuumCoordinatorDataAttributes.last_clean_details,
@@ -639,7 +639,7 @@ VACUUM_SENSORS = {
entity_category=EntityCategory.DIAGNOSTIC,
),
f"current_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription(
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
icon="mdi:texture-box",
key=ATTR_STATUS_CLEAN_AREA,
parent_key=VacuumCoordinatorDataAttributes.status,
@@ -657,7 +657,7 @@ VACUUM_SENSORS = {
entity_category=EntityCategory.DIAGNOSTIC,
),
f"clean_history_{ATTR_CLEAN_HISTORY_TOTAL_AREA}": XiaomiMiioSensorDescription(
- native_unit_of_measurement=AREA_SQUARE_METERS,
+ native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
icon="mdi:texture-box",
key=ATTR_CLEAN_HISTORY_TOTAL_AREA,
parent_key=VacuumCoordinatorDataAttributes.clean_history_status,
diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json
index 31fe547b162..bafc1ec543b 100644
--- a/homeassistant/components/xiaomi_miio/strings.json
+++ b/homeassistant/components/xiaomi_miio/strings.json
@@ -216,22 +216,22 @@
"name": "Air quality index"
},
"filter_life_remaining": {
- "name": "Filter lifetime remaining"
+ "name": "Filter life remaining"
},
"filter_hours_used": {
"name": "Filter use"
},
"filter_left_time": {
- "name": "Filter lifetime left"
+ "name": "Filter lifetime remaining"
},
"dust_filter_life_remaining": {
- "name": "Dust filter lifetime remaining"
+ "name": "Dust filter life remaining"
},
"dust_filter_life_remaining_days": {
"name": "Dust filter lifetime remaining days"
},
"upper_filter_life_remaining": {
- "name": "Upper filter lifetime remaining"
+ "name": "Upper filter life remaining"
},
"upper_filter_life_remaining_days": {
"name": "Upper filter lifetime remaining days"
@@ -276,16 +276,16 @@
"name": "Total dust collection count"
},
"main_brush_left": {
- "name": "Main brush left"
+ "name": "Main brush remaining"
},
"side_brush_left": {
- "name": "Side brush left"
+ "name": "Side brush remaining"
},
"filter_left": {
- "name": "Filter left"
+ "name": "Filter remaining"
},
"sensor_dirty_left": {
- "name": "Sensor dirty left"
+ "name": "Sensor dirty remaining"
}
},
"switch": {
diff --git a/homeassistant/components/xiaomi_tv/manifest.json b/homeassistant/components/xiaomi_tv/manifest.json
index 2e913e80fdc..8335adff333 100644
--- a/homeassistant/components/xiaomi_tv/manifest.json
+++ b/homeassistant/components/xiaomi_tv/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/xiaomi_tv",
"iot_class": "assumed_state",
"loggers": ["pymitv"],
+ "quality_scale": "legacy",
"requirements": ["pymitv==1.4.3"]
}
diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json
index 308c3d70978..d77d70ff86c 100644
--- a/homeassistant/components/xmpp/manifest.json
+++ b/homeassistant/components/xmpp/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/xmpp",
"iot_class": "cloud_push",
"loggers": ["pyasn1", "slixmpp"],
+ "quality_scale": "legacy",
"requirements": ["slixmpp==1.8.5", "emoji==2.8.0"]
}
diff --git a/homeassistant/components/xs1/manifest.json b/homeassistant/components/xs1/manifest.json
index 9f4c921642d..88a5e4427ae 100644
--- a/homeassistant/components/xs1/manifest.json
+++ b/homeassistant/components/xs1/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/xs1",
"iot_class": "local_polling",
"loggers": ["xs1_api_client"],
+ "quality_scale": "legacy",
"requirements": ["xs1-api-client==3.0.0"]
}
diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json
index 34f3a7a1728..50c2a0af457 100644
--- a/homeassistant/components/yale/manifest.json
+++ b/homeassistant/components/yale/manifest.json
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/yale",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
- "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.0"]
+ "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.1"]
}
diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json
index 1baeaeea63f..c3d1a3d97f1 100644
--- a/homeassistant/components/yalexs_ble/manifest.json
+++ b/homeassistant/components/yalexs_ble/manifest.json
@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
"iot_class": "local_push",
- "requirements": ["yalexs-ble==2.5.0"]
+ "requirements": ["yalexs-ble==2.5.1"]
}
diff --git a/homeassistant/components/yamaha/manifest.json b/homeassistant/components/yamaha/manifest.json
index 8e6ba0b8854..936028330a5 100644
--- a/homeassistant/components/yamaha/manifest.json
+++ b/homeassistant/components/yamaha/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/yamaha",
"iot_class": "local_polling",
"loggers": ["rxv"],
+ "quality_scale": "legacy",
"requirements": ["rxv==0.7.0"]
}
diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py
index a074f34c782..d6ad54c4a3d 100644
--- a/homeassistant/components/yamaha_musiccast/config_flow.py
+++ b/homeassistant/components/yamaha_musiccast/config_flow.py
@@ -10,9 +10,8 @@ from aiohttp import ClientConnectorError
from aiomusiccast import MusicCastConnectionException, MusicCastDevice
import voluptuous as vol
-from homeassistant import data_entry_flow
from homeassistant.components import ssdp
-from homeassistant.config_entries import ConfigFlow
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -33,7 +32,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
- ) -> data_entry_flow.ConfigFlowResult:
+ ) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
# Request user input, unless we are preparing discovery flow
if user_input is None:
@@ -73,9 +72,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
return self._show_setup_form(errors)
- def _show_setup_form(
- self, errors: dict | None = None
- ) -> data_entry_flow.ConfigFlowResult:
+ def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult:
"""Show the setup form to the user."""
return self.async_show_form(
step_id="user",
@@ -85,7 +82,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_ssdp(
self, discovery_info: ssdp.SsdpServiceInfo
- ) -> data_entry_flow.ConfigFlowResult:
+ ) -> ConfigFlowResult:
"""Handle ssdp discoveries."""
if not await MusicCastDevice.check_yamaha_ssdp(
discovery_info.ssdp_location, async_get_clientsession(self.hass)
@@ -117,9 +114,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
- async def async_step_confirm(
- self, user_input=None
- ) -> data_entry_flow.ConfigFlowResult:
+ async def async_step_confirm(self, user_input=None) -> ConfigFlowResult:
"""Allow the user to confirm adding the device."""
if user_input is not None:
return self.async_create_entry(
diff --git a/homeassistant/components/yandex_transport/manifest.json b/homeassistant/components/yandex_transport/manifest.json
index 1d1219d5a95..ad31d495253 100644
--- a/homeassistant/components/yandex_transport/manifest.json
+++ b/homeassistant/components/yandex_transport/manifest.json
@@ -4,5 +4,6 @@
"codeowners": ["@rishatik92", "@devbis"],
"documentation": "https://www.home-assistant.io/integrations/yandex_transport",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["aioymaps==1.2.5"]
}
diff --git a/homeassistant/components/yandextts/manifest.json b/homeassistant/components/yandextts/manifest.json
index e1ab27272ef..418516a2d09 100644
--- a/homeassistant/components/yandextts/manifest.json
+++ b/homeassistant/components/yandextts/manifest.json
@@ -3,5 +3,6 @@
"name": "Yandex TTS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/yandextts",
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json
index 8d0a2e31185..4da2e0cfc3e 100644
--- a/homeassistant/components/yeelight/manifest.json
+++ b/homeassistant/components/yeelight/manifest.json
@@ -16,7 +16,6 @@
},
"iot_class": "local_push",
"loggers": ["async_upnp_client", "yeelight"],
- "quality_scale": "platinum",
"requirements": ["yeelight==0.7.14", "async-upnp-client==0.41.0"],
"zeroconf": [
{
diff --git a/homeassistant/components/yeelightsunflower/manifest.json b/homeassistant/components/yeelightsunflower/manifest.json
index 67746e122cb..bfd185cfa72 100644
--- a/homeassistant/components/yeelightsunflower/manifest.json
+++ b/homeassistant/components/yeelightsunflower/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/yeelightsunflower",
"iot_class": "local_polling",
"loggers": ["yeelightsunflower"],
+ "quality_scale": "legacy",
"requirements": ["yeelightsunflower==0.0.10"]
}
diff --git a/homeassistant/components/yi/manifest.json b/homeassistant/components/yi/manifest.json
index d8514b251cc..24b5aaad758 100644
--- a/homeassistant/components/yi/manifest.json
+++ b/homeassistant/components/yi/manifest.json
@@ -7,5 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["aioftp"],
+ "quality_scale": "legacy",
"requirements": ["aioftp==0.21.3"]
}
diff --git a/homeassistant/components/zabbix/manifest.json b/homeassistant/components/zabbix/manifest.json
index d1823051636..9c7171bea46 100644
--- a/homeassistant/components/zabbix/manifest.json
+++ b/homeassistant/components/zabbix/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/zabbix",
"iot_class": "local_polling",
"loggers": ["pyzabbix"],
+ "quality_scale": "legacy",
"requirements": ["py-zabbix==1.1.7"]
}
diff --git a/homeassistant/components/zengge/manifest.json b/homeassistant/components/zengge/manifest.json
index 5a4525079da..03d989c5f3b 100644
--- a/homeassistant/components/zengge/manifest.json
+++ b/homeassistant/components/zengge/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/zengge",
"iot_class": "local_polling",
"loggers": ["zengge"],
+ "quality_scale": "legacy",
"requirements": ["bluepy==1.3.0", "zengge==0.2"]
}
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index 98b09f1a251..9ad92bb4bc7 100644
--- a/homeassistant/components/zeroconf/manifest.json
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["zeroconf"],
"quality_scale": "internal",
- "requirements": ["zeroconf==0.136.0"]
+ "requirements": ["zeroconf==0.136.2"]
}
diff --git a/homeassistant/components/zestimate/manifest.json b/homeassistant/components/zestimate/manifest.json
index a881adf503d..a787a9b1099 100644
--- a/homeassistant/components/zestimate/manifest.json
+++ b/homeassistant/components/zestimate/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/zestimate",
"iot_class": "cloud_polling",
+ "quality_scale": "legacy",
"requirements": ["xmltodict==0.13.0"]
}
diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py
index f3f7f38772d..9c515c315b7 100644
--- a/homeassistant/components/zha/config_flow.py
+++ b/homeassistant/components/zha/config_flow.py
@@ -70,8 +70,17 @@ UPLOADED_BACKUP_FILE = "uploaded_backup_file"
REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/"
-DEFAULT_ZHA_ZEROCONF_PORT = 6638
-ESPHOME_API_PORT = 6053
+LEGACY_ZEROCONF_PORT = 6638
+LEGACY_ZEROCONF_ESPHOME_API_PORT = 6053
+
+ZEROCONF_SERVICE_TYPE = "_zigbee-coordinator._tcp.local."
+ZEROCONF_PROPERTIES_SCHEMA = vol.Schema(
+ {
+ vol.Required("radio_type"): vol.All(str, vol.In([t.name for t in RadioType])),
+ vol.Required("serial_number"): str,
+ },
+ extra=vol.ALLOW_EXTRA,
+)
def _format_backup_choice(
@@ -617,34 +626,65 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
- # Hostname is format: livingroom.local.
- local_name = discovery_info.hostname[:-1]
- port = discovery_info.port or DEFAULT_ZHA_ZEROCONF_PORT
+ # Transform legacy zeroconf discovery into the new format
+ if discovery_info.type != ZEROCONF_SERVICE_TYPE:
+ port = discovery_info.port or LEGACY_ZEROCONF_PORT
+ name = discovery_info.name
- # Fix incorrect port for older TubesZB devices
- if "tube" in local_name and port == ESPHOME_API_PORT:
- port = DEFAULT_ZHA_ZEROCONF_PORT
+ # Fix incorrect port for older TubesZB devices
+ if "tube" in name and port == LEGACY_ZEROCONF_ESPHOME_API_PORT:
+ port = LEGACY_ZEROCONF_PORT
- if "radio_type" in discovery_info.properties:
- self._radio_mgr.radio_type = self._radio_mgr.parse_radio_type(
- discovery_info.properties["radio_type"]
+ # Determine the radio type
+ if "radio_type" in discovery_info.properties:
+ radio_type = discovery_info.properties["radio_type"]
+ elif "efr32" in name:
+ radio_type = RadioType.ezsp.name
+ elif "zigate" in name:
+ radio_type = RadioType.zigate.name
+ else:
+ radio_type = RadioType.znp.name
+
+ fallback_title = name.split("._", 1)[0]
+ title = discovery_info.properties.get("name", fallback_title)
+
+ discovery_info = zeroconf.ZeroconfServiceInfo(
+ ip_address=discovery_info.ip_address,
+ ip_addresses=discovery_info.ip_addresses,
+ port=port,
+ hostname=discovery_info.hostname,
+ type=ZEROCONF_SERVICE_TYPE,
+ name=f"{title}.{ZEROCONF_SERVICE_TYPE}",
+ properties={
+ "radio_type": radio_type,
+ # To maintain backwards compatibility
+ "serial_number": discovery_info.hostname.removesuffix(".local."),
+ },
)
- elif "efr32" in local_name:
- self._radio_mgr.radio_type = RadioType.ezsp
- else:
- self._radio_mgr.radio_type = RadioType.znp
- node_name = local_name.removesuffix(".local")
- device_path = f"socket://{discovery_info.host}:{port}"
+ try:
+ discovery_props = ZEROCONF_PROPERTIES_SCHEMA(discovery_info.properties)
+ except vol.Invalid:
+ return self.async_abort(reason="invalid_zeroconf_data")
+
+ radio_type = self._radio_mgr.parse_radio_type(discovery_props["radio_type"])
+ device_path = f"socket://{discovery_info.host}:{discovery_info.port}"
+ title = discovery_info.name.removesuffix(f".{ZEROCONF_SERVICE_TYPE}")
await self._set_unique_id_and_update_ignored_flow(
- unique_id=node_name,
+ unique_id=discovery_props["serial_number"],
device_path=device_path,
)
- self.context["title_placeholders"] = {CONF_NAME: node_name}
- self._title = device_path
+ self.context["title_placeholders"] = {CONF_NAME: title}
+ self._title = title
self._radio_mgr.device_path = device_path
+ self._radio_mgr.radio_type = radio_type
+ self._radio_mgr.device_settings = {
+ CONF_DEVICE_PATH: device_path,
+ CONF_BAUDRATE: 115200,
+ CONF_FLOW_CONTROL: None,
+ }
return await self.async_step_confirm()
diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json
index 5b3b85ced39..6ba4aab18ab 100644
--- a/homeassistant/components/zha/icons.json
+++ b/homeassistant/components/zha/icons.json
@@ -118,6 +118,12 @@
},
"exercise_day_of_week": {
"default": "mdi:wrench-clock"
+ },
+ "off_led_color": {
+ "default": "mdi:palette-outline"
+ },
+ "on_led_color": {
+ "default": "mdi:palette"
}
},
"sensor": {
@@ -206,6 +212,9 @@
},
"use_load_balancing": {
"default": "mdi:scale-balance"
+ },
+ "double_up_full": {
+ "default": "mdi:gesture-double-tap"
}
}
},
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 8736dc89549..1fbbd83bb9c 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
- "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.39"],
+ "requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.41"],
"usb": [
{
"vid": "10C4",
@@ -130,6 +130,10 @@
{
"type": "_czc._tcp.local.",
"name": "czc*"
+ },
+ {
+ "type": "_zigbee-coordinator._tcp.local.",
+ "name": "*"
}
]
}
diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json
index d0505bf2460..4706e204872 100644
--- a/homeassistant/components/zha/strings.json
+++ b/homeassistant/components/zha/strings.json
@@ -76,7 +76,8 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"not_zha_device": "This device is not a zha device",
"usb_probe_failed": "Failed to probe the usb device",
- "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this."
+ "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.",
+ "invalid_zeroconf_data": "The coordinator has invalid zeroconf service info and cannot be identified by ZHA"
}
},
"options": {
@@ -297,7 +298,7 @@
},
"reconfigure_device": {
"name": "Reconfigure device",
- "description": "Reconfigures a ZHA device (heal device). Use this if you are having issues with the device. If the device in question is a battery-powered device, ensure it is awake and accepting commands when you use this service.",
+ "description": "Reconfigures a ZHA device (heal device). Use this if you are having issues with the device. If the device in question is a battery-powered device, ensure it is awake and accepting commands when you use this action.",
"fields": {
"ieee": {
"name": "[%key:component::zha::services::permit::fields::ieee::name%]",
@@ -599,6 +600,12 @@
},
"self_test": {
"name": "Self-test"
+ },
+ "reset_summation_delivered": {
+ "name": "Reset summation delivered"
+ },
+ "restart_device": {
+ "name": "Restart device"
}
},
"climate": {
@@ -791,6 +798,30 @@
},
"valve_countdown_2": {
"name": "Irrigation time 2"
+ },
+ "on_led_intensity": {
+ "name": "On LED intensity"
+ },
+ "off_led_intensity": {
+ "name": "Off LED intensity"
+ },
+ "frost_protection_temperature": {
+ "name": "Frost protection temperature"
+ },
+ "valve_opening_degree": {
+ "name": "Valve opening degree"
+ },
+ "valve_closing_degree": {
+ "name": "Valve closing degree"
+ },
+ "siren_time": {
+ "name": "Siren time"
+ },
+ "timer_time_left": {
+ "name": "Timer time left"
+ },
+ "approach_distance": {
+ "name": "Approach distance"
}
},
"select": {
@@ -886,6 +917,15 @@
},
"weather_delay": {
"name": "Weather delay"
+ },
+ "on_led_color": {
+ "name": "On LED color"
+ },
+ "off_led_color": {
+ "name": "Off LED color"
+ },
+ "external_trigger_mode": {
+ "name": "External trigger mode"
}
},
"sensor": {
@@ -1083,6 +1123,15 @@
},
"valve_status_2": {
"name": "Status 2"
+ },
+ "timer_state": {
+ "name": "Timer state"
+ },
+ "last_valve_open_duration": {
+ "name": "Last valve open duration"
+ },
+ "motion_distance": {
+ "name": "Motion distance"
}
},
"switch": {
@@ -1193,6 +1242,21 @@
},
"valve_on_off_2": {
"name": "Valve 2"
+ },
+ "double_up_full": {
+ "name": "Double tap on - full"
+ },
+ "open_window": {
+ "name": "Open window"
+ },
+ "turbo_mode": {
+ "name": "Turbo mode"
+ },
+ "detach_relay": {
+ "name": "Detach relay"
+ },
+ "enable_siren": {
+ "name": "Enable siren"
}
}
}
diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py
index 18b8ed1cca5..cb5c160e7b3 100644
--- a/homeassistant/components/zha/update.py
+++ b/homeassistant/components/zha/update.py
@@ -36,6 +36,18 @@ from .helpers import (
_LOGGER = logging.getLogger(__name__)
+OTA_MESSAGE_BATTERY_POWERED = (
+ "Battery powered devices can sometimes take multiple hours to update and you may"
+ " need to wake the device for the update to begin."
+)
+
+ZHA_DOCS_NETWORK_RELIABILITY = "https://www.home-assistant.io/integrations/zha/#zigbee-interference-avoidance-and-network-rangecoverage-optimization"
+OTA_MESSAGE_RELIABILITY = (
+ "If you are having issues updating a specific device, make sure that you've"
+ f" eliminated [common environmental issues]({ZHA_DOCS_NETWORK_RELIABILITY}) that"
+ " could be affecting network reliability. OTA updates require a reliable network."
+)
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -149,7 +161,21 @@ class ZHAFirmwareUpdateEntity(
This is suitable for a long changelog that does not fit in the release_summary
property. The returned string can contain markdown.
"""
- return self.entity_data.entity.release_notes
+
+ if self.entity_data.device_proxy.device.is_mains_powered:
+ header = (
+ ""
+ f"{OTA_MESSAGE_RELIABILITY}"
+ ""
+ )
+ else:
+ header = (
+ ""
+ f"{OTA_MESSAGE_BATTERY_POWERED} {OTA_MESSAGE_RELIABILITY}"
+ ""
+ )
+
+ return f"{header}\n\n{self.entity_data.entity.release_notes or ''}"
@property
def release_url(self) -> str | None:
diff --git a/homeassistant/components/zhong_hong/manifest.json b/homeassistant/components/zhong_hong/manifest.json
index 9da0e9ab72b..3569466fb0a 100644
--- a/homeassistant/components/zhong_hong/manifest.json
+++ b/homeassistant/components/zhong_hong/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/zhong_hong",
"iot_class": "local_push",
"loggers": ["zhong_hong_hvac"],
+ "quality_scale": "legacy",
"requirements": ["zhong-hong-hvac==1.0.13"]
}
diff --git a/homeassistant/components/ziggo_mediabox_xl/manifest.json b/homeassistant/components/ziggo_mediabox_xl/manifest.json
index 81aac99e58d..1ae09c9927d 100644
--- a/homeassistant/components/ziggo_mediabox_xl/manifest.json
+++ b/homeassistant/components/ziggo_mediabox_xl/manifest.json
@@ -4,5 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ziggo_mediabox_xl",
"iot_class": "local_polling",
+ "quality_scale": "legacy",
"requirements": ["ziggo-mediabox-xl==1.1.0"]
}
diff --git a/homeassistant/components/zodiac/manifest.json b/homeassistant/components/zodiac/manifest.json
index 88f3d7fadef..f641826ca7b 100644
--- a/homeassistant/components/zodiac/manifest.json
+++ b/homeassistant/components/zodiac/manifest.json
@@ -4,6 +4,5 @@
"codeowners": ["@JulienTant"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zodiac",
- "iot_class": "calculated",
- "quality_scale": "silver"
+ "iot_class": "calculated"
}
diff --git a/homeassistant/components/zoneminder/manifest.json b/homeassistant/components/zoneminder/manifest.json
index f441a800555..2501aba2cf1 100644
--- a/homeassistant/components/zoneminder/manifest.json
+++ b/homeassistant/components/zoneminder/manifest.json
@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/zoneminder",
"iot_class": "local_polling",
"loggers": ["zoneminder"],
+ "quality_scale": "legacy",
"requirements": ["zm-py==0.5.4"]
}
diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py
index 06b8214d941..c8503b1f4c6 100644
--- a/homeassistant/components/zwave_js/__init__.py
+++ b/homeassistant/components/zwave_js/__init__.py
@@ -9,6 +9,7 @@ import logging
from typing import Any
from awesomeversion import AwesomeVersion
+import voluptuous as vol
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import CommandClass, RemoveNodeReason
from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion
@@ -87,6 +88,7 @@ from .const import (
CONF_ADDON_S2_AUTHENTICATED_KEY,
CONF_ADDON_S2_UNAUTHENTICATED_KEY,
CONF_DATA_COLLECTION_OPTED_IN,
+ CONF_INSTALLER_MODE,
CONF_INTEGRATION_CREATED_ADDON,
CONF_LR_S2_ACCESS_CONTROL_KEY,
CONF_LR_S2_AUTHENTICATED_KEY,
@@ -132,12 +134,21 @@ DATA_CLIENT_LISTEN_TASK = "client_listen_task"
DATA_DRIVER_EVENTS = "driver_events"
DATA_START_CLIENT_TASK = "start_client_task"
-CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN: vol.Schema(
+ {
+ vol.Optional(CONF_INSTALLER_MODE, default=False): cv.boolean,
+ }
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Z-Wave JS component."""
- hass.data[DOMAIN] = {}
+ hass.data[DOMAIN] = config.get(DOMAIN, {})
for entry in hass.config_entries.async_entries(DOMAIN):
if not isinstance(entry.unique_id, str):
hass.config_entries.async_update_entry(
diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py
index bd49e85b601..88f8f25c8e2 100644
--- a/homeassistant/components/zwave_js/api.py
+++ b/homeassistant/components/zwave_js/api.py
@@ -83,7 +83,9 @@ from .const import (
ATTR_PARAMETERS,
ATTR_WAIT_FOR_RESULT,
CONF_DATA_COLLECTION_OPTED_IN,
+ CONF_INSTALLER_MODE,
DATA_CLIENT,
+ DOMAIN,
EVENT_DEVICE_ADDED_TO_REGISTRY,
USER_AGENT,
)
@@ -393,6 +395,7 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_node_metadata)
websocket_api.async_register_command(hass, websocket_node_alerts)
websocket_api.async_register_command(hass, websocket_add_node)
+ websocket_api.async_register_command(hass, websocket_cancel_secure_bootstrap_s2)
websocket_api.async_register_command(hass, websocket_grant_security_classes)
websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin)
websocket_api.async_register_command(hass, websocket_provision_smart_start_node)
@@ -450,6 +453,7 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_hard_reset_controller)
websocket_api.async_register_command(hass, websocket_node_capabilities)
websocket_api.async_register_command(hass, websocket_invoke_cc_api)
+ websocket_api.async_register_command(hass, websocket_get_integration_settings)
hass.http.register_view(FirmwareUploadView(dr.async_get(hass)))
@@ -836,6 +840,29 @@ async def websocket_add_node(
)
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(TYPE): "zwave_js/cancel_secure_bootstrap_s2",
+ vol.Required(ENTRY_ID): str,
+ }
+)
+@websocket_api.async_response
+@async_handle_failed_command
+@async_get_entry
+async def websocket_cancel_secure_bootstrap_s2(
+ hass: HomeAssistant,
+ connection: ActiveConnection,
+ msg: dict[str, Any],
+ entry: ConfigEntry,
+ client: Client,
+ driver: Driver,
+) -> None:
+ """Cancel secure bootstrap S2."""
+ await driver.controller.async_cancel_secure_bootstrap_s2()
+ connection.send_result(msg[ID])
+
+
@websocket_api.require_admin
@websocket_api.websocket_command(
{
@@ -2682,3 +2709,25 @@ async def websocket_invoke_cc_api(
msg[ID],
result,
)
+
+
+@callback
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(TYPE): "zwave_js/get_integration_settings",
+ }
+)
+def websocket_get_integration_settings(
+ hass: HomeAssistant,
+ connection: ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Get Z-Wave JS integration wide configuration."""
+ connection.send_result(
+ msg[ID],
+ {
+ # list explicitly to avoid leaking other keys and to set default
+ CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False),
+ },
+ )
diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py
index fd81cd7e7de..16cf6f748bb 100644
--- a/homeassistant/components/zwave_js/const.py
+++ b/homeassistant/components/zwave_js/const.py
@@ -25,6 +25,7 @@ CONF_ADDON_S2_AUTHENTICATED_KEY = "s2_authenticated_key"
CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key"
CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key"
CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key"
+CONF_INSTALLER_MODE = "installer_mode"
CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon"
CONF_NETWORK_KEY = "network_key"
CONF_S0_LEGACY_KEY = "s0_legacy_key"
diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json
index 3631bf1163b..011776f4556 100644
--- a/homeassistant/components/zwave_js/manifest.json
+++ b/homeassistant/components/zwave_js/manifest.json
@@ -9,8 +9,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["zwave_js_server"],
- "quality_scale": "platinum",
- "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.1"],
+ "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.0"],
"usb": [
{
"vid": "0658",
diff --git a/homeassistant/config.py b/homeassistant/config.py
index cab4d0c7aff..e9089f27662 100644
--- a/homeassistant/config.py
+++ b/homeassistant/config.py
@@ -814,6 +814,8 @@ def _get_log_message_and_stack_print_pref(
"domain": domain,
"error": str(exception),
"p_name": platform_path,
+ "config_file": "?",
+ "line": "?",
}
show_stack_trace: bool | None = _CONFIG_LOG_SHOW_STACK_TRACE.get(
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index dd298ae3786..ade4cd855ca 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -54,7 +54,12 @@ from .exceptions import (
ConfigEntryNotReady,
HomeAssistantError,
)
-from .helpers import device_registry, entity_registry, issue_registry as ir, storage
+from .helpers import (
+ device_registry as dr,
+ entity_registry as er,
+ issue_registry as ir,
+ storage,
+)
from .helpers.debounce import Debouncer
from .helpers.discovery_flow import DiscoveryKey
from .helpers.dispatcher import SignalType, async_dispatcher_send_internal
@@ -1195,9 +1200,9 @@ def _report_non_awaited_platform_forwards(entry: ConfigEntry, what: str) -> None
f"calls {what} for integration {entry.domain} with "
f"title: {entry.title} and entry_id: {entry.entry_id}, "
f"during setup without awaiting {what}, which can cause "
- "the setup lock to be released before the setup is done. "
- "This will stop working in Home Assistant 2025.1",
+ "the setup lock to be released before the setup is done",
core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.1",
)
@@ -1267,6 +1272,7 @@ class ConfigEntriesFlowManager(
# Deprecated in 2024.12, should fail in 2025.12
report_usage(
f"initialises a {source} flow without a link to the config entry",
+ breaks_in_ha_version="2025.12",
)
flow_id = ulid_util.ulid_now()
@@ -1481,8 +1487,6 @@ class ConfigEntriesFlowManager(
)
# Unload the entry before setting up the new one.
- # We will remove it only after the other one is set up,
- # so that device customizations are not getting lost.
if existing_entry is not None and existing_entry.state.recoverable:
await self.config_entries.async_unload(existing_entry.entry_id)
@@ -1505,12 +1509,14 @@ class ConfigEntriesFlowManager(
)
if existing_entry is not None:
- # Unload and remove the existing entry
+ # Unload and remove the existing entry, but don't clean up devices and
+ # entities until the new entry is added
await self.config_entries._async_remove(existing_entry.entry_id) # noqa: SLF001
await self.config_entries.async_add(entry)
if existing_entry is not None:
# Clean up devices and entities belonging to the existing entry
+ # which are not present in the new entry
self.config_entries._async_clean_up(existing_entry) # noqa: SLF001
result["result"] = entry
@@ -1827,6 +1833,16 @@ class ConfigEntries:
"""Return entry with matching entry_id."""
return self._entries.data.get(entry_id)
+ @callback
+ def async_get_known_entry(self, entry_id: str) -> ConfigEntry:
+ """Return entry with matching entry_id.
+
+ Raises UnknownEntry if entry is not found.
+ """
+ if (entry := self.async_get_entry(entry_id)) is None:
+ raise UnknownEntry
+ return entry
+
@callback
def async_entry_ids(self) -> list[str]:
"""Return entry ids."""
@@ -1916,8 +1932,7 @@ class ConfigEntries:
async def _async_remove(self, entry_id: str) -> tuple[bool, ConfigEntry]:
"""Remove and unload an entry."""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
async with entry.setup_lock:
if not entry.state.recoverable:
@@ -1938,8 +1953,8 @@ class ConfigEntries:
"""Clean up after an entry."""
entry_id = entry.entry_id
- dev_reg = device_registry.async_get(self.hass)
- ent_reg = entity_registry.async_get(self.hass)
+ dev_reg = dr.async_get(self.hass)
+ ent_reg = er.async_get(self.hass)
dev_reg.async_clear_config_entry(entry_id)
ent_reg.async_clear_config_entry(entry_id)
@@ -2010,8 +2025,7 @@ class ConfigEntries:
Return True if entry has been successfully loaded.
"""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
if entry.state is not ConfigEntryState.NOT_LOADED:
raise OperationNotAllowed(
@@ -2042,8 +2056,7 @@ class ConfigEntries:
async def async_unload(self, entry_id: str, _lock: bool = True) -> bool:
"""Unload a config entry."""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
if not entry.state.recoverable:
raise OperationNotAllowed(
@@ -2061,8 +2074,7 @@ class ConfigEntries:
@callback
def async_schedule_reload(self, entry_id: str) -> None:
"""Schedule a config entry to be reloaded."""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
entry.async_cancel_retry_setup()
self.hass.async_create_task(
self.async_reload(entry_id),
@@ -2080,8 +2092,7 @@ class ConfigEntries:
If an entry was not loaded, will just load.
"""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
# Cancel the setup retry task before waiting for the
# reload lock to reduce the chance of concurrent reload
@@ -2111,8 +2122,7 @@ class ConfigEntries:
If disabled_by is changed, the config entry will be reloaded.
"""
- if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ entry = self.async_get_known_entry(entry_id)
_validate_item(disabled_by=disabled_by)
if entry.disabled_by is disabled_by:
@@ -2121,21 +2131,21 @@ class ConfigEntries:
entry.disabled_by = disabled_by
self._async_schedule_save()
- dev_reg = device_registry.async_get(self.hass)
- ent_reg = entity_registry.async_get(self.hass)
+ dev_reg = dr.async_get(self.hass)
+ ent_reg = er.async_get(self.hass)
if not entry.disabled_by:
# The config entry will no longer be disabled, enable devices and entities
- device_registry.async_config_entry_disabled_by_changed(dev_reg, entry)
- entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry)
+ dr.async_config_entry_disabled_by_changed(dev_reg, entry)
+ er.async_config_entry_disabled_by_changed(ent_reg, entry)
# Load or unload the config entry
reload_result = await self.async_reload(entry_id)
if entry.disabled_by:
# The config entry has been disabled, disable devices and entities
- device_registry.async_config_entry_disabled_by_changed(dev_reg, entry)
- entity_registry.async_config_entry_disabled_by_changed(ent_reg, entry)
+ dr.async_config_entry_disabled_by_changed(dev_reg, entry)
+ er.async_config_entry_disabled_by_changed(ent_reg, entry)
return reload_result
@@ -2321,10 +2331,10 @@ class ConfigEntries:
report_usage(
"calls async_forward_entry_setup for "
f"integration, {entry.domain} with title: {entry.title} "
- f"and entry_id: {entry.entry_id}, which is deprecated and "
- "will stop working in Home Assistant 2025.6, "
+ f"and entry_id: {entry.entry_id}, which is deprecated, "
"await async_forward_entry_setups instead",
core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.6",
)
if not entry.setup_lock.locked():
async with entry.setup_lock:
@@ -2886,18 +2896,12 @@ class ConfigFlow(ConfigEntryBaseFlow):
) -> ConfigFlowResult:
"""Finish config flow and create a config entry."""
if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
- report_issue = async_suggest_report_issue(
- self.hass, integration_domain=self.handler
- )
- _LOGGER.warning(
- (
- "Detected %s config flow creating a new entry, "
- "when it is expected to update an existing entry and abort. "
- "This will stop working in %s, please %s"
- ),
- self.source,
- "2025.11",
- report_issue,
+ report_usage(
+ f"creates a new entry in a '{self.source}' flow, "
+ "when it is expected to update an existing entry and abort",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.11",
+ integration_domain=self.handler,
)
result = super().async_create_entry(
title=title,
@@ -3005,9 +3009,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
@callback
def _get_reauth_entry(self) -> ConfigEntry:
"""Return the reauth config entry linked to the current context."""
- if entry := self.hass.config_entries.async_get_entry(self._reauth_entry_id):
- return entry
- raise UnknownEntry
+ return self.hass.config_entries.async_get_known_entry(self._reauth_entry_id)
@property
def _reconfigure_entry_id(self) -> str:
@@ -3019,11 +3021,9 @@ class ConfigFlow(ConfigEntryBaseFlow):
@callback
def _get_reconfigure_entry(self) -> ConfigEntry:
"""Return the reconfigure config entry linked to the current context."""
- if entry := self.hass.config_entries.async_get_entry(
+ return self.hass.config_entries.async_get_known_entry(
self._reconfigure_entry_id
- ):
- return entry
- raise UnknownEntry
+ )
class OptionsFlowManager(
@@ -3035,11 +3035,7 @@ class OptionsFlowManager(
def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry:
"""Return config entry or raise if not found."""
- entry = self.hass.config_entries.async_get_entry(config_entry_id)
- if entry is None:
- raise UnknownEntry(config_entry_id)
-
- return entry
+ return self.hass.config_entries.async_get_known_entry(config_entry_id)
async def async_create_flow(
self,
@@ -3073,9 +3069,8 @@ class OptionsFlowManager(
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
return result
- entry = self.hass.config_entries.async_get_entry(flow.handler)
- if entry is None:
- raise UnknownEntry(flow.handler)
+ entry = self.hass.config_entries.async_get_known_entry(flow.handler)
+
if result["data"] is not None:
self.hass.config_entries.async_update_entry(entry, options=result["data"])
@@ -3147,19 +3142,17 @@ class OptionsFlow(ConfigEntryBaseFlow):
if self.hass is None:
raise ValueError("The config entry is not available during initialisation")
- if entry := self.hass.config_entries.async_get_entry(self._config_entry_id):
- return entry
- raise UnknownEntry
+ return self.hass.config_entries.async_get_known_entry(self._config_entry_id)
@config_entry.setter
def config_entry(self, value: ConfigEntry) -> None:
"""Set the config entry value."""
report_usage(
- "sets option flow config_entry explicitly, which is deprecated "
- "and will stop working in 2025.12",
+ "sets option flow config_entry explicitly, which is deprecated",
core_behavior=ReportBehavior.ERROR,
core_integration_behavior=ReportBehavior.ERROR,
custom_integration_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.12",
)
self._config_entry = value
@@ -3194,7 +3187,7 @@ class EntityRegistryDisabledHandler:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the handler."""
self.hass = hass
- self.registry: entity_registry.EntityRegistry | None = None
+ self.registry: er.EntityRegistry | None = None
self.changed: set[str] = set()
self._remove_call_later: Callable[[], None] | None = None
@@ -3202,18 +3195,18 @@ class EntityRegistryDisabledHandler:
def async_setup(self) -> None:
"""Set up the disable handler."""
self.hass.bus.async_listen(
- entity_registry.EVENT_ENTITY_REGISTRY_UPDATED,
+ er.EVENT_ENTITY_REGISTRY_UPDATED,
self._handle_entry_updated,
event_filter=_handle_entry_updated_filter,
)
@callback
def _handle_entry_updated(
- self, event: Event[entity_registry.EventEntityRegistryUpdatedData]
+ self, event: Event[er.EventEntityRegistryUpdatedData]
) -> None:
"""Handle entity registry entry update."""
if self.registry is None:
- self.registry = entity_registry.async_get(self.hass)
+ self.registry = er.async_get(self.hass)
entity_entry = self.registry.async_get(event.data["entity_id"])
@@ -3228,10 +3221,9 @@ class EntityRegistryDisabledHandler:
):
return
- config_entry = self.hass.config_entries.async_get_entry(
+ config_entry = self.hass.config_entries.async_get_known_entry(
entity_entry.config_entry_id
)
- assert config_entry is not None
if config_entry.entry_id not in self.changed and config_entry.supports_unload:
self.changed.add(config_entry.entry_id)
@@ -3271,7 +3263,7 @@ class EntityRegistryDisabledHandler:
@callback
def _handle_entry_updated_filter(
- event_data: entity_registry.EventEntityRegistryUpdatedData,
+ event_data: er.EventEntityRegistryUpdatedData,
) -> bool:
"""Handle entity registry entry update filter.
@@ -3281,8 +3273,7 @@ def _handle_entry_updated_filter(
return not (
event_data["action"] != "update"
or "disabled_by" not in event_data["changes"]
- or event_data["changes"]["disabled_by"]
- is entity_registry.RegistryEntryDisabler.CONFIG_ENTRY
+ or event_data["changes"]["disabled_by"] is er.RegistryEntryDisabler.CONFIG_ENTRY
)
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 4082a076b94..2eb4194ad15 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -23,8 +23,8 @@ if TYPE_CHECKING:
from .helpers.typing import NoEventData
APPLICATION_NAME: Final = "HomeAssistant"
-MAJOR_VERSION: Final = 2024
-MINOR_VERSION: Final = 12
+MAJOR_VERSION: Final = 2025
+MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
@@ -336,133 +336,6 @@ EVENT_RECORDER_HOURLY_STATISTICS_GENERATED: Final = (
)
EVENT_SHOPPING_LIST_UPDATED: Final = "shopping_list_updated"
-# #### DEVICE CLASSES ####
-# DEVICE_CLASS_* below are deprecated as of 2021.12
-# use the SensorDeviceClass enum instead.
-_DEPRECATED_DEVICE_CLASS_AQI: Final = DeprecatedConstant(
- "aqi", "SensorDeviceClass.AQI", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_BATTERY: Final = DeprecatedConstant(
- "battery",
- "SensorDeviceClass.BATTERY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_CO: Final = DeprecatedConstant(
- "carbon_monoxide",
- "SensorDeviceClass.CO",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_CO2: Final = DeprecatedConstant(
- "carbon_dioxide",
- "SensorDeviceClass.CO2",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_CURRENT: Final = DeprecatedConstant(
- "current",
- "SensorDeviceClass.CURRENT",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_DATE: Final = DeprecatedConstant(
- "date", "SensorDeviceClass.DATE", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_ENERGY: Final = DeprecatedConstant(
- "energy",
- "SensorDeviceClass.ENERGY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_FREQUENCY: Final = DeprecatedConstant(
- "frequency",
- "SensorDeviceClass.FREQUENCY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_GAS: Final = DeprecatedConstant(
- "gas", "SensorDeviceClass.GAS", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_HUMIDITY: Final = DeprecatedConstant(
- "humidity",
- "SensorDeviceClass.HUMIDITY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_ILLUMINANCE: Final = DeprecatedConstant(
- "illuminance",
- "SensorDeviceClass.ILLUMINANCE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_MONETARY: Final = DeprecatedConstant(
- "monetary",
- "SensorDeviceClass.MONETARY",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE: Final = DeprecatedConstant(
- "nitrogen_dioxide",
- "SensorDeviceClass.NITROGEN_DIOXIDE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE: Final = DeprecatedConstant(
- "nitrogen_monoxide",
- "SensorDeviceClass.NITROGEN_MONOXIDE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE: Final = DeprecatedConstant(
- "nitrous_oxide",
- "SensorDeviceClass.NITROUS_OXIDE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_OZONE: Final = DeprecatedConstant(
- "ozone", "SensorDeviceClass.OZONE", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PM1: Final = DeprecatedConstant(
- "pm1", "SensorDeviceClass.PM1", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PM10: Final = DeprecatedConstant(
- "pm10", "SensorDeviceClass.PM10", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PM25: Final = DeprecatedConstant(
- "pm25", "SensorDeviceClass.PM25", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_POWER_FACTOR: Final = DeprecatedConstant(
- "power_factor",
- "SensorDeviceClass.POWER_FACTOR",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_POWER: Final = DeprecatedConstant(
- "power", "SensorDeviceClass.POWER", "2025.1"
-)
-_DEPRECATED_DEVICE_CLASS_PRESSURE: Final = DeprecatedConstant(
- "pressure",
- "SensorDeviceClass.PRESSURE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH: Final = DeprecatedConstant(
- "signal_strength",
- "SensorDeviceClass.SIGNAL_STRENGTH",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE: Final = DeprecatedConstant(
- "sulphur_dioxide",
- "SensorDeviceClass.SULPHUR_DIOXIDE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_TEMPERATURE: Final = DeprecatedConstant(
- "temperature",
- "SensorDeviceClass.TEMPERATURE",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_TIMESTAMP: Final = DeprecatedConstant(
- "timestamp",
- "SensorDeviceClass.TIMESTAMP",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: Final = DeprecatedConstant(
- "volatile_organic_compounds",
- "SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS",
- "2025.1",
-)
-_DEPRECATED_DEVICE_CLASS_VOLTAGE: Final = DeprecatedConstant(
- "voltage",
- "SensorDeviceClass.VOLTAGE",
- "2025.1",
-)
# #### STATES ####
STATE_ON: Final = "on"
@@ -712,13 +585,6 @@ class UnitOfApparentPower(StrEnum):
VOLT_AMPERE = "VA"
-_DEPRECATED_POWER_VOLT_AMPERE: Final = DeprecatedConstantEnum(
- UnitOfApparentPower.VOLT_AMPERE,
- "2025.1",
-)
-"""Deprecated: please use UnitOfApparentPower.VOLT_AMPERE."""
-
-
# Power units
class UnitOfPower(StrEnum):
"""Power units."""
@@ -731,23 +597,6 @@ class UnitOfPower(StrEnum):
BTU_PER_HOUR = "BTU/h"
-_DEPRECATED_POWER_WATT: Final = DeprecatedConstantEnum(
- UnitOfPower.WATT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPower.WATT."""
-_DEPRECATED_POWER_KILO_WATT: Final = DeprecatedConstantEnum(
- UnitOfPower.KILO_WATT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPower.KILO_WATT."""
-_DEPRECATED_POWER_BTU_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfPower.BTU_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPower.BTU_PER_HOUR."""
-
-
# Reactive power units
class UnitOfReactivePower(StrEnum):
"""Reactive power units."""
@@ -781,23 +630,6 @@ class UnitOfEnergy(StrEnum):
GIGA_CALORIE = "Gcal"
-_DEPRECATED_ENERGY_KILO_WATT_HOUR: Final = DeprecatedConstantEnum(
- UnitOfEnergy.KILO_WATT_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfEnergy.KILO_WATT_HOUR."""
-_DEPRECATED_ENERGY_MEGA_WATT_HOUR: Final = DeprecatedConstantEnum(
- UnitOfEnergy.MEGA_WATT_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfEnergy.MEGA_WATT_HOUR."""
-_DEPRECATED_ENERGY_WATT_HOUR: Final = DeprecatedConstantEnum(
- UnitOfEnergy.WATT_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfEnergy.WATT_HOUR."""
-
-
# Electric_current units
class UnitOfElectricCurrent(StrEnum):
"""Electric current units."""
@@ -806,37 +638,15 @@ class UnitOfElectricCurrent(StrEnum):
AMPERE = "A"
-_DEPRECATED_ELECTRIC_CURRENT_MILLIAMPERE: Final = DeprecatedConstantEnum(
- UnitOfElectricCurrent.MILLIAMPERE,
- "2025.1",
-)
-"""Deprecated: please use UnitOfElectricCurrent.MILLIAMPERE."""
-_DEPRECATED_ELECTRIC_CURRENT_AMPERE: Final = DeprecatedConstantEnum(
- UnitOfElectricCurrent.AMPERE,
- "2025.1",
-)
-"""Deprecated: please use UnitOfElectricCurrent.AMPERE."""
-
-
# Electric_potential units
class UnitOfElectricPotential(StrEnum):
"""Electric potential units."""
+ MICROVOLT = "µV"
MILLIVOLT = "mV"
VOLT = "V"
-_DEPRECATED_ELECTRIC_POTENTIAL_MILLIVOLT: Final = DeprecatedConstantEnum(
- UnitOfElectricPotential.MILLIVOLT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfElectricPotential.MILLIVOLT."""
-_DEPRECATED_ELECTRIC_POTENTIAL_VOLT: Final = DeprecatedConstantEnum(
- UnitOfElectricPotential.VOLT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfElectricPotential.VOLT."""
-
# Degree units
DEGREE: Final = "°"
@@ -855,23 +665,6 @@ class UnitOfTemperature(StrEnum):
KELVIN = "K"
-_DEPRECATED_TEMP_CELSIUS: Final = DeprecatedConstantEnum(
- UnitOfTemperature.CELSIUS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTemperature.CELSIUS"""
-_DEPRECATED_TEMP_FAHRENHEIT: Final = DeprecatedConstantEnum(
- UnitOfTemperature.FAHRENHEIT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTemperature.FAHRENHEIT"""
-_DEPRECATED_TEMP_KELVIN: Final = DeprecatedConstantEnum(
- UnitOfTemperature.KELVIN,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTemperature.KELVIN"""
-
-
# Time units
class UnitOfTime(StrEnum):
"""Time units."""
@@ -887,53 +680,6 @@ class UnitOfTime(StrEnum):
YEARS = "y"
-_DEPRECATED_TIME_MICROSECONDS: Final = DeprecatedConstantEnum(
- UnitOfTime.MICROSECONDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.MICROSECONDS."""
-_DEPRECATED_TIME_MILLISECONDS: Final = DeprecatedConstantEnum(
- UnitOfTime.MILLISECONDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.MILLISECONDS."""
-_DEPRECATED_TIME_SECONDS: Final = DeprecatedConstantEnum(
- UnitOfTime.SECONDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.SECONDS."""
-_DEPRECATED_TIME_MINUTES: Final = DeprecatedConstantEnum(
- UnitOfTime.MINUTES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.MINUTES."""
-_DEPRECATED_TIME_HOURS: Final = DeprecatedConstantEnum(
- UnitOfTime.HOURS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.HOURS."""
-_DEPRECATED_TIME_DAYS: Final = DeprecatedConstantEnum(
- UnitOfTime.DAYS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.DAYS."""
-_DEPRECATED_TIME_WEEKS: Final = DeprecatedConstantEnum(
- UnitOfTime.WEEKS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.WEEKS."""
-_DEPRECATED_TIME_MONTHS: Final = DeprecatedConstantEnum(
- UnitOfTime.MONTHS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.MONTHS."""
-_DEPRECATED_TIME_YEARS: Final = DeprecatedConstantEnum(
- UnitOfTime.YEARS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfTime.YEARS."""
-
-
# Length units
class UnitOfLength(StrEnum):
"""Length units."""
@@ -949,48 +695,6 @@ class UnitOfLength(StrEnum):
NAUTICAL_MILES = "nmi"
-_DEPRECATED_LENGTH_MILLIMETERS: Final = DeprecatedConstantEnum(
- UnitOfLength.MILLIMETERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.MILLIMETERS."""
-_DEPRECATED_LENGTH_CENTIMETERS: Final = DeprecatedConstantEnum(
- UnitOfLength.CENTIMETERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.CENTIMETERS."""
-_DEPRECATED_LENGTH_METERS: Final = DeprecatedConstantEnum(
- UnitOfLength.METERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.METERS."""
-_DEPRECATED_LENGTH_KILOMETERS: Final = DeprecatedConstantEnum(
- UnitOfLength.KILOMETERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.KILOMETERS."""
-_DEPRECATED_LENGTH_INCHES: Final = DeprecatedConstantEnum(
- UnitOfLength.INCHES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.INCHES."""
-_DEPRECATED_LENGTH_FEET: Final = DeprecatedConstantEnum(
- UnitOfLength.FEET,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.FEET."""
-_DEPRECATED_LENGTH_YARD: Final = DeprecatedConstantEnum(
- UnitOfLength.YARDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.YARDS."""
-_DEPRECATED_LENGTH_MILES: Final = DeprecatedConstantEnum(
- UnitOfLength.MILES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfLength.MILES."""
-
-
# Frequency units
class UnitOfFrequency(StrEnum):
"""Frequency units."""
@@ -1001,28 +705,6 @@ class UnitOfFrequency(StrEnum):
GIGAHERTZ = "GHz"
-_DEPRECATED_FREQUENCY_HERTZ: Final = DeprecatedConstantEnum(
- UnitOfFrequency.HERTZ,
- "2025.1",
-)
-"""Deprecated: please use UnitOfFrequency.HERTZ"""
-_DEPRECATED_FREQUENCY_KILOHERTZ: Final = DeprecatedConstantEnum(
- UnitOfFrequency.KILOHERTZ,
- "2025.1",
-)
-"""Deprecated: please use UnitOfFrequency.KILOHERTZ"""
-_DEPRECATED_FREQUENCY_MEGAHERTZ: Final = DeprecatedConstantEnum(
- UnitOfFrequency.MEGAHERTZ,
- "2025.1",
-)
-"""Deprecated: please use UnitOfFrequency.MEGAHERTZ"""
-_DEPRECATED_FREQUENCY_GIGAHERTZ: Final = DeprecatedConstantEnum(
- UnitOfFrequency.GIGAHERTZ,
- "2025.1",
-)
-"""Deprecated: please use UnitOfFrequency.GIGAHERTZ"""
-
-
# Pressure units
class UnitOfPressure(StrEnum):
"""Pressure units."""
@@ -1038,53 +720,6 @@ class UnitOfPressure(StrEnum):
PSI = "psi"
-_DEPRECATED_PRESSURE_PA: Final = DeprecatedConstantEnum(
- UnitOfPressure.PA,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.PA"""
-_DEPRECATED_PRESSURE_HPA: Final = DeprecatedConstantEnum(
- UnitOfPressure.HPA,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.HPA"""
-_DEPRECATED_PRESSURE_KPA: Final = DeprecatedConstantEnum(
- UnitOfPressure.KPA,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.KPA"""
-_DEPRECATED_PRESSURE_BAR: Final = DeprecatedConstantEnum(
- UnitOfPressure.BAR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.BAR"""
-_DEPRECATED_PRESSURE_CBAR: Final = DeprecatedConstantEnum(
- UnitOfPressure.CBAR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.CBAR"""
-_DEPRECATED_PRESSURE_MBAR: Final = DeprecatedConstantEnum(
- UnitOfPressure.MBAR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.MBAR"""
-_DEPRECATED_PRESSURE_MMHG: Final = DeprecatedConstantEnum(
- UnitOfPressure.MMHG,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.MMHG"""
-_DEPRECATED_PRESSURE_INHG: Final = DeprecatedConstantEnum(
- UnitOfPressure.INHG,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.INHG"""
-_DEPRECATED_PRESSURE_PSI: Final = DeprecatedConstantEnum(
- UnitOfPressure.PSI,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPressure.PSI"""
-
-
# Sound pressure units
class UnitOfSoundPressure(StrEnum):
"""Sound pressure units."""
@@ -1093,18 +728,6 @@ class UnitOfSoundPressure(StrEnum):
WEIGHTED_DECIBEL_A = "dBA"
-_DEPRECATED_SOUND_PRESSURE_DB: Final = DeprecatedConstantEnum(
- UnitOfSoundPressure.DECIBEL,
- "2025.1",
-)
-"""Deprecated: please use UnitOfSoundPressure.DECIBEL"""
-_DEPRECATED_SOUND_PRESSURE_WEIGHTED_DBA: Final = DeprecatedConstantEnum(
- UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
- "2025.1",
-)
-"""Deprecated: please use UnitOfSoundPressure.WEIGHTED_DECIBEL_A"""
-
-
# Volume units
class UnitOfVolume(StrEnum):
"""Volume units."""
@@ -1124,39 +747,6 @@ class UnitOfVolume(StrEnum):
British/Imperial fluid ounces are not yet supported"""
-_DEPRECATED_VOLUME_LITERS: Final = DeprecatedConstantEnum(
- UnitOfVolume.LITERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.LITERS"""
-_DEPRECATED_VOLUME_MILLILITERS: Final = DeprecatedConstantEnum(
- UnitOfVolume.MILLILITERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.MILLILITERS"""
-_DEPRECATED_VOLUME_CUBIC_METERS: Final = DeprecatedConstantEnum(
- UnitOfVolume.CUBIC_METERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.CUBIC_METERS"""
-_DEPRECATED_VOLUME_CUBIC_FEET: Final = DeprecatedConstantEnum(
- UnitOfVolume.CUBIC_FEET,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.CUBIC_FEET"""
-
-_DEPRECATED_VOLUME_GALLONS: Final = DeprecatedConstantEnum(
- UnitOfVolume.GALLONS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.GALLONS"""
-_DEPRECATED_VOLUME_FLUID_OUNCE: Final = DeprecatedConstantEnum(
- UnitOfVolume.FLUID_OUNCES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolume.FLUID_OUNCES"""
-
-
# Volume Flow Rate units
class UnitOfVolumeFlowRate(StrEnum):
"""Volume flow rate units."""
@@ -1165,21 +755,29 @@ class UnitOfVolumeFlowRate(StrEnum):
CUBIC_FEET_PER_MINUTE = "ft³/min"
LITERS_PER_MINUTE = "L/min"
GALLONS_PER_MINUTE = "gal/min"
+ MILLILITERS_PER_SECOND = "mL/s"
-_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
- "2025.1",
+class UnitOfArea(StrEnum):
+ """Area units."""
+
+ SQUARE_METERS = "m²"
+ SQUARE_CENTIMETERS = "cm²"
+ SQUARE_KILOMETERS = "km²"
+ SQUARE_MILLIMETERS = "mm²"
+ SQUARE_INCHES = "in²"
+ SQUARE_FEET = "ft²"
+ SQUARE_YARDS = "yd²"
+ SQUARE_MILES = "mi²"
+ ACRES = "ac"
+ HECTARES = "ha"
+
+
+_DEPRECATED_AREA_SQUARE_METERS: Final = DeprecatedConstantEnum(
+ UnitOfArea.SQUARE_METERS,
+ "2025.12",
)
-"""Deprecated: please use UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR"""
-_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = DeprecatedConstantEnum(
- UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE"""
-
-# Area units
-AREA_SQUARE_METERS: Final = "m²"
+"""Deprecated: please use UnitOfArea.SQUARE_METERS"""
# Mass units
@@ -1195,38 +793,6 @@ class UnitOfMass(StrEnum):
STONES = "st"
-_DEPRECATED_MASS_GRAMS: Final = DeprecatedConstantEnum(
- UnitOfMass.GRAMS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.GRAMS"""
-_DEPRECATED_MASS_KILOGRAMS: Final = DeprecatedConstantEnum(
- UnitOfMass.KILOGRAMS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.KILOGRAMS"""
-_DEPRECATED_MASS_MILLIGRAMS: Final = DeprecatedConstantEnum(
- UnitOfMass.MILLIGRAMS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.MILLIGRAMS"""
-_DEPRECATED_MASS_MICROGRAMS: Final = DeprecatedConstantEnum(
- UnitOfMass.MICROGRAMS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.MICROGRAMS"""
-_DEPRECATED_MASS_OUNCES: Final = DeprecatedConstantEnum(
- UnitOfMass.OUNCES,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.OUNCES"""
-_DEPRECATED_MASS_POUNDS: Final = DeprecatedConstantEnum(
- UnitOfMass.POUNDS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfMass.POUNDS"""
-
-
class UnitOfConductivity(
StrEnum,
metaclass=EnumWithDeprecatedMembers,
@@ -1278,19 +844,6 @@ class UnitOfIrradiance(StrEnum):
BTUS_PER_HOUR_SQUARE_FOOT = "BTU/(h⋅ft²)"
-# Irradiation units
-_DEPRECATED_IRRADIATION_WATTS_PER_SQUARE_METER: Final = DeprecatedConstantEnum(
- UnitOfIrradiance.WATTS_PER_SQUARE_METER,
- "2025.1",
-)
-"""Deprecated: please use UnitOfIrradiance.WATTS_PER_SQUARE_METER"""
-_DEPRECATED_IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = DeprecatedConstantEnum(
- UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT,
- "2025.1",
-)
-"""Deprecated: please use UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT"""
-
-
class UnitOfVolumetricFlux(StrEnum):
"""Volumetric flux, commonly used for precipitation intensity.
@@ -1328,27 +881,6 @@ class UnitOfPrecipitationDepth(StrEnum):
"""Derived from cm³/cm²"""
-# Precipitation units
-_DEPRECATED_PRECIPITATION_INCHES: Final = DeprecatedConstantEnum(
- UnitOfPrecipitationDepth.INCHES, "2025.1"
-)
-"""Deprecated: please use UnitOfPrecipitationDepth.INCHES"""
-_DEPRECATED_PRECIPITATION_MILLIMETERS: Final = DeprecatedConstantEnum(
- UnitOfPrecipitationDepth.MILLIMETERS,
- "2025.1",
-)
-"""Deprecated: please use UnitOfPrecipitationDepth.MILLIMETERS"""
-_DEPRECATED_PRECIPITATION_MILLIMETERS_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR"""
-_DEPRECATED_PRECIPITATION_INCHES_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.INCHES_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR"""
-
# Concentration units
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³"
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³"
@@ -1379,45 +911,6 @@ class UnitOfSpeed(StrEnum):
MILLIMETERS_PER_SECOND = "mm/s"
-_DEPRECATED_SPEED_FEET_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfSpeed.FEET_PER_SECOND, "2025.1"
-)
-"""Deprecated: please use UnitOfSpeed.FEET_PER_SECOND"""
-_DEPRECATED_SPEED_METERS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfSpeed.METERS_PER_SECOND, "2025.1"
-)
-"""Deprecated: please use UnitOfSpeed.METERS_PER_SECOND"""
-_DEPRECATED_SPEED_KILOMETERS_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfSpeed.KILOMETERS_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfSpeed.KILOMETERS_PER_HOUR"""
-_DEPRECATED_SPEED_KNOTS: Final = DeprecatedConstantEnum(UnitOfSpeed.KNOTS, "2025.1")
-"""Deprecated: please use UnitOfSpeed.KNOTS"""
-_DEPRECATED_SPEED_MILES_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfSpeed.MILES_PER_HOUR, "2025.1"
-)
-"""Deprecated: please use UnitOfSpeed.MILES_PER_HOUR"""
-
-_DEPRECATED_SPEED_MILLIMETERS_PER_DAY: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.MILLIMETERS_PER_DAY,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_DAY"""
-
-_DEPRECATED_SPEED_INCHES_PER_DAY: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.INCHES_PER_DAY,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_DAY"""
-
-_DEPRECATED_SPEED_INCHES_PER_HOUR: Final = DeprecatedConstantEnum(
- UnitOfVolumetricFlux.INCHES_PER_HOUR,
- "2025.1",
-)
-"""Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR"""
-
-
# Signal_strength units
SIGNAL_STRENGTH_DECIBELS: Final = "dB"
SIGNAL_STRENGTH_DECIBELS_MILLIWATT: Final = "dBm"
@@ -1450,90 +943,6 @@ class UnitOfInformation(StrEnum):
YOBIBYTES = "YiB"
-_DEPRECATED_DATA_BITS: Final = DeprecatedConstantEnum(UnitOfInformation.BITS, "2025.1")
-"""Deprecated: please use UnitOfInformation.BITS"""
-_DEPRECATED_DATA_KILOBITS: Final = DeprecatedConstantEnum(
- UnitOfInformation.KILOBITS, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.KILOBITS"""
-_DEPRECATED_DATA_MEGABITS: Final = DeprecatedConstantEnum(
- UnitOfInformation.MEGABITS, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.MEGABITS"""
-_DEPRECATED_DATA_GIGABITS: Final = DeprecatedConstantEnum(
- UnitOfInformation.GIGABITS, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.GIGABITS"""
-_DEPRECATED_DATA_BYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.BYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.BYTES"""
-_DEPRECATED_DATA_KILOBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.KILOBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.KILOBYTES"""
-_DEPRECATED_DATA_MEGABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.MEGABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.MEGABYTES"""
-_DEPRECATED_DATA_GIGABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.GIGABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.GIGABYTES"""
-_DEPRECATED_DATA_TERABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.TERABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.TERABYTES"""
-_DEPRECATED_DATA_PETABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.PETABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.PETABYTES"""
-_DEPRECATED_DATA_EXABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.EXABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.EXABYTES"""
-_DEPRECATED_DATA_ZETTABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.ZETTABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.ZETTABYTES"""
-_DEPRECATED_DATA_YOTTABYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.YOTTABYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.YOTTABYTES"""
-_DEPRECATED_DATA_KIBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.KIBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.KIBIBYTES"""
-_DEPRECATED_DATA_MEBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.MEBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.MEBIBYTES"""
-_DEPRECATED_DATA_GIBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.GIBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.GIBIBYTES"""
-_DEPRECATED_DATA_TEBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.TEBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.TEBIBYTES"""
-_DEPRECATED_DATA_PEBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.PEBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.PEBIBYTES"""
-_DEPRECATED_DATA_EXBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.EXBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.EXBIBYTES"""
-_DEPRECATED_DATA_ZEBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.ZEBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.ZEBIBYTES"""
-_DEPRECATED_DATA_YOBIBYTES: Final = DeprecatedConstantEnum(
- UnitOfInformation.YOBIBYTES, "2025.1"
-)
-"""Deprecated: please use UnitOfInformation.YOBIBYTES"""
-
-
# Data_rate units
class UnitOfDataRate(StrEnum):
"""Data rate units."""
@@ -1551,63 +960,6 @@ class UnitOfDataRate(StrEnum):
GIBIBYTES_PER_SECOND = "GiB/s"
-_DEPRECATED_DATA_RATE_BITS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.BITS_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.BITS_PER_SECOND"""
-_DEPRECATED_DATA_RATE_KILOBITS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.KILOBITS_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.KILOBITS_PER_SECOND"""
-_DEPRECATED_DATA_RATE_MEGABITS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.MEGABITS_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.MEGABITS_PER_SECOND"""
-_DEPRECATED_DATA_RATE_GIGABITS_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.GIGABITS_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.GIGABITS_PER_SECOND"""
-_DEPRECATED_DATA_RATE_BYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.BYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.BYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_KILOBYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.KILOBYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.KILOBYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_MEGABYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.MEGABYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.MEGABYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_GIGABYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.GIGABYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.GIGABYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_KIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.KIBIBYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.KIBIBYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_MEBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.MEBIBYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.MEBIBYTES_PER_SECOND"""
-_DEPRECATED_DATA_RATE_GIBIBYTES_PER_SECOND: Final = DeprecatedConstantEnum(
- UnitOfDataRate.GIBIBYTES_PER_SECOND,
- "2025.1",
-)
-"""Deprecated: please use UnitOfDataRate.GIBIBYTES_PER_SECOND"""
-
-
# States
COMPRESSED_STATE_STATE: Final = "s"
COMPRESSED_STATE_ATTRIBUTES: Final = "a"
@@ -1703,6 +1055,7 @@ RESTART_EXIT_CODE: Final = 100
UNIT_NOT_RECOGNIZED_TEMPLATE: Final = "{} is not a recognized {} unit."
LENGTH: Final = "length"
+AREA: Final = "area"
MASS: Final = "mass"
PRESSURE: Final = "pressure"
VOLUME: Final = "volume"
@@ -1740,14 +1093,6 @@ class EntityCategory(StrEnum):
DIAGNOSTIC = "diagnostic"
-# ENTITY_CATEGOR* below are deprecated as of 2021.12
-# use the EntityCategory enum instead.
-_DEPRECATED_ENTITY_CATEGORY_CONFIG: Final = DeprecatedConstantEnum(
- EntityCategory.CONFIG, "2025.1"
-)
-_DEPRECATED_ENTITY_CATEGORY_DIAGNOSTIC: Final = DeprecatedConstantEnum(
- EntityCategory.DIAGNOSTIC, "2025.1"
-)
ENTITY_CATEGORIES: Final[list[str]] = [cls.value for cls in EntityCategory]
# The ID of the Home Assistant Media Player Cast App
diff --git a/homeassistant/core.py b/homeassistant/core.py
index cdfb5570b44..0640664d64f 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -84,7 +84,6 @@ from .exceptions import (
)
from .helpers.deprecation import (
DeferredDeprecatedAlias,
- DeprecatedConstantEnum,
EnumWithDeprecatedMembers,
all_with_deprecated_constants,
check_if_deprecated_constant,
@@ -177,14 +176,6 @@ class EventStateReportedData(EventStateEventData):
old_last_reported: datetime.datetime
-# SOURCE_* are deprecated as of Home Assistant 2022.2, use ConfigSource instead
-_DEPRECATED_SOURCE_DISCOVERED = DeprecatedConstantEnum(
- ConfigSource.DISCOVERED, "2025.1"
-)
-_DEPRECATED_SOURCE_STORAGE = DeprecatedConstantEnum(ConfigSource.STORAGE, "2025.1")
-_DEPRECATED_SOURCE_YAML = DeprecatedConstantEnum(ConfigSource.YAML, "2025.1")
-
-
def _deprecated_core_config() -> Any:
# pylint: disable-next=import-outside-toplevel
from . import core_config
@@ -657,11 +648,11 @@ class HomeAssistant:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_add_job`, which is deprecated and will be removed in Home "
- "Assistant 2025.4; Please review "
+ "calls `async_add_job`, which should be reviewed against "
"https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job"
" for replacement options",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.4",
)
if target is None:
@@ -713,11 +704,11 @@ class HomeAssistant:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_add_hass_job`, which is deprecated and will be removed in Home "
- "Assistant 2025.5; Please review "
+ "calls `async_add_hass_job`, which should be reviewed against "
"https://developers.home-assistant.io/blog/2024/04/07/deprecate_add_hass_job"
" for replacement options",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.5",
)
return self._async_add_hass_job(hassjob, *args, background=background)
@@ -987,11 +978,11 @@ class HomeAssistant:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_run_job`, which is deprecated and will be removed in Home "
- "Assistant 2025.4; Please review "
+ "calls `async_run_job`, which should be reviewed against "
"https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job"
" for replacement options",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.4",
)
if asyncio.iscoroutine(target):
@@ -1636,9 +1627,9 @@ class EventBus:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_listen` with run_immediately, which is"
- " deprecated and will be removed in Home Assistant 2025.5",
+ "calls `async_listen` with run_immediately",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.5",
)
if event_filter is not None and not is_callback_check_partial(event_filter):
@@ -1706,9 +1697,9 @@ class EventBus:
from .helpers import frame # pylint: disable=import-outside-toplevel
frame.report_usage(
- "calls `async_listen_once` with run_immediately, which is "
- "deprecated and will be removed in Home Assistant 2025.5",
+ "calls `async_listen_once` with run_immediately",
core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.5",
)
one_time_listener: _OneTimeListener[_DataT] = _OneTimeListener(
diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py
index 5c773c57bc4..430a882ecb9 100644
--- a/homeassistant/core_config.py
+++ b/homeassistant/core_config.py
@@ -696,10 +696,10 @@ class Config:
It will be removed in Home Assistant 2025.6.
"""
report_usage(
- "set the time zone using set_time_zone instead of async_set_time_zone"
- " which will stop working in Home Assistant 2025.6",
+ "sets the time zone using set_time_zone instead of async_set_time_zone",
core_integration_behavior=ReportBehavior.ERROR,
custom_integration_behavior=ReportBehavior.ERROR,
+ breaks_in_ha_version="2025.6",
)
if time_zone := dt_util.get_time_zone(time_zone_str):
self.time_zone = time_zone_str
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
index 63baca56aeb..6df77443e7e 100644
--- a/homeassistant/data_entry_flow.py
+++ b/homeassistant/data_entry_flow.py
@@ -10,7 +10,6 @@ from contextlib import suppress
import copy
from dataclasses import dataclass
from enum import StrEnum
-from functools import partial
import logging
from types import MappingProxyType
from typing import Any, Generic, Required, TypedDict, cast
@@ -20,12 +19,6 @@ import voluptuous as vol
from .core import HomeAssistant, callback
from .exceptions import HomeAssistantError
-from .helpers.deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
from .helpers.frame import ReportBehavior, report_usage
from .loader import async_suggest_report_issue
from .util import uuid as uuid_util
@@ -46,26 +39,6 @@ class FlowResultType(StrEnum):
MENU = "menu"
-# RESULT_TYPE_* is deprecated, to be removed in 2025.1
-_DEPRECATED_RESULT_TYPE_FORM = DeprecatedConstantEnum(FlowResultType.FORM, "2025.1")
-_DEPRECATED_RESULT_TYPE_CREATE_ENTRY = DeprecatedConstantEnum(
- FlowResultType.CREATE_ENTRY, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_ABORT = DeprecatedConstantEnum(FlowResultType.ABORT, "2025.1")
-_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP = DeprecatedConstantEnum(
- FlowResultType.EXTERNAL_STEP, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP_DONE = DeprecatedConstantEnum(
- FlowResultType.EXTERNAL_STEP_DONE, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS = DeprecatedConstantEnum(
- FlowResultType.SHOW_PROGRESS, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS_DONE = DeprecatedConstantEnum(
- FlowResultType.SHOW_PROGRESS_DONE, "2025.1"
-)
-_DEPRECATED_RESULT_TYPE_MENU = DeprecatedConstantEnum(FlowResultType.MENU, "2025.1")
-
# Event that is fired when a flow is progressed via external or progress source.
EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed"
@@ -126,6 +99,7 @@ class InvalidData(vol.Invalid):
schema_errors: dict[str, Any],
**kwargs: Any,
) -> None:
+ """Initialize an invalid data exception."""
super().__init__(message, path, error_message, **kwargs)
self.schema_errors = schema_errors
@@ -531,11 +505,9 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
if not isinstance(result["type"], FlowResultType):
result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable]
report_usage(
- (
- "does not use FlowResultType enum for data entry flow result type. "
- "This is deprecated and will stop working in Home Assistant 2025.1"
- ),
+ "does not use FlowResultType enum for data entry flow result type",
core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.1",
)
if (
@@ -931,11 +903,3 @@ class section:
def __call__(self, value: Any) -> Any:
"""Validate input."""
return self.schema(value)
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py
index f308cbc5cd8..85fe55277fa 100644
--- a/homeassistant/exceptions.py
+++ b/homeassistant/exceptions.py
@@ -270,6 +270,25 @@ class ServiceNotFound(ServiceValidationError):
self.generate_message = True
+class ServiceNotSupported(ServiceValidationError):
+ """Raised when an entity action is not supported."""
+
+ def __init__(self, domain: str, service: str, entity_id: str) -> None:
+ """Initialize ServiceNotSupported exception."""
+ super().__init__(
+ translation_domain="homeassistant",
+ translation_key="service_not_supported",
+ translation_placeholders={
+ "domain": domain,
+ "service": service,
+ "entity_id": entity_id,
+ },
+ )
+ self.domain = domain
+ self.service = service
+ self.generate_message = True
+
+
class MaxLengthExceeded(HomeAssistantError):
"""Raised when a property value has exceeded the max character length."""
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index ffe61b915c6..9a75ac32ea1 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -406,6 +406,7 @@ FLOWS = {
"nibe_heatpump",
"nice_go",
"nightscout",
+ "niko_home_control",
"nina",
"nmap_tracker",
"nobo_hub",
diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py
index 7dacf9a0bca..e37fb2332b1 100644
--- a/homeassistant/generated/dhcp.py
+++ b/homeassistant/generated/dhcp.py
@@ -236,6 +236,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "guardian*",
"macaddress": "30AEA4*",
},
+ {
+ "domain": "homewizard",
+ "registered_devices": True,
+ },
{
"domain": "hunterdouglas_powerview",
"registered_devices": True,
@@ -276,6 +280,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "polisy*",
"macaddress": "000DB9*",
},
+ {
+ "domain": "lamarzocco",
+ "registered_devices": True,
+ },
{
"domain": "lamarzocco",
"hostname": "gs[0-9][0-9][0-9][0-9][0-9][0-9]",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index f007db87868..ae7e0dd6c59 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -9,7 +9,8 @@
"name": "Abode",
"integration_type": "hub",
"config_flow": true,
- "iot_class": "cloud_push"
+ "iot_class": "cloud_push",
+ "single_config_entry": true
},
"acaia": {
"name": "Acaia",
@@ -871,7 +872,8 @@
"name": "Canary",
"integration_type": "hub",
"config_flow": true,
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_polling",
+ "single_config_entry": true
},
"ccm15": {
"name": "Midea ccm15 AC Controller",
@@ -1056,7 +1058,8 @@
"cpuspeed": {
"integration_type": "device",
"config_flow": true,
- "iot_class": "local_push"
+ "iot_class": "local_push",
+ "single_config_entry": true
},
"cribl": {
"name": "Cribl",
@@ -4142,7 +4145,7 @@
"niko_home_control": {
"name": "Niko Home Control",
"integration_type": "hub",
- "config_flow": false,
+ "config_flow": true,
"iot_class": "local_polling"
},
"nilu": {
@@ -5117,7 +5120,7 @@
"iot_class": "local_polling"
},
"reolink": {
- "name": "Reolink IP NVR/camera",
+ "name": "Reolink",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
index 1fbd6337fdb..5f7161a8245 100644
--- a/homeassistant/generated/zeroconf.py
+++ b/homeassistant/generated/zeroconf.py
@@ -872,6 +872,12 @@ ZEROCONF = {
"name": "*zigate*",
},
],
+ "_zigbee-coordinator._tcp.local.": [
+ {
+ "domain": "zha",
+ "name": "*",
+ },
+ ],
"_zigstar_gw._tcp.local.": [
{
"domain": "zha",
diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py
index 86965f86d40..5952e28a1eb 100644
--- a/homeassistant/helpers/condition.py
+++ b/homeassistant/helpers/condition.py
@@ -821,9 +821,15 @@ def time(
after_entity.attributes.get("minute", 59),
after_entity.attributes.get("second", 59),
)
- elif after_entity.attributes.get(
- ATTR_DEVICE_CLASS
- ) == SensorDeviceClass.TIMESTAMP and after_entity.state not in (
+ elif after_entity.domain == "time" and after_entity.state not in (
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+ ):
+ after = datetime.strptime(after_entity.state, "%H:%M:%S").time()
+ elif (
+ after_entity.attributes.get(ATTR_DEVICE_CLASS)
+ == SensorDeviceClass.TIMESTAMP
+ ) and after_entity.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
@@ -845,9 +851,15 @@ def time(
before_entity.attributes.get("minute", 59),
before_entity.attributes.get("second", 59),
)
- elif before_entity.attributes.get(
- ATTR_DEVICE_CLASS
- ) == SensorDeviceClass.TIMESTAMP and before_entity.state not in (
+ elif before_entity.domain == "time":
+ try:
+ before = datetime.strptime(before_entity.state, "%H:%M:%S").time()
+ except ValueError:
+ return False
+ elif (
+ before_entity.attributes.get(ATTR_DEVICE_CLASS)
+ == SensorDeviceClass.TIMESTAMP
+ ) and before_entity.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index 2b35ebade76..3681e941eee 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -1574,10 +1574,10 @@ TIME_CONDITION_SCHEMA = vol.All(
**CONDITION_BASE_SCHEMA,
vol.Required(CONF_CONDITION): "time",
vol.Optional("before"): vol.Any(
- time, vol.All(str, entity_domain(["input_datetime", "sensor"]))
+ time, vol.All(str, entity_domain(["input_datetime", "time", "sensor"]))
),
vol.Optional("after"): vol.Any(
- time, vol.All(str, entity_domain(["input_datetime", "sensor"]))
+ time, vol.All(str, entity_domain(["input_datetime", "time", "sensor"]))
),
vol.Optional("weekday"): weekdays,
}
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
index 0e56adc7377..981430f192d 100644
--- a/homeassistant/helpers/device_registry.py
+++ b/homeassistant/helpers/device_registry.py
@@ -6,7 +6,7 @@ from collections import defaultdict
from collections.abc import Mapping
from datetime import datetime
from enum import StrEnum
-from functools import lru_cache, partial
+from functools import lru_cache
import logging
import time
from typing import TYPE_CHECKING, Any, Literal, TypedDict
@@ -32,12 +32,7 @@ import homeassistant.util.uuid as uuid_util
from . import storage, translation
from .debounce import Debouncer
-from .deprecation import (
- DeprecatedConstantEnum,
- all_with_deprecated_constants,
- check_if_deprecated_constant,
- dir_with_deprecated_constants,
-)
+from .frame import ReportBehavior, report_usage
from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment
from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType
from .singleton import singleton
@@ -85,16 +80,6 @@ class DeviceEntryDisabler(StrEnum):
USER = "user"
-# DISABLED_* are deprecated, to be removed in 2022.3
-_DEPRECATED_DISABLED_CONFIG_ENTRY = DeprecatedConstantEnum(
- DeviceEntryDisabler.CONFIG_ENTRY, "2025.1"
-)
-_DEPRECATED_DISABLED_INTEGRATION = DeprecatedConstantEnum(
- DeviceEntryDisabler.INTEGRATION, "2025.1"
-)
-_DEPRECATED_DISABLED_USER = DeprecatedConstantEnum(DeviceEntryDisabler.USER, "2025.1")
-
-
class DeviceInfo(TypedDict, total=False):
"""Entity device information for device registry."""
@@ -821,7 +806,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
name = default_name
if via_device is not None and via_device is not UNDEFINED:
- via = self.async_get_device(identifiers={via_device})
+ if (via := self.async_get_device(identifiers={via_device})) is None:
+ report_usage(
+ "calls `device_registry.async_get_or_create` referencing a "
+ f"non existing `via_device` {via_device}, "
+ f"with device info: {device_info}",
+ core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.12.0",
+ )
+
via_device_id: str | UndefinedType = via.id if via else UNDEFINED
else:
via_device_id = UNDEFINED
@@ -1471,11 +1464,3 @@ def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str,
(key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value)
for key, value in connections
}
-
-
-# These can be removed if no deprecated constant are in this module anymore
-__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
-__dir__ = partial(
- dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
-)
-__all__ = all_with_deprecated_constants(globals())
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 1f77dd3f95c..19076c4edc0 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -647,6 +647,22 @@ class Entity(
f".{self.translation_key}.name"
)
+ @cached_property
+ def _unit_of_measurement_translation_key(self) -> str | None:
+ """Return translation key for unit of measurement."""
+ if self.translation_key is None:
+ return None
+ if self.platform is None:
+ raise ValueError(
+ f"Entity {type(self)} cannot have a translation key for "
+ "unit of measurement before being added to the entity platform"
+ )
+ platform = self.platform
+ return (
+ f"component.{platform.platform_name}.entity.{platform.domain}"
+ f".{self.translation_key}.unit_of_measurement"
+ )
+
def _substitute_name_placeholders(self, name: str) -> str:
"""Substitute placeholders in entity name."""
try:
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index 62eed213b2a..0d7614c569c 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -145,6 +145,7 @@ class EntityPlatform:
self.platform_translations: dict[str, str] = {}
self.object_id_component_translations: dict[str, str] = {}
self.object_id_platform_translations: dict[str, str] = {}
+ self.default_language_platform_translations: dict[str, str] = {}
self._tasks: list[asyncio.Task[None]] = []
# Stop tracking tasks after setup is completed
self._setup_complete = False
@@ -480,6 +481,14 @@ class EntityPlatform:
self.object_id_platform_translations = await self._async_get_translations(
object_id_language, "entity", self.platform_name
)
+ if config_language == languages.DEFAULT_LANGUAGE:
+ self.default_language_platform_translations = self.platform_translations
+ else:
+ self.default_language_platform_translations = (
+ await self._async_get_translations(
+ languages.DEFAULT_LANGUAGE, "entity", self.platform_name
+ )
+ )
def _schedule_add_entities(
self, new_entities: Iterable[Entity], update_before_add: bool = False
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index 779cd8d5108..578132f358f 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -996,15 +996,10 @@ class TrackTemplateResultInfo:
if track_template_.template.hass:
continue
- # pylint: disable-next=import-outside-toplevel
- from .frame import ReportBehavior, report_usage
-
- report_usage(
- (
- "calls async_track_template_result with template without hass, "
- "which will stop working in HA Core 2025.10"
- ),
- core_behavior=ReportBehavior.LOG,
+ frame.report_usage(
+ "calls async_track_template_result with template without hass",
+ core_behavior=frame.ReportBehavior.LOG,
+ breaks_in_ha_version="2025.10",
)
track_template_.template.hass = hass
diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py
index eda98099713..6d03ae4ffd2 100644
--- a/homeassistant/helpers/frame.py
+++ b/homeassistant/helpers/frame.py
@@ -15,9 +15,13 @@ from typing import Any, cast
from propcache import cached_property
-from homeassistant.core import async_get_hass_or_none
+from homeassistant.core import HomeAssistant, async_get_hass_or_none
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.loader import async_suggest_report_issue
+from homeassistant.loader import (
+ Integration,
+ async_get_issue_integration,
+ async_suggest_report_issue,
+)
_LOGGER = logging.getLogger(__name__)
@@ -181,25 +185,52 @@ class ReportBehavior(enum.Enum):
def report_usage(
what: str,
*,
+ breaks_in_ha_version: str | None = None,
core_behavior: ReportBehavior = ReportBehavior.ERROR,
core_integration_behavior: ReportBehavior = ReportBehavior.LOG,
custom_integration_behavior: ReportBehavior = ReportBehavior.LOG,
exclude_integrations: set[str] | None = None,
+ integration_domain: str | None = None,
level: int = logging.WARNING,
) -> None:
"""Report incorrect code usage.
- Similar to `report` but allows more fine-grained reporting.
+ :param what: will be wrapped with "Detected that integration 'integration' {what}.
+ Please create a bug report at https://..."
+ :param breaks_in_ha_version: if set, the report will be adjusted to specify the
+ breaking version
+ :param exclude_integrations: skip specified integration when reviewing the stack.
+ If no integration is found, the core behavior will be applied
+ :param integration_domain: fallback for identifying the integration if the
+ frame is not found
"""
try:
integration_frame = get_integration_frame(
exclude_integrations=exclude_integrations
)
except MissingIntegrationFrame as err:
- msg = f"Detected code that {what}. Please report this issue."
+ if integration := async_get_issue_integration(
+ hass := async_get_hass_or_none(), integration_domain
+ ):
+ _report_integration_domain(
+ hass,
+ what,
+ breaks_in_ha_version,
+ integration,
+ core_integration_behavior,
+ custom_integration_behavior,
+ level,
+ )
+ return
+ msg = f"Detected code that {what}. Please report this issue"
if core_behavior is ReportBehavior.ERROR:
raise RuntimeError(msg) from err
if core_behavior is ReportBehavior.LOG:
+ if breaks_in_ha_version:
+ msg = (
+ f"Detected code that {what}. This will stop working in Home "
+ f"Assistant {breaks_in_ha_version}, please report this issue"
+ )
_LOGGER.warning(msg, stack_info=True)
return
@@ -208,18 +239,73 @@ def report_usage(
integration_behavior = custom_integration_behavior
if integration_behavior is not ReportBehavior.IGNORE:
- _report_integration(
- what, integration_frame, level, integration_behavior is ReportBehavior.ERROR
+ _report_integration_frame(
+ what,
+ breaks_in_ha_version,
+ integration_frame,
+ level,
+ integration_behavior is ReportBehavior.ERROR,
)
-def _report_integration(
+def _report_integration_domain(
+ hass: HomeAssistant | None,
what: str,
+ breaks_in_ha_version: str | None,
+ integration: Integration,
+ core_integration_behavior: ReportBehavior,
+ custom_integration_behavior: ReportBehavior,
+ level: int,
+) -> None:
+ """Report incorrect usage in an integration (identified via domain).
+
+ Async friendly.
+ """
+ integration_behavior = core_integration_behavior
+ if not integration.is_built_in:
+ integration_behavior = custom_integration_behavior
+
+ if integration_behavior is ReportBehavior.IGNORE:
+ return
+
+ # Keep track of integrations already reported to prevent flooding
+ key = f"{integration.domain}:{what}"
+ if (
+ integration_behavior is not ReportBehavior.ERROR
+ and key in _REPORTED_INTEGRATIONS
+ ):
+ return
+ _REPORTED_INTEGRATIONS.add(key)
+
+ report_issue = async_suggest_report_issue(hass, integration=integration)
+ integration_type = "" if integration.is_built_in else "custom "
+ _LOGGER.log(
+ level,
+ "Detected that %sintegration '%s' %s. %s %s",
+ integration_type,
+ integration.domain,
+ what,
+ f"This will stop working in Home Assistant {breaks_in_ha_version}, please"
+ if breaks_in_ha_version
+ else "Please",
+ report_issue,
+ )
+
+ if integration_behavior is ReportBehavior.ERROR:
+ raise RuntimeError(
+ f"Detected that {integration_type}integration "
+ f"'{integration.domain}' {what}. Please {report_issue}"
+ )
+
+
+def _report_integration_frame(
+ what: str,
+ breaks_in_ha_version: str | None,
integration_frame: IntegrationFrame,
level: int = logging.WARNING,
error: bool = False,
) -> None:
- """Report incorrect usage in an integration.
+ """Report incorrect usage in an integration (identified via frame).
Async friendly.
"""
@@ -237,13 +323,16 @@ def _report_integration(
integration_type = "custom " if integration_frame.custom_integration else ""
_LOGGER.log(
level,
- "Detected that %sintegration '%s' %s at %s, line %s: %s, please %s",
+ "Detected that %sintegration '%s' %s at %s, line %s: %s. %s %s",
integration_type,
integration_frame.integration,
what,
integration_frame.relative_filename,
integration_frame.line_number,
integration_frame.line,
+ f"This will stop working in Home Assistant {breaks_in_ha_version}, please"
+ if breaks_in_ha_version
+ else "Please",
report_issue,
)
if not error:
@@ -253,7 +342,7 @@ def _report_integration(
f"'{integration_frame.integration}' {what} at "
f"{integration_frame.relative_filename}, line "
f"{integration_frame.line_number}: {integration_frame.line}. "
- f"Please {report_issue}."
+ f"Please {report_issue}"
)
diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py
index b38f769b302..468539f5a9d 100644
--- a/homeassistant/helpers/intent.py
+++ b/homeassistant/helpers/intent.py
@@ -49,6 +49,7 @@ INTENT_NEVERMIND = "HassNevermind"
INTENT_SET_POSITION = "HassSetPosition"
INTENT_START_TIMER = "HassStartTimer"
INTENT_CANCEL_TIMER = "HassCancelTimer"
+INTENT_CANCEL_ALL_TIMERS = "HassCancelAllTimers"
INTENT_INCREASE_TIMER = "HassIncreaseTimer"
INTENT_DECREASE_TIMER = "HassDecreaseTimer"
INTENT_PAUSE_TIMER = "HassPauseTimer"
diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py
index d322810b0ef..38d80d5649d 100644
--- a/homeassistant/helpers/llm.py
+++ b/homeassistant/helpers/llm.py
@@ -22,15 +22,13 @@ from homeassistant.components.conversation import (
from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.intent import async_device_supports_timers
-from homeassistant.components.script import ATTR_VARIABLES, DOMAIN as SCRIPT_DOMAIN
+from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
from homeassistant.components.weather import INTENT_GET_WEATHER
from homeassistant.const import (
ATTR_DOMAIN,
- ATTR_ENTITY_ID,
ATTR_SERVICE,
EVENT_HOMEASSISTANT_CLOSE,
EVENT_SERVICE_REMOVED,
- SERVICE_TURN_ON,
)
from homeassistant.core import Context, Event, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
@@ -416,9 +414,7 @@ class AssistAPI(API):
):
continue
- script_tool = ScriptTool(self.hass, state.entity_id)
- if script_tool.parameters.schema:
- tools.append(script_tool)
+ tools.append(ScriptTool(self.hass, state.entity_id))
return tools
@@ -449,17 +445,13 @@ def _get_exposed_entities(
entities = {}
for state in hass.states.async_all():
- if not async_should_expose(hass, assistant, state.entity_id):
+ if (
+ not async_should_expose(hass, assistant, state.entity_id)
+ or state.domain == SCRIPT_DOMAIN
+ ):
continue
description: str | None = None
- if state.domain == SCRIPT_DOMAIN:
- description, parameters = _get_cached_script_parameters(
- hass, state.entity_id
- )
- if parameters.schema: # Only list scripts without input fields here
- continue
-
entity_entry = entity_registry.async_get(state.entity_id)
names = [state.name]
area_names = []
@@ -702,10 +694,9 @@ class ScriptTool(Tool):
script_entity_id: str,
) -> None:
"""Init the class."""
- self.name = split_entity_id(script_entity_id)[1]
+ self._object_id = self.name = split_entity_id(script_entity_id)[1]
if self.name[0].isdigit():
self.name = "_" + self.name
- self._entity_id = script_entity_id
self.description, self.parameters = _get_cached_script_parameters(
hass, script_entity_id
@@ -745,14 +736,13 @@ class ScriptTool(Tool):
floor = list(intent.find_floors(floor, floor_reg))[0].floor_id
tool_input.tool_args[field] = floor
- await hass.services.async_call(
+ result = await hass.services.async_call(
SCRIPT_DOMAIN,
- SERVICE_TURN_ON,
- {
- ATTR_ENTITY_ID: self._entity_id,
- ATTR_VARIABLES: tool_input.tool_args,
- },
+ self._object_id,
+ tool_input.tool_args,
context=llm_context.context,
+ blocking=True,
+ return_response=True,
)
- return {"success": True}
+ return {"success": True, "result": result}
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 86dcd858c1b..a67ef60c799 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -473,13 +473,13 @@ class _ScriptRun:
script_execution_set("aborted")
except _StopScript as err:
script_execution_set("finished", err.response)
- response = err.response
# Let the _StopScript bubble up if this is a sub-script
if not self._script.top_level:
- # We already consumed the response, do not pass it on
- err.response = None
raise
+
+ response = err.response
+
except Exception:
script_execution_set("error")
raise
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index e3da52604cb..35135010452 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -42,6 +42,7 @@ from homeassistant.core import (
)
from homeassistant.exceptions import (
HomeAssistantError,
+ ServiceNotSupported,
TemplateError,
Unauthorized,
UnknownUser,
@@ -986,9 +987,7 @@ async def entity_service_call(
):
# If entity explicitly referenced, raise an error
if referenced is not None and entity.entity_id in referenced.referenced:
- raise HomeAssistantError(
- f"Entity {entity.entity_id} does not support this service."
- )
+ raise ServiceNotSupported(call.domain, call.service, entity.entity_id)
continue
@@ -1280,11 +1279,9 @@ def async_register_entity_service(
from .frame import ReportBehavior, report_usage
report_usage(
- (
- "registers an entity service with a non entity service schema "
- "which will stop working in HA Core 2025.9"
- ),
+ "registers an entity service with a non entity service schema",
core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.9",
)
service_func: str | HassJob[..., Any]
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py
index 2eab666bbd4..5b4a48bb07c 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template.py
@@ -23,7 +23,16 @@ import statistics
from struct import error as StructError, pack, unpack_from
import sys
from types import CodeType, TracebackType
-from typing import Any, Concatenate, Literal, NoReturn, Self, cast, overload
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Concatenate,
+ Literal,
+ NoReturn,
+ Self,
+ cast,
+ overload,
+)
from urllib.parse import urlencode as urllib_urlencode
import weakref
@@ -88,6 +97,9 @@ from .singleton import singleton
from .translation import async_translate_state
from .typing import TemplateVarsType
+if TYPE_CHECKING:
+ from _typeshed import OptExcInfo
+
# mypy: allow-untyped-defs, no-check-untyped-defs
_LOGGER = logging.getLogger(__name__)
@@ -522,11 +534,9 @@ class Template:
if not hass:
report_usage(
- (
- "creates a template object without passing hass, "
- "which will stop working in HA Core 2025.10"
- ),
+ "creates a template object without passing hass",
core_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.10",
)
self.template: str = template.strip()
@@ -534,7 +544,7 @@ class Template:
self._compiled: jinja2.Template | None = None
self.hass = hass
self.is_static = not is_template_string(template)
- self._exc_info: sys._OptExcInfo | None = None
+ self._exc_info: OptExcInfo | None = None
self._limited: bool | None = None
self._strict: bool | None = None
self._log_fn: Callable[[int, str], None] | None = None
diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py
index 7f8ad41d7bb..1486e33d6fa 100644
--- a/homeassistant/helpers/trigger_template_entity.py
+++ b/homeassistant/helpers/trigger_template_entity.py
@@ -30,7 +30,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
from . import config_validation as cv
from .entity import Entity
-from .template import render_complex
+from .template import TemplateStateFromEntityId, render_complex
from .typing import ConfigType
CONF_AVAILABILITY = "availability"
@@ -231,16 +231,14 @@ class ManualTriggerEntity(TriggerBaseEntity):
Ex: self._process_manual_data(payload)
"""
- self.async_write_ha_state()
- this = None
- if state := self.hass.states.get(self.entity_id):
- this = state.as_dict()
-
run_variables: dict[str, Any] = {"value": value}
# Silently try if variable is a json and store result in `value_json` if it is.
with contextlib.suppress(*JSON_DECODE_EXCEPTIONS):
run_variables["value_json"] = json_loads(run_variables["value"])
- variables = {"this": this, **(run_variables or {})}
+ variables = {
+ "this": TemplateStateFromEntityId(self.hass, self.entity_id),
+ **(run_variables or {}),
+ }
self._render_templates(variables)
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
index 87d55891e90..6cc4584935e 100644
--- a/homeassistant/helpers/update_coordinator.py
+++ b/homeassistant/helpers/update_coordinator.py
@@ -24,6 +24,7 @@ from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
+ HomeAssistantError,
)
from homeassistant.util.dt import utcnow
@@ -43,7 +44,7 @@ _DataUpdateCoordinatorT = TypeVar(
)
-class UpdateFailed(Exception):
+class UpdateFailed(HomeAssistantError):
"""Raised when an update has failed."""
@@ -288,8 +289,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
if self.config_entry is None:
report_usage(
"uses `async_config_entry_first_refresh`, which is only supported "
- "for coordinators with a config entry and will stop working in "
- "Home Assistant 2025.11"
+ "for coordinators with a config entry",
+ breaks_in_ha_version="2025.11",
)
elif (
self.config_entry.state
@@ -298,8 +299,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
report_usage(
"uses `async_config_entry_first_refresh`, which is only supported "
f"when entry state is {config_entries.ConfigEntryState.SETUP_IN_PROGRESS}, "
- f"but it is in state {self.config_entry.state}, "
- "This will stop working in Home Assistant 2025.11",
+ f"but it is in state {self.config_entry.state}",
+ breaks_in_ha_version="2025.11",
)
if await self.__wrap_async_setup():
await self._async_refresh(
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index d2e04df04c4..1fa9d0cd49d 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -830,6 +830,9 @@ class Integration:
@cached_property
def quality_scale(self) -> str | None:
"""Return Integration Quality Scale."""
+ # Custom integrations default to "custom" quality scale.
+ if not self.is_built_in or self.overwrites_built_in:
+ return "custom"
return self.manifest.get("quality_scale")
@cached_property
@@ -1560,14 +1563,12 @@ class Components:
from .helpers.frame import ReportBehavior, report_usage
report_usage(
- (
- f"accesses hass.components.{comp_name}."
- " This is deprecated and will stop working in Home Assistant 2025.3, it"
- f" should be updated to import functions used from {comp_name} directly"
- ),
+ f"accesses hass.components.{comp_name}, which"
+ f" should be updated to import functions used from {comp_name} directly",
core_behavior=ReportBehavior.IGNORE,
core_integration_behavior=ReportBehavior.IGNORE,
custom_integration_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.3",
)
wrapped = ModuleWrapper(self._hass, component)
@@ -1592,13 +1593,13 @@ class Helpers:
report_usage(
(
- f"accesses hass.helpers.{helper_name}."
- " This is deprecated and will stop working in Home Assistant 2025.5, it"
+ f"accesses hass.helpers.{helper_name}, which"
f" should be updated to import functions used from {helper_name} directly"
),
core_behavior=ReportBehavior.IGNORE,
core_integration_behavior=ReportBehavior.IGNORE,
custom_integration_behavior=ReportBehavior.LOG,
+ breaks_in_ha_version="2025.5",
)
wrapped = ModuleWrapper(self._hass, helper)
@@ -1685,6 +1686,29 @@ def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool:
return module in hass.data[DATA_COMPONENTS]
+@callback
+def async_get_issue_integration(
+ hass: HomeAssistant | None,
+ integration_domain: str | None,
+) -> Integration | None:
+ """Return details of an integration for issue reporting."""
+ integration: Integration | None = None
+ if not hass or not integration_domain:
+ # We are unable to get the integration
+ return None
+
+ if (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS)) and not isinstance(
+ comps_or_future, asyncio.Future
+ ):
+ integration = comps_or_future.get(integration_domain)
+
+ if not integration:
+ with suppress(IntegrationNotLoaded):
+ integration = async_get_loaded_integration(hass, integration_domain)
+
+ return integration
+
+
@callback
def async_get_issue_tracker(
hass: HomeAssistant | None,
@@ -1698,20 +1722,11 @@ def async_get_issue_tracker(
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue"
)
if not integration and not integration_domain and not module:
- # If we know nothing about the entity, suggest opening an issue on HA core
+ # If we know nothing about the integration, suggest opening an issue on HA core
return issue_tracker
- if (
- not integration
- and (hass and integration_domain)
- and (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS))
- and not isinstance(comps_or_future, asyncio.Future)
- ):
- integration = comps_or_future.get(integration_domain)
-
- if not integration and (hass and integration_domain):
- with suppress(IntegrationNotLoaded):
- integration = async_get_loaded_integration(hass, integration_domain)
+ if not integration:
+ integration = async_get_issue_integration(hass, integration_domain)
if integration and not integration.is_built_in:
return integration.issue_tracker
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index f5d94921941..503937a44cb 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -5,7 +5,7 @@ aiodiscover==2.1.0
aiodns==3.2.0
aiohasupervisor==0.2.1
aiohttp-fast-zlib==0.2.0
-aiohttp==3.11.4
+aiohttp==3.11.9
aiohttp_cors==0.7.0
aiozoneinfo==0.2.1
astral==2.2
@@ -25,32 +25,32 @@ bluetooth-data-tools==1.20.0
cached-ipaddress==0.8.0
certifi>=2021.5.30
ciso8601==2.3.1
-cryptography==43.0.1
+cryptography==44.0.0
dbus-fast==2.24.3
fnv-hash-fast==1.0.2
go2rtc-client==0.1.1
ha-ffmpeg==3.2.2
habluetooth==3.6.0
-hass-nabucasa==0.84.0
-hassil==2.0.2
+hass-nabucasa==0.85.0
+hassil==2.0.5
home-assistant-bluetooth==1.13.0
-home-assistant-frontend==20241106.2
-home-assistant-intents==2024.11.13
+home-assistant-frontend==20241127.3
+home-assistant-intents==2024.12.2
httpx==0.27.2
ifaddr==0.2.0
Jinja2==3.1.4
lru-dict==1.3.0
mutagen==1.47.0
-orjson==3.10.11
+orjson==3.10.12
packaging>=23.1
paho-mqtt==1.6.1
Pillow==11.0.0
-propcache==0.2.0
+propcache==0.2.1
psutil-home-assistant==0.0.1
-PyJWT==2.9.0
+PyJWT==2.10.1
pymicro-vad==1.0.1
PyNaCl==1.5.0
-pyOpenSSL==24.2.1
+pyOpenSSL==24.3.0
pyserial==3.5
pyspeex-noise==1.0.2
python-slugify==8.0.4
@@ -58,20 +58,20 @@ PyTurboJPEG==1.7.5
pyudev==0.24.1
PyYAML==6.0.2
requests==2.32.3
-securetar==2024.2.1
-SQLAlchemy==2.0.31
+securetar==2024.11.0
+SQLAlchemy==2.0.36
standard-aifc==3.13.0;python_version>='3.13'
standard-telnetlib==3.13.0;python_version>='3.13'
typing-extensions>=4.12.2,<5.0
ulid-transform==1.0.2
urllib3>=1.26.5,<2
-uv==0.5.0
+uv==0.5.4
voluptuous-openapi==0.0.5
voluptuous-serialize==2.6.0
voluptuous==0.15.2
webrtc-models==0.3.0
-yarl==1.17.2
-zeroconf==0.136.0
+yarl==1.18.3
+zeroconf==0.136.2
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
@@ -152,10 +152,12 @@ protobuf==5.28.3
# 2.1.18 is the first version that works with our wheel builder
faust-cchardet>=2.1.18
-# websockets 11.0 is missing files in the source distribution
-# which break wheel builds so we need at least 11.0.1
-# https://github.com/aaugustin/websockets/issues/1329
-websockets>=11.0.1
+# websockets 13.1 is the first version to fully support the new
+# asyncio implementation. The legacy implementation is now
+# deprecated as of websockets 14.0.
+# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features
+# https://websockets.readthedocs.io/en/stable/howto/upgrade.html
+websockets>=13.1
# pysnmplib is no longer maintained and does not work with newer
# python
@@ -195,3 +197,16 @@ tenacity!=8.4.0
# 5.0.0 breaks Timeout as a context manager
# TypeError: 'Timeout' object does not support the context manager protocol
async-timeout==4.0.3
+
+# aiofiles keeps getting downgraded by custom components
+# causing newer methods to not be available and breaking
+# some integrations at startup
+# https://github.com/home-assistant/core/issues/127529
+# https://github.com/home-assistant/core/issues/122508
+# https://github.com/home-assistant/core/issues/118004
+aiofiles>=24.1.0
+
+# 0.22.0 causes CI failures on Python 3.13
+# python3 -X dev -m pytest tests/components/matrix
+# python3 -X dev -m pytest tests/components/zha
+rpds-py==0.21.0
diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py
index 0745bc96dfb..18f8182650b 100644
--- a/homeassistant/util/color.py
+++ b/homeassistant/util/color.py
@@ -377,7 +377,7 @@ def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> tuple[int, int, int]:
Val is scaled 0-100
"""
fRGB = colorsys.hsv_to_rgb(iH / 360, iS / 100, iV / 100)
- return (int(fRGB[0] * 255), int(fRGB[1] * 255), int(fRGB[2] * 255))
+ return (round(fRGB[0] * 255), round(fRGB[1] * 255), round(fRGB[2] * 255))
def color_hs_to_RGB(iH: float, iS: float) -> tuple[int, int, int]:
diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py
index ee2b6c762d8..eb898e4b544 100644
--- a/homeassistant/util/dt.py
+++ b/homeassistant/util/dt.py
@@ -13,6 +13,8 @@ import zoneinfo
from aiozoneinfo import async_get_time_zone as _async_get_time_zone
import ciso8601
+from homeassistant.helpers.deprecation import deprecated_function
+
DATE_STR_FORMAT = "%Y-%m-%d"
UTC = dt.UTC
DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC
@@ -170,6 +172,7 @@ utc_from_timestamp = partial(dt.datetime.fromtimestamp, tz=UTC)
"""Return a UTC time from a timestamp."""
+@deprecated_function("datetime.timestamp", breaks_in_ha_version="2026.1")
def utc_to_timestamp(utc_dt: dt.datetime) -> float:
"""Fast conversion of a datetime in UTC to a timestamp."""
# Taken from
diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py
index fa67f6b1dcc..968567ae0c9 100644
--- a/homeassistant/util/json.py
+++ b/homeassistant/util/json.py
@@ -30,32 +30,30 @@ class SerializationError(HomeAssistantError):
"""Error serializing the data to JSON."""
-def json_loads(__obj: bytes | bytearray | memoryview | str) -> JsonValueType:
+def json_loads(obj: bytes | bytearray | memoryview | str, /) -> JsonValueType:
"""Parse JSON data.
This adds a workaround for orjson not handling subclasses of str,
https://github.com/ijl/orjson/issues/445.
"""
# Avoid isinstance overhead for the common case
- if type(__obj) not in (bytes, bytearray, memoryview, str) and isinstance(
- __obj, str
- ):
- return orjson.loads(str(__obj)) # type:ignore[no-any-return]
- return orjson.loads(__obj) # type:ignore[no-any-return]
+ if type(obj) not in (bytes, bytearray, memoryview, str) and isinstance(obj, str):
+ return orjson.loads(str(obj)) # type:ignore[no-any-return]
+ return orjson.loads(obj) # type:ignore[no-any-return]
-def json_loads_array(__obj: bytes | bytearray | memoryview | str) -> JsonArrayType:
+def json_loads_array(obj: bytes | bytearray | memoryview | str, /) -> JsonArrayType:
"""Parse JSON data and ensure result is a list."""
- value: JsonValueType = json_loads(__obj)
+ value: JsonValueType = json_loads(obj)
# Avoid isinstance overhead as we are not interested in list subclasses
if type(value) is list: # noqa: E721
return value
raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}")
-def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObjectType:
+def json_loads_object(obj: bytes | bytearray | memoryview | str, /) -> JsonObjectType:
"""Parse JSON data and ensure result is a dictionary."""
- value: JsonValueType = json_loads(__obj)
+ value: JsonValueType = json_loads(obj)
# Avoid isinstance overhead as we are not interested in dict subclasses
if type(value) is dict: # noqa: E721
return value
diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py
index 1bf3561e66a..3cffcb5768e 100644
--- a/homeassistant/util/unit_conversion.py
+++ b/homeassistant/util/unit_conversion.py
@@ -10,6 +10,7 @@ from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
UNIT_NOT_RECOGNIZED_TEMPLATE,
+ UnitOfArea,
UnitOfBloodGlucoseConcentration,
UnitOfConductivity,
UnitOfDataRate,
@@ -42,6 +43,19 @@ _MILE_TO_M = _YARD_TO_M * 1760 # 1760 yard = 1 mile (1609.344 m)
_NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m
+# Area constants to square meters
+_CM2_TO_M2 = _CM_TO_M**2 # 1 cm² = 0.0001 m²
+_MM2_TO_M2 = _MM_TO_M**2 # 1 mm² = 0.000001 m²
+_KM2_TO_M2 = _KM_TO_M**2 # 1 km² = 1,000,000 m²
+
+_IN2_TO_M2 = _IN_TO_M**2 # 1 in² = 0.00064516 m²
+_FT2_TO_M2 = _FOOT_TO_M**2 # 1 ft² = 0.092903 m²
+_YD2_TO_M2 = _YARD_TO_M**2 # 1 yd² = 0.836127 m²
+_MI2_TO_M2 = _MILE_TO_M**2 # 1 mi² = 2,590,000 m²
+
+_ACRE_TO_M2 = 66 * 660 * _FT2_TO_M2 # 1 acre = 4,046.86 m²
+_HECTARE_TO_M2 = 100 * 100 # 1 hectare = 10,000 m²
+
# Duration conversion constants
_MIN_TO_SEC = 60 # 1 min = 60 seconds
_HRS_TO_MINUTES = 60 # 1 hr = 60 minutes
@@ -146,6 +160,25 @@ class DataRateConverter(BaseUnitConverter):
VALID_UNITS = set(UnitOfDataRate)
+class AreaConverter(BaseUnitConverter):
+ """Utility to convert area values."""
+
+ UNIT_CLASS = "area"
+ _UNIT_CONVERSION: dict[str | None, float] = {
+ UnitOfArea.SQUARE_METERS: 1,
+ UnitOfArea.SQUARE_CENTIMETERS: 1 / _CM2_TO_M2,
+ UnitOfArea.SQUARE_MILLIMETERS: 1 / _MM2_TO_M2,
+ UnitOfArea.SQUARE_KILOMETERS: 1 / _KM2_TO_M2,
+ UnitOfArea.SQUARE_INCHES: 1 / _IN2_TO_M2,
+ UnitOfArea.SQUARE_FEET: 1 / _FT2_TO_M2,
+ UnitOfArea.SQUARE_YARDS: 1 / _YD2_TO_M2,
+ UnitOfArea.SQUARE_MILES: 1 / _MI2_TO_M2,
+ UnitOfArea.ACRES: 1 / _ACRE_TO_M2,
+ UnitOfArea.HECTARES: 1 / _HECTARE_TO_M2,
+ }
+ VALID_UNITS = set(UnitOfArea)
+
+
class DistanceConverter(BaseUnitConverter):
"""Utility to convert distance values."""
@@ -215,10 +248,12 @@ class ElectricPotentialConverter(BaseUnitConverter):
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfElectricPotential.VOLT: 1,
UnitOfElectricPotential.MILLIVOLT: 1e3,
+ UnitOfElectricPotential.MICROVOLT: 1e6,
}
VALID_UNITS = {
UnitOfElectricPotential.VOLT,
UnitOfElectricPotential.MILLIVOLT,
+ UnitOfElectricPotential.MICROVOLT,
}
@@ -631,12 +666,15 @@ class VolumeFlowRateConverter(BaseUnitConverter):
/ (_HRS_TO_MINUTES * _L_TO_CUBIC_METER),
UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 1
/ (_HRS_TO_MINUTES * _GALLON_TO_CUBIC_METER),
+ UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND: 1
+ / (_HRS_TO_SECS * _ML_TO_CUBIC_METER),
}
VALID_UNITS = {
UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE,
UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
UnitOfVolumeFlowRate.GALLONS_PER_MINUTE,
+ UnitOfVolumeFlowRate.MILLILITERS_PER_SECOND,
}
diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py
index 7f7c7f2b5fd..c812dd38230 100644
--- a/homeassistant/util/unit_system.py
+++ b/homeassistant/util/unit_system.py
@@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.const import (
ACCUMULATED_PRECIPITATION,
+ AREA,
LENGTH,
MASS,
PRESSURE,
@@ -16,6 +17,7 @@ from homeassistant.const import (
UNIT_NOT_RECOGNIZED_TEMPLATE,
VOLUME,
WIND_SPEED,
+ UnitOfArea,
UnitOfLength,
UnitOfMass,
UnitOfPrecipitationDepth,
@@ -27,6 +29,7 @@ from homeassistant.const import (
)
from .unit_conversion import (
+ AreaConverter,
DistanceConverter,
PressureConverter,
SpeedConverter,
@@ -41,6 +44,8 @@ _CONF_UNIT_SYSTEM_IMPERIAL: Final = "imperial"
_CONF_UNIT_SYSTEM_METRIC: Final = "metric"
_CONF_UNIT_SYSTEM_US_CUSTOMARY: Final = "us_customary"
+AREA_UNITS = AreaConverter.VALID_UNITS
+
LENGTH_UNITS = DistanceConverter.VALID_UNITS
MASS_UNITS: set[str] = {
@@ -66,6 +71,7 @@ _VALID_BY_TYPE: dict[str, set[str] | set[str | None]] = {
MASS: MASS_UNITS,
VOLUME: VOLUME_UNITS,
PRESSURE: PRESSURE_UNITS,
+ AREA: AREA_UNITS,
}
@@ -84,6 +90,7 @@ class UnitSystem:
name: str,
*,
accumulated_precipitation: UnitOfPrecipitationDepth,
+ area: UnitOfArea,
conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str],
length: UnitOfLength,
mass: UnitOfMass,
@@ -97,6 +104,7 @@ class UnitSystem:
UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit, unit_type)
for unit, unit_type in (
(accumulated_precipitation, ACCUMULATED_PRECIPITATION),
+ (area, AREA),
(temperature, TEMPERATURE),
(length, LENGTH),
(wind_speed, WIND_SPEED),
@@ -112,10 +120,11 @@ class UnitSystem:
self._name = name
self.accumulated_precipitation_unit = accumulated_precipitation
- self.temperature_unit = temperature
+ self.area_unit = area
self.length_unit = length
self.mass_unit = mass
self.pressure_unit = pressure
+ self.temperature_unit = temperature
self.volume_unit = volume
self.wind_speed_unit = wind_speed
self._conversions = conversions
@@ -149,6 +158,16 @@ class UnitSystem:
precip, from_unit, self.accumulated_precipitation_unit
)
+ def area(self, area: float | None, from_unit: str) -> float:
+ """Convert the given area to this unit system."""
+ if not isinstance(area, Number):
+ raise TypeError(f"{area!s} is not a numeric value.")
+
+ # type ignore: https://github.com/python/mypy/issues/7207
+ return AreaConverter.convert( # type: ignore[unreachable]
+ area, from_unit, self.area_unit
+ )
+
def pressure(self, pressure: float | None, from_unit: str) -> float:
"""Convert the given pressure to this unit system."""
if not isinstance(pressure, Number):
@@ -184,6 +203,7 @@ class UnitSystem:
return {
LENGTH: self.length_unit,
ACCUMULATED_PRECIPITATION: self.accumulated_precipitation_unit,
+ AREA: self.area_unit,
MASS: self.mass_unit,
PRESSURE: self.pressure_unit,
TEMPERATURE: self.temperature_unit,
@@ -234,6 +254,12 @@ METRIC_SYSTEM = UnitSystem(
for unit in UnitOfPressure
if unit != UnitOfPressure.HPA
},
+ # Convert non-metric area
+ ("area", UnitOfArea.SQUARE_INCHES): UnitOfArea.SQUARE_CENTIMETERS,
+ ("area", UnitOfArea.SQUARE_FEET): UnitOfArea.SQUARE_METERS,
+ ("area", UnitOfArea.SQUARE_MILES): UnitOfArea.SQUARE_KILOMETERS,
+ ("area", UnitOfArea.SQUARE_YARDS): UnitOfArea.SQUARE_METERS,
+ ("area", UnitOfArea.ACRES): UnitOfArea.HECTARES,
# Convert non-metric distances
("distance", UnitOfLength.FEET): UnitOfLength.METERS,
("distance", UnitOfLength.INCHES): UnitOfLength.MILLIMETERS,
@@ -285,6 +311,7 @@ METRIC_SYSTEM = UnitSystem(
if unit not in (UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS)
},
},
+ area=UnitOfArea.SQUARE_METERS,
length=UnitOfLength.KILOMETERS,
mass=UnitOfMass.GRAMS,
pressure=UnitOfPressure.PA,
@@ -303,6 +330,12 @@ US_CUSTOMARY_SYSTEM = UnitSystem(
for unit in UnitOfPressure
if unit != UnitOfPressure.INHG
},
+ # Convert non-USCS areas
+ ("area", UnitOfArea.SQUARE_METERS): UnitOfArea.SQUARE_FEET,
+ ("area", UnitOfArea.SQUARE_CENTIMETERS): UnitOfArea.SQUARE_INCHES,
+ ("area", UnitOfArea.SQUARE_MILLIMETERS): UnitOfArea.SQUARE_INCHES,
+ ("area", UnitOfArea.SQUARE_KILOMETERS): UnitOfArea.SQUARE_MILES,
+ ("area", UnitOfArea.HECTARES): UnitOfArea.ACRES,
# Convert non-USCS distances
("distance", UnitOfLength.CENTIMETERS): UnitOfLength.INCHES,
("distance", UnitOfLength.KILOMETERS): UnitOfLength.MILES,
@@ -356,6 +389,7 @@ US_CUSTOMARY_SYSTEM = UnitSystem(
if unit not in (UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR)
},
},
+ area=UnitOfArea.SQUARE_FEET,
length=UnitOfLength.MILES,
mass=UnitOfMass.POUNDS,
pressure=UnitOfPressure.PSI,
diff --git a/mypy.ini b/mypy.ini
index 4d33f16d968..22e85244843 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -165,6 +165,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.acaia.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.accuweather.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3606,6 +3616,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.reolink.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.repairs.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3796,6 +3816,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.schlage.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.scrape.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -4127,6 +4157,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.stookwijzer.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.stream.*]
check_untyped_defs = true
disallow_incomplete_defs = true
diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py
index c6a869dd7fc..194f99ae700 100644
--- a/pylint/plugins/hass_imports.py
+++ b/pylint/plugins/hass_imports.py
@@ -37,140 +37,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
constant=re.compile(r"^cached_property$"),
),
],
- "homeassistant.components.alarm_control_panel": [
- ObsoleteImportMatch(
- reason="replaced by AlarmControlPanelEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by CodeFormat enum",
- constant=re.compile(r"^FORMAT_(\w*)$"),
- ),
- ],
- "homeassistant.components.alarm_control_panel.const": [
- ObsoleteImportMatch(
- reason="replaced by AlarmControlPanelEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by CodeFormat enum",
- constant=re.compile(r"^FORMAT_(\w*)$"),
- ),
- ],
- "homeassistant.components.automation": [
- ObsoleteImportMatch(
- reason="replaced by TriggerActionType from helpers.trigger",
- constant=re.compile(r"^AutomationActionType$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by TriggerData from helpers.trigger",
- constant=re.compile(r"^AutomationTriggerData$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by TriggerInfo from helpers.trigger",
- constant=re.compile(r"^AutomationTriggerInfo$"),
- ),
- ],
- "homeassistant.components.binary_sensor": [
- ObsoleteImportMatch(
- reason="replaced by BinarySensorDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ],
- "homeassistant.components.camera": [
- ObsoleteImportMatch(
- reason="replaced by CameraEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by StreamType enum",
- constant=re.compile(r"^STREAM_TYPE_(\w*)$"),
- ),
- ],
- "homeassistant.components.camera.const": [
- ObsoleteImportMatch(
- reason="replaced by StreamType enum",
- constant=re.compile(r"^STREAM_TYPE_(\w*)$"),
- ),
- ],
- "homeassistant.components.climate": [
- ObsoleteImportMatch(
- reason="replaced by HVACMode enum",
- constant=re.compile(r"^HVAC_MODE_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by ClimateEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.climate.const": [
- ObsoleteImportMatch(
- reason="replaced by HVACAction enum",
- constant=re.compile(r"^CURRENT_HVAC_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by HVACMode enum",
- constant=re.compile(r"^HVAC_MODE_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by ClimateEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.cover": [
- ObsoleteImportMatch(
- reason="replaced by CoverDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by CoverEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.device_tracker": [
- ObsoleteImportMatch(
- reason="replaced by SourceType enum",
- constant=re.compile(r"^SOURCE_TYPE_\w+$"),
- ),
- ],
- "homeassistant.components.device_tracker.const": [
- ObsoleteImportMatch(
- reason="replaced by SourceType enum",
- constant=re.compile(r"^SOURCE_TYPE_\w+$"),
- ),
- ],
- "homeassistant.components.fan": [
- ObsoleteImportMatch(
- reason="replaced by FanEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.humidifier": [
- ObsoleteImportMatch(
- reason="replaced by HumidifierDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by HumidifierEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.humidifier.const": [
- ObsoleteImportMatch(
- reason="replaced by HumidifierDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by HumidifierEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.lock": [
- ObsoleteImportMatch(
- reason="replaced by LockEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
"homeassistant.components.light": [
ObsoleteImportMatch(
reason="replaced by ColorMode enum",
@@ -225,52 +91,12 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
constant=re.compile(r"^REPEAT_MODE(\w*)$"),
),
],
- "homeassistant.components.remote": [
- ObsoleteImportMatch(
- reason="replaced by RemoteEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.sensor": [
- ObsoleteImportMatch(
- reason="replaced by SensorDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(?!STATE_CLASSES)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by SensorStateClass enum",
- constant=re.compile(r"^STATE_CLASS_(\w*)$"),
- ),
- ],
- "homeassistant.components.siren": [
- ObsoleteImportMatch(
- reason="replaced by SirenEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.siren.const": [
- ObsoleteImportMatch(
- reason="replaced by SirenEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
- "homeassistant.components.switch": [
- ObsoleteImportMatch(
- reason="replaced by SwitchDeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w*)$"),
- ),
- ],
"homeassistant.components.vacuum": [
ObsoleteImportMatch(
reason="replaced by VacuumEntityFeature enum",
constant=re.compile(r"^SUPPORT_(\w*)$"),
),
],
- "homeassistant.components.water_heater": [
- ObsoleteImportMatch(
- reason="replaced by WaterHeaterEntityFeature enum",
- constant=re.compile(r"^SUPPORT_(\w*)$"),
- ),
- ],
"homeassistant.config_entries": [
ObsoleteImportMatch(
reason="replaced by ConfigEntryDisabler enum",
@@ -282,86 +108,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
reason="replaced by local constants",
constant=re.compile(r"^CONF_UNIT_SYSTEM_(\w+)$"),
),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^DATA_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by ***DeviceClass enum",
- constant=re.compile(r"^DEVICE_CLASS_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^ELECTRIC_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^ENERGY_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by EntityCategory enum",
- constant=re.compile(r"^(ENTITY_CATEGORY_(\w+))|(ENTITY_CATEGORIES)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^FREQUENCY_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^IRRADIATION_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^LENGTH_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^MASS_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^POWER_(?!VOLT_AMPERE_REACTIVE)(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^PRECIPITATION_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^PRESSURE_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^SOUND_PRESSURE_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^SPEED_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^TEMP_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^TIME_(\w+)$"),
- ),
- ObsoleteImportMatch(
- reason="replaced by unit enums",
- constant=re.compile(r"^VOLUME_(\w+)$"),
- ),
- ],
- "homeassistant.core": [
- ObsoleteImportMatch(
- reason="replaced by ConfigSource enum",
- constant=re.compile(r"^SOURCE_(\w*)$"),
- ),
- ],
- "homeassistant.data_entry_flow": [
- ObsoleteImportMatch(
- reason="replaced by FlowResultType enum",
- constant=re.compile(r"^RESULT_TYPE_(\w*)$"),
- ),
],
"homeassistant.helpers.config_validation": [
ObsoleteImportMatch(
@@ -369,12 +115,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
constant=re.compile(r"^PLATFORM_SCHEMA(_BASE)?$"),
),
],
- "homeassistant.helpers.device_registry": [
- ObsoleteImportMatch(
- reason="replaced by DeviceEntryDisabler enum",
- constant=re.compile(r"^DISABLED_(\w*)$"),
- ),
- ],
"homeassistant.helpers.json": [
ObsoleteImportMatch(
reason="moved to homeassistant.util.json",
@@ -383,12 +123,6 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
),
),
],
- "homeassistant.util": [
- ObsoleteImportMatch(
- reason="replaced by unit_conversion.***Converter",
- constant=re.compile(r"^(distance|pressure|speed|temperature|volume)$"),
- ),
- ],
"homeassistant.util.unit_system": [
ObsoleteImportMatch(
reason="replaced by US_CUSTOMARY_SYSTEM",
diff --git a/pyproject.toml b/pyproject.toml
index f2be95a697f..1cd7cb878d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
-version = "2024.12.0.dev0"
+version = "2025.1.0.dev0"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@@ -29,7 +29,7 @@ dependencies = [
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11
"aiohasupervisor==0.2.1",
- "aiohttp==3.11.4",
+ "aiohttp==3.11.9",
"aiohttp_cors==0.7.0",
"aiohttp-fast-zlib==0.2.0",
"aiozoneinfo==0.2.1",
@@ -45,7 +45,7 @@ dependencies = [
"fnv-hash-fast==1.0.2",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
- "hass-nabucasa==0.84.0",
+ "hass-nabucasa==0.85.0",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.27.2",
@@ -53,20 +53,20 @@ dependencies = [
"ifaddr==0.2.0",
"Jinja2==3.1.4",
"lru-dict==1.3.0",
- "PyJWT==2.9.0",
+ "PyJWT==2.10.1",
# PyJWT has loose dependency. We want the latest one.
- "cryptography==43.0.1",
+ "cryptography==44.0.0",
"Pillow==11.0.0",
- "propcache==0.2.0",
- "pyOpenSSL==24.2.1",
- "orjson==3.10.11",
+ "propcache==0.2.1",
+ "pyOpenSSL==24.3.0",
+ "orjson==3.10.12",
"packaging>=23.1",
"psutil-home-assistant==0.0.1",
"python-slugify==8.0.4",
"PyYAML==6.0.2",
"requests==2.32.3",
- "securetar==2024.2.1",
- "SQLAlchemy==2.0.31",
+ "securetar==2024.11.0",
+ "SQLAlchemy==2.0.36",
"standard-aifc==3.13.0;python_version>='3.13'",
"standard-telnetlib==3.13.0;python_version>='3.13'",
"typing-extensions>=4.12.2,<5.0",
@@ -75,11 +75,11 @@ dependencies = [
# Temporary setting an upper bound, to prevent compat issues with urllib3>=2
# https://github.com/home-assistant/core/issues/97248
"urllib3>=1.26.5,<2",
- "uv==0.5.0",
+ "uv==0.5.4",
"voluptuous==0.15.2",
"voluptuous-serialize==2.6.0",
"voluptuous-openapi==0.0.5",
- "yarl==1.17.2",
+ "yarl==1.18.3",
"webrtc-models==0.3.0",
]
@@ -700,7 +700,7 @@ exclude_lines = [
]
[tool.ruff]
-required-version = ">=0.6.8"
+required-version = ">=0.8.0"
[tool.ruff.lint]
select = [
@@ -783,7 +783,7 @@ select = [
"SLOT", # flake8-slots
"T100", # Trace found: {name} used
"T20", # flake8-print
- "TCH", # flake8-type-checking
+ "TC", # flake8-type-checking
"TID", # Tidy imports
"TRY", # tryceratops
"UP", # pyupgrade
@@ -807,7 +807,6 @@ ignore = [
"PLR0915", # Too many statements ({statements} > {max_statements})
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
- "PT004", # Fixture {fixture} does not return anything, add leading underscore
"PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception
"PT018", # Assertion should be broken down into multiple parts
"RUF001", # String contains ambiguous unicode character.
@@ -820,9 +819,9 @@ ignore = [
"SIM115", # Use context handler for opening files
# Moving imports into type-checking blocks can mess with pytest.patch()
- "TCH001", # Move application import {} into a type-checking block
- "TCH002", # Move third-party import {} into a type-checking block
- "TCH003", # Move standard library import {} into a type-checking block
+ "TC001", # Move application import {} into a type-checking block
+ "TC002", # Move third-party import {} into a type-checking block
+ "TC003", # Move standard library import {} into a type-checking block
"TRY003", # Avoid specifying long messages outside the exception class
"TRY400", # Use `logging.exception` instead of `logging.error`
diff --git a/requirements.txt b/requirements.txt
index ebb35d5fffc..e4aa6dc121a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,7 +5,7 @@
# Home Assistant Core
aiodns==3.2.0
aiohasupervisor==0.2.1
-aiohttp==3.11.4
+aiohttp==3.11.9
aiohttp_cors==0.7.0
aiohttp-fast-zlib==0.2.0
aiozoneinfo==0.2.1
@@ -19,33 +19,33 @@ bcrypt==4.2.0
certifi>=2021.5.30
ciso8601==2.3.1
fnv-hash-fast==1.0.2
-hass-nabucasa==0.84.0
+hass-nabucasa==0.85.0
httpx==0.27.2
home-assistant-bluetooth==1.13.0
ifaddr==0.2.0
Jinja2==3.1.4
lru-dict==1.3.0
-PyJWT==2.9.0
-cryptography==43.0.1
+PyJWT==2.10.1
+cryptography==44.0.0
Pillow==11.0.0
-propcache==0.2.0
-pyOpenSSL==24.2.1
-orjson==3.10.11
+propcache==0.2.1
+pyOpenSSL==24.3.0
+orjson==3.10.12
packaging>=23.1
psutil-home-assistant==0.0.1
python-slugify==8.0.4
PyYAML==6.0.2
requests==2.32.3
-securetar==2024.2.1
-SQLAlchemy==2.0.31
+securetar==2024.11.0
+SQLAlchemy==2.0.36
standard-aifc==3.13.0;python_version>='3.13'
standard-telnetlib==3.13.0;python_version>='3.13'
typing-extensions>=4.12.2,<5.0
ulid-transform==1.0.2
urllib3>=1.26.5,<2
-uv==0.5.0
+uv==0.5.4
voluptuous==0.15.2
voluptuous-serialize==2.6.0
voluptuous-openapi==0.0.5
-yarl==1.17.2
+yarl==1.18.3
webrtc-models==0.3.0
diff --git a/requirements_all.txt b/requirements_all.txt
index 5d9e8b9b4f1..06e184246b2 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -4,7 +4,7 @@
-r requirements.txt
# homeassistant.components.aemet
-AEMET-OpenData==0.5.4
+AEMET-OpenData==0.6.3
# homeassistant.components.honeywell
AIOSomecomfort==0.0.25
@@ -60,7 +60,7 @@ PyFronius==0.7.3
PyLoadAPI==1.3.2
# homeassistant.components.met_eireann
-PyMetEireann==2021.8.0
+PyMetEireann==2024.11.0
# homeassistant.components.met
# homeassistant.components.norway_air
@@ -70,7 +70,7 @@ PyMetno==0.13.0
PyMicroBot==0.0.17
# homeassistant.components.nina
-PyNINA==0.3.3
+PyNINA==0.3.4
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
@@ -84,7 +84,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
-PySwitchbot==0.53.2
+PySwitchbot==0.54.0
# homeassistant.components.switchmate
PySwitchmate==0.5.1
@@ -116,7 +116,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
-SQLAlchemy==2.0.31
+SQLAlchemy==2.0.36
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -155,7 +155,7 @@ afsapi==0.2.7
agent-py==0.0.24
# homeassistant.components.geo_json_events
-aio-geojson-generic-client==0.4
+aio-geojson-generic-client==0.5
# homeassistant.components.geonetnz_quakes
aio-geojson-geonetnz-quakes==0.16
@@ -173,16 +173,16 @@ aio-geojson-usgs-earthquakes==0.3
aio-georss-gdacs==0.10
# homeassistant.components.acaia
-aioacaia==0.1.6
+aioacaia==0.1.10
# homeassistant.components.airq
-aioairq==0.3.2
+aioairq==0.4.3
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.6.10
# homeassistant.components.airzone
-aioairzone==0.9.6
+aioairzone==0.9.7
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==27.0.1
+aioesphomeapi==27.0.3
# homeassistant.components.flo
aioflo==2021.11.0
@@ -265,7 +265,7 @@ aioharmony==0.2.10
aiohasupervisor==0.2.1
# homeassistant.components.homekit_controller
-aiohomekit==3.2.6
+aiohomekit==3.2.7
# homeassistant.components.hue
aiohue==4.7.3
@@ -288,9 +288,6 @@ aiolifx-themes==0.5.5
# homeassistant.components.lifx
aiolifx==1.1.1
-# homeassistant.components.livisi
-aiolivisi==0.0.19
-
# homeassistant.components.lookin
aiolookin==1.0.0
@@ -298,7 +295,7 @@ aiolookin==1.0.0
aiolyric==2.0.1
# homeassistant.components.mealie
-aiomealie==0.9.3
+aiomealie==0.9.4
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -325,7 +322,7 @@ aioopenexchangerates==0.6.8
aiooui==0.1.7
# homeassistant.components.pegel_online
-aiopegelonline==0.0.10
+aiopegelonline==0.1.0
# homeassistant.components.acmeda
aiopulse==0.4.6
@@ -369,7 +366,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==12.0.1
+aioshelly==12.1.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -384,7 +381,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0
# homeassistant.components.cambridge_audio
-aiostreammagic==2.8.5
+aiostreammagic==2.10.0
# homeassistant.components.switcher_kis
aioswitcher==5.0.0
@@ -441,19 +438,19 @@ airthings-cloud==0.2.0
airtouch4pyapi==1.0.5
# homeassistant.components.airtouch5
-airtouch5py==0.2.10
+airtouch5py==0.2.11
# homeassistant.components.alpha_vantage
alpha-vantage==2.3.1
# homeassistant.components.amberelectric
-amberelectric==1.1.1
+amberelectric==2.0.12
# homeassistant.components.amcrest
amcrest==1.9.8
# homeassistant.components.androidtv
-androidtv[async]==0.0.73
+androidtv[async]==0.0.75
# homeassistant.components.androidtv_remote
androidtvremote2==0.1.2
@@ -546,7 +543,7 @@ av==13.1.0
axis==63
# homeassistant.components.fujitsu_fglair
-ayla-iot-unofficial==1.4.3
+ayla-iot-unofficial==1.4.4
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
@@ -582,7 +579,7 @@ beautifulsoup4==4.12.3
# beewi-smartclim==0.0.10
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.16.4
+bimmer-connected[china]==0.17.2
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
@@ -738,7 +735,7 @@ debugpy==1.8.6
# decora==0.6
# homeassistant.components.ecovacs
-deebot-client==8.4.1
+deebot-client==9.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -752,7 +749,7 @@ deluge-client==1.10.2
demetriek==0.4.0
# homeassistant.components.denonavr
-denonavr==1.0.0
+denonavr==1.0.1
# homeassistant.components.devialet
devialet==1.4.5
@@ -824,7 +821,7 @@ eliqonline==1.2.2
elkm1-lib==2.2.10
# homeassistant.components.elmax
-elmax-api==0.0.5
+elmax-api==0.0.6.1
# homeassistant.components.elvia
elvia==0.1.0
@@ -931,7 +928,7 @@ fnv-hash-fast==1.0.2
foobot_async==1.0.0
# homeassistant.components.forecast_solar
-forecast-solar==3.1.0
+forecast-solar==4.0.0
# homeassistant.components.fortios
fortiosapi==1.0.5
@@ -947,7 +944,7 @@ freesms==0.2.0
fritzconnection[qr]==1.14.0
# homeassistant.components.fyta
-fyta_cli==0.6.10
+fyta_cli==0.7.0
# homeassistant.components.google_translate
gTTS==2.2.4
@@ -1090,16 +1087,16 @@ habitipy==0.3.3
habluetooth==3.6.0
# homeassistant.components.cloud
-hass-nabucasa==0.84.0
+hass-nabucasa==0.85.0
# homeassistant.components.splunk
hass-splunk==0.1.1
# homeassistant.components.conversation
-hassil==2.0.2
+hassil==2.0.5
# homeassistant.components.jewish_calendar
-hdate==0.10.9
+hdate==0.11.1
# homeassistant.components.heatmiser
heatmiserV3==2.0.3
@@ -1127,13 +1124,13 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.60
+holidays==0.61
# homeassistant.components.frontend
-home-assistant-frontend==20241106.2
+home-assistant-frontend==20241127.3
# homeassistant.components.conversation
-home-assistant-intents==2024.11.13
+home-assistant-intents==2024.12.2
# homeassistant.components.home_connect
homeconnect==0.8.0
@@ -1312,6 +1309,9 @@ linear-garage-door==0.2.9
# homeassistant.components.linode
linode-api==4.1.9b1
+# homeassistant.components.livisi
+livisi==0.0.24
+
# homeassistant.components.google_maps
locationsharinglib==5.0.1
@@ -1370,7 +1370,7 @@ mficlient==0.5.0
micloud==0.5
# homeassistant.components.microbees
-microBeesPy==0.3.2
+microBeesPy==0.3.5
# homeassistant.components.mill
mill-local==0.3.0
@@ -1397,19 +1397,19 @@ mopeka-iot-ble==0.8.0
motionblinds==0.6.25
# homeassistant.components.motionblinds_ble
-motionblindsble==0.1.2
+motionblindsble==0.1.3
# homeassistant.components.motioneye
motioneye-client==0.3.14
# homeassistant.components.bang_olufsen
-mozart-api==4.1.1.116.0
+mozart-api==4.1.1.116.3
# homeassistant.components.mullvad
mullvad-api==1.0.0
# homeassistant.components.music_assistant
-music-assistant-client==1.0.5
+music-assistant-client==1.0.8
# homeassistant.components.tts
mutagen==1.47.0
@@ -1439,7 +1439,7 @@ netdata==1.1.0
netmap==0.7.0.2
# homeassistant.components.nam
-nettigo-air-monitor==3.3.0
+nettigo-air-monitor==4.0.0
# homeassistant.components.neurio_energy
neurio==0.3.1
@@ -1457,7 +1457,7 @@ nextcord==2.6.0
nextdns==4.0.0
# homeassistant.components.nibe_heatpump
-nibe==2.11.0
+nibe==2.13.0
# homeassistant.components.nice_go
nice-go==0.3.10
@@ -1622,7 +1622,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==1.5.0
+plugwise==1.6.1
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1672,7 +1672,7 @@ pushover_complete==1.1.1
pvo==2.1.1
# homeassistant.components.aosmith
-py-aosmith==1.0.10
+py-aosmith==1.0.11
# homeassistant.components.canary
py-canary==0.5.4
@@ -1778,7 +1778,7 @@ pyatag==0.3.5.3
pyatmo==8.1.0
# homeassistant.components.apple_tv
-pyatv==0.15.1
+pyatv==0.16.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.0.15
@@ -1859,7 +1859,7 @@ pydiscovergy==3.0.2
pydoods==1.0.2
# homeassistant.components.hydrawise
-pydrawise==2024.9.0
+pydrawise==2024.12.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@@ -1892,7 +1892,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.22.0
+pyenphase==1.23.0
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -1907,7 +1907,7 @@ pyeverlights==0.1.0
pyevilgenius==2.0.0
# homeassistant.components.ezviz
-pyezviz==0.2.1.2
+pyezviz==0.2.2.3
# homeassistant.components.fibaro
pyfibaro==0.8.0
@@ -2027,7 +2027,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
-pylamarzocco==1.2.3
+pylamarzocco==1.2.12
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2048,7 +2048,7 @@ pylitejet==0.6.3
pylitterbot==2023.5.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.21.1
+pylutron-caseta==0.22.0
# homeassistant.components.lutron
pylutron==0.2.16
@@ -2090,7 +2090,7 @@ pymsteams==0.1.12
pymysensors==0.24.0
# homeassistant.components.iron_os
-pynecil==0.2.1
+pynecil==1.0.1
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -2149,13 +2149,13 @@ pyotgw==2.2.2
pyotp==2.8.0
# homeassistant.components.overkiz
-pyoverkiz==1.14.1
+pyoverkiz==1.15.0
# homeassistant.components.onewire
pyownet==0.10.0.post1
# homeassistant.components.palazzetti
-pypalazzetti==0.1.12
+pypalazzetti==0.1.14
# homeassistant.components.elv
pypca==0.0.7
@@ -2221,7 +2221,7 @@ pysabnzbd==1.1.1
pysaj==0.0.16
# homeassistant.components.schlage
-pyschlage==2024.8.0
+pyschlage==2024.11.0
# homeassistant.components.sensibo
pysensibo==1.1.0
@@ -2269,7 +2269,7 @@ pysmarty2==0.10.1
pysml==0.0.12
# homeassistant.components.smlight
-pysmlight==0.1.3
+pysmlight==0.1.4
# homeassistant.components.snmp
pysnmp==6.2.6
@@ -2293,7 +2293,7 @@ pysqueezebox==0.10.0
pystiebeleltron==0.0.1.dev2
# homeassistant.components.suez_water
-pysuezV2==1.3.1
+pysuezV2==1.3.2
# homeassistant.components.switchbee
pyswitchbee==1.8.3
@@ -2347,7 +2347,7 @@ python-gitlab==1.6.0
python-homeassistant-analytics==0.8.0
# homeassistant.components.homewizard
-python-homewizard-energy==v6.3.0
+python-homewizard-energy==v7.0.0
# homeassistant.components.hp_ilo
python-hpilo==4.4.3
@@ -2362,7 +2362,7 @@ python-join-api==0.0.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.7.7
+python-kasa[speedups]==0.8.0
# homeassistant.components.linkplay
python-linkplay==0.0.20
@@ -2435,7 +2435,7 @@ pytomorrowio==0.3.6
pytouchline==0.7
# homeassistant.components.touchline_sl
-pytouchlinesl==0.1.9
+pytouchlinesl==0.3.0
# homeassistant.components.traccar
# homeassistant.components.traccar_server
@@ -2448,7 +2448,7 @@ pytradfri[async]==9.0.1
# homeassistant.components.trafikverket_ferry
# homeassistant.components.trafikverket_train
# homeassistant.components.trafikverket_weatherstation
-pytrafikverket==1.0.0
+pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==0.8.0
@@ -2544,19 +2544,19 @@ rapt-ble==0.1.2
raspyrfm-client==1.2.8
# homeassistant.components.refoss
-refoss-ha==1.2.4
+refoss-ha==1.2.5
# homeassistant.components.rainmachine
regenmaschine==2024.03.0
# homeassistant.components.renault
-renault-api==0.2.7
+renault-api==0.2.8
# homeassistant.components.renson
renson-endura-delta==1.7.1
# homeassistant.components.reolink
-reolink-aio==0.11.1
+reolink-aio==0.11.4
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2565,7 +2565,7 @@ rfk101py==0.0.1
rflink==0.0.66
# homeassistant.components.ring
-ring-doorbell==0.9.12
+ring-doorbell==0.9.13
# homeassistant.components.fleetgo
ritassist==0.9.2
@@ -2610,7 +2610,7 @@ rxv==0.7.0
samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
-samsungtvws[async,encrypted]==2.6.0
+samsungtvws[async,encrypted]==2.7.1
# homeassistant.components.sanix
sanix==1.0.6
@@ -2625,7 +2625,7 @@ screenlogicpy==0.10.0
scsgate==0.1.0
# homeassistant.components.backup
-securetar==2024.2.1
+securetar==2024.11.0
# homeassistant.components.sendgrid
sendgrid==6.8.2
@@ -2701,10 +2701,10 @@ soco==0.30.6
solaredge-local==0.2.3
# homeassistant.components.solarlog
-solarlog_cli==0.3.2
+solarlog_cli==0.4.0
# homeassistant.components.solax
-solax==3.1.1
+solax==3.2.1
# homeassistant.components.somfy_mylink
somfy-mylink-synergy==1.0.6
@@ -2719,7 +2719,7 @@ speak2mary==1.4.0
speedtest-cli==2.1.3
# homeassistant.components.spotify
-spotifyaio==0.8.8
+spotifyaio==0.8.11
# homeassistant.components.sql
sqlparse==0.5.0
@@ -2746,7 +2746,7 @@ steamodd==4.21
stookalert==0.1.4
# homeassistant.components.stookwijzer
-stookwijzer==1.3.0
+stookwijzer==1.5.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2837,7 +2837,7 @@ thermopro-ble==0.10.0
thingspeak==1.0.0
# homeassistant.components.lg_thinq
-thinqconnect==1.0.0
+thinqconnect==1.0.1
# homeassistant.components.tikteck
tikteck==0.4
@@ -2897,7 +2897,7 @@ typedmonarchmoney==0.3.1
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==6.6.0
+uiprotect==6.6.5
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2906,7 +2906,7 @@ ultraheat-api==0.5.7
unifi-discovery==1.2.0
# homeassistant.components.unifi_direct
-unifi_ap==0.0.1
+unifi_ap==0.0.2
# homeassistant.components.unifiled
unifiled==0.11
@@ -2938,7 +2938,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2024.11.0
+velbus-aio==2024.11.1
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -2947,7 +2947,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0
# homeassistant.components.voip
-voip-utils==0.1.0
+voip-utils==0.2.1
# homeassistant.components.volkszaehler
volkszaehler==0.4.0
@@ -2987,13 +2987,13 @@ weatherflow4py==1.0.6
webexpythonsdk==2.0.1
# homeassistant.components.nasweb
-webio-api==0.1.8
+webio-api==0.1.11
# homeassistant.components.webmin
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
-weheat==2024.11.02
+weheat==2024.11.26
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.18.8
@@ -3044,7 +3044,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
-yalexs-ble==2.5.0
+yalexs-ble==2.5.1
# homeassistant.components.august
# homeassistant.components.yale
@@ -3066,7 +3066,7 @@ youless-api==2.1.2
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp[default]==2024.11.04
+yt-dlp[default]==2024.11.18
# homeassistant.components.zamg
zamg==0.3.6
@@ -3075,13 +3075,13 @@ zamg==0.3.6
zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.136.0
+zeroconf==0.136.2
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.39
+zha==0.0.41
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
@@ -3093,7 +3093,7 @@ ziggo-mediabox-xl==1.1.0
zm-py==0.5.4
# homeassistant.components.zwave_js
-zwave-js-server-python==0.59.1
+zwave-js-server-python==0.60.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
diff --git a/requirements_test.txt b/requirements_test.txt
index 73874e3a631..34dcdfc1244 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -8,11 +8,11 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
astroid==3.3.5
-coverage==7.6.1
+coverage==7.6.8
freezegun==1.5.1
license-expression==30.4.0
mock-open==1.4.0
-mypy-dev==1.14.0a3
+mypy-dev==1.14.0a5
pre-commit==4.0.0
pydantic==1.10.19
pylint==3.3.1
@@ -20,7 +20,7 @@ pylint-per-file-ignores==1.3.2
pipdeptree==2.23.4
pytest-asyncio==0.24.0
pytest-aiohttp==1.0.5
-pytest-cov==5.0.0
+pytest-cov==6.0.0
pytest-freezer==0.4.8
pytest-github-actions-annotate-failures==0.2.0
pytest-socket==0.7.0
@@ -32,19 +32,19 @@ pytest-xdist==3.6.1
pytest==8.3.3
requests-mock==1.12.1
respx==0.21.1
-syrupy==4.7.2
+syrupy==4.8.0
tqdm==4.66.5
types-aiofiles==24.1.0.20240626
types-atomicwrites==1.4.5.1
-types-croniter==2.0.0.20240423
-types-beautifulsoup4==4.12.0.20240907
-types-caldav==1.3.0.20240824
+types-croniter==4.0.0.20241030
+types-beautifulsoup4==4.12.0.20241020
+types-caldav==1.3.0.20241107
types-chardet==0.1.5
types-decorator==5.1.8.20240310
types-paho-mqtt==1.6.0.20240321
types-pillow==10.2.0.20240822
-types-protobuf==5.28.0.20240924
-types-psutil==6.0.0.20240901
+types-protobuf==5.28.3.20241030
+types-psutil==6.1.0.20241102
types-python-dateutil==2.9.0.20241003
types-python-slugify==8.0.2.20240310
types-pytz==2024.2.0.20241003
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index dd6ecb1c30e..52dcb44e47d 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -4,7 +4,7 @@
-r requirements_test.txt
# homeassistant.components.aemet
-AEMET-OpenData==0.5.4
+AEMET-OpenData==0.6.3
# homeassistant.components.honeywell
AIOSomecomfort==0.0.25
@@ -57,7 +57,7 @@ PyFronius==0.7.3
PyLoadAPI==1.3.2
# homeassistant.components.met_eireann
-PyMetEireann==2021.8.0
+PyMetEireann==2024.11.0
# homeassistant.components.met
# homeassistant.components.norway_air
@@ -67,7 +67,7 @@ PyMetno==0.13.0
PyMicroBot==0.0.17
# homeassistant.components.nina
-PyNINA==0.3.3
+PyNINA==0.3.4
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
@@ -81,7 +81,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
-PySwitchbot==0.53.2
+PySwitchbot==0.54.0
# homeassistant.components.syncthru
PySyncThru==0.7.10
@@ -110,7 +110,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
-SQLAlchemy==2.0.31
+SQLAlchemy==2.0.36
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -143,7 +143,7 @@ afsapi==0.2.7
agent-py==0.0.24
# homeassistant.components.geo_json_events
-aio-geojson-generic-client==0.4
+aio-geojson-generic-client==0.5
# homeassistant.components.geonetnz_quakes
aio-geojson-geonetnz-quakes==0.16
@@ -161,16 +161,16 @@ aio-geojson-usgs-earthquakes==0.3
aio-georss-gdacs==0.10
# homeassistant.components.acaia
-aioacaia==0.1.6
+aioacaia==0.1.10
# homeassistant.components.airq
-aioairq==0.3.2
+aioairq==0.4.3
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.6.10
# homeassistant.components.airzone
-aioairzone==0.9.6
+aioairzone==0.9.7
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==27.0.1
+aioesphomeapi==27.0.3
# homeassistant.components.flo
aioflo==2021.11.0
@@ -250,7 +250,7 @@ aioharmony==0.2.10
aiohasupervisor==0.2.1
# homeassistant.components.homekit_controller
-aiohomekit==3.2.6
+aiohomekit==3.2.7
# homeassistant.components.hue
aiohue==4.7.3
@@ -270,9 +270,6 @@ aiolifx-themes==0.5.5
# homeassistant.components.lifx
aiolifx==1.1.1
-# homeassistant.components.livisi
-aiolivisi==0.0.19
-
# homeassistant.components.lookin
aiolookin==1.0.0
@@ -280,7 +277,7 @@ aiolookin==1.0.0
aiolyric==2.0.1
# homeassistant.components.mealie
-aiomealie==0.9.3
+aiomealie==0.9.4
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -307,7 +304,7 @@ aioopenexchangerates==0.6.8
aiooui==0.1.7
# homeassistant.components.pegel_online
-aiopegelonline==0.0.10
+aiopegelonline==0.1.0
# homeassistant.components.acmeda
aiopulse==0.4.6
@@ -351,7 +348,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==12.0.1
+aioshelly==12.1.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -366,7 +363,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0
# homeassistant.components.cambridge_audio
-aiostreammagic==2.8.5
+aiostreammagic==2.10.0
# homeassistant.components.switcher_kis
aioswitcher==5.0.0
@@ -423,13 +420,13 @@ airthings-cloud==0.2.0
airtouch4pyapi==1.0.5
# homeassistant.components.airtouch5
-airtouch5py==0.2.10
+airtouch5py==0.2.11
# homeassistant.components.amberelectric
-amberelectric==1.1.1
+amberelectric==2.0.12
# homeassistant.components.androidtv
-androidtv[async]==0.0.73
+androidtv[async]==0.0.75
# homeassistant.components.androidtv_remote
androidtvremote2==0.1.2
@@ -495,7 +492,7 @@ av==13.1.0
axis==63
# homeassistant.components.fujitsu_fglair
-ayla-iot-unofficial==1.4.3
+ayla-iot-unofficial==1.4.4
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
@@ -516,7 +513,7 @@ base36==0.1.1
beautifulsoup4==4.12.3
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.16.4
+bimmer-connected[china]==0.17.2
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
@@ -628,7 +625,7 @@ dbus-fast==2.24.3
debugpy==1.8.6
# homeassistant.components.ecovacs
-deebot-client==8.4.1
+deebot-client==9.0.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -642,7 +639,7 @@ deluge-client==1.10.2
demetriek==0.4.0
# homeassistant.components.denonavr
-denonavr==1.0.0
+denonavr==1.0.1
# homeassistant.components.devialet
devialet==1.4.5
@@ -699,7 +696,7 @@ elgato==5.1.2
elkm1-lib==2.2.10
# homeassistant.components.elmax
-elmax-api==0.0.5
+elmax-api==0.0.6.1
# homeassistant.components.elvia
elvia==0.1.0
@@ -790,7 +787,7 @@ fnv-hash-fast==1.0.2
foobot_async==1.0.0
# homeassistant.components.forecast_solar
-forecast-solar==3.1.0
+forecast-solar==4.0.0
# homeassistant.components.freebox
freebox-api==1.1.0
@@ -800,7 +797,7 @@ freebox-api==1.1.0
fritzconnection[qr]==1.14.0
# homeassistant.components.fyta
-fyta_cli==0.6.10
+fyta_cli==0.7.0
# homeassistant.components.google_translate
gTTS==2.2.4
@@ -928,13 +925,13 @@ habitipy==0.3.3
habluetooth==3.6.0
# homeassistant.components.cloud
-hass-nabucasa==0.84.0
+hass-nabucasa==0.85.0
# homeassistant.components.conversation
-hassil==2.0.2
+hassil==2.0.5
# homeassistant.components.jewish_calendar
-hdate==0.10.9
+hdate==0.11.1
# homeassistant.components.here_travel_time
here-routing==1.0.1
@@ -953,13 +950,13 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.60
+holidays==0.61
# homeassistant.components.frontend
-home-assistant-frontend==20241106.2
+home-assistant-frontend==20241127.3
# homeassistant.components.conversation
-home-assistant-intents==2024.11.13
+home-assistant-intents==2024.12.2
# homeassistant.components.home_connect
homeconnect==0.8.0
@@ -1093,6 +1090,9 @@ libsoundtouch==0.8
# homeassistant.components.linear_garage_door
linear-garage-door==0.2.9
+# homeassistant.components.livisi
+livisi==0.0.24
+
# homeassistant.components.london_underground
london-tube-status==0.5
@@ -1139,7 +1139,7 @@ mficlient==0.5.0
micloud==0.5
# homeassistant.components.microbees
-microBeesPy==0.3.2
+microBeesPy==0.3.5
# homeassistant.components.mill
mill-local==0.3.0
@@ -1166,19 +1166,19 @@ mopeka-iot-ble==0.8.0
motionblinds==0.6.25
# homeassistant.components.motionblinds_ble
-motionblindsble==0.1.2
+motionblindsble==0.1.3
# homeassistant.components.motioneye
motioneye-client==0.3.14
# homeassistant.components.bang_olufsen
-mozart-api==4.1.1.116.0
+mozart-api==4.1.1.116.3
# homeassistant.components.mullvad
mullvad-api==1.0.0
# homeassistant.components.music_assistant
-music-assistant-client==1.0.5
+music-assistant-client==1.0.8
# homeassistant.components.tts
mutagen==1.47.0
@@ -1202,7 +1202,7 @@ nessclient==1.1.2
netmap==0.7.0.2
# homeassistant.components.nam
-nettigo-air-monitor==3.3.0
+nettigo-air-monitor==4.0.0
# homeassistant.components.nexia
nexia==2.0.8
@@ -1217,11 +1217,14 @@ nextcord==2.6.0
nextdns==4.0.0
# homeassistant.components.nibe_heatpump
-nibe==2.11.0
+nibe==2.13.0
# homeassistant.components.nice_go
nice-go==0.3.10
+# homeassistant.components.niko_home_control
+niko-home-control==0.2.1
+
# homeassistant.components.nfandroidtv
notifications-android-tv==0.1.5
@@ -1329,7 +1332,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==1.5.0
+plugwise==1.6.1
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1367,7 +1370,7 @@ pushover_complete==1.1.1
pvo==2.1.1
# homeassistant.components.aosmith
-py-aosmith==1.0.10
+py-aosmith==1.0.11
# homeassistant.components.canary
py-canary==0.5.4
@@ -1449,7 +1452,7 @@ pyatag==0.3.5.3
pyatmo==8.1.0
# homeassistant.components.apple_tv
-pyatv==0.15.1
+pyatv==0.16.0
# homeassistant.components.aussie_broadband
pyaussiebb==0.0.15
@@ -1500,7 +1503,7 @@ pydexcom==0.2.3
pydiscovergy==3.0.2
# homeassistant.components.hydrawise
-pydrawise==2024.9.0
+pydrawise==2024.12.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@@ -1527,7 +1530,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.22.0
+pyenphase==1.23.0
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1536,7 +1539,7 @@ pyeverlights==0.1.0
pyevilgenius==2.0.0
# homeassistant.components.ezviz
-pyezviz==0.2.1.2
+pyezviz==0.2.2.3
# homeassistant.components.fibaro
pyfibaro==0.8.0
@@ -1632,7 +1635,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.2
# homeassistant.components.lamarzocco
-pylamarzocco==1.2.3
+pylamarzocco==1.2.12
# homeassistant.components.lastfm
pylast==5.1.0
@@ -1653,7 +1656,7 @@ pylitejet==0.6.3
pylitterbot==2023.5.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.21.1
+pylutron-caseta==0.22.0
# homeassistant.components.lutron
pylutron==0.2.16
@@ -1686,7 +1689,7 @@ pymonoprice==0.4
pymysensors==0.24.0
# homeassistant.components.iron_os
-pynecil==0.2.1
+pynecil==1.0.1
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -1736,13 +1739,13 @@ pyotgw==2.2.2
pyotp==2.8.0
# homeassistant.components.overkiz
-pyoverkiz==1.14.1
+pyoverkiz==1.15.0
# homeassistant.components.onewire
pyownet==0.10.0.post1
# homeassistant.components.palazzetti
-pypalazzetti==0.1.12
+pypalazzetti==0.1.14
# homeassistant.components.lcn
pypck==0.7.24
@@ -1790,7 +1793,7 @@ pyrympro==0.0.8
pysabnzbd==1.1.1
# homeassistant.components.schlage
-pyschlage==2024.8.0
+pyschlage==2024.11.0
# homeassistant.components.sensibo
pysensibo==1.1.0
@@ -1829,7 +1832,7 @@ pysmarty2==0.10.1
pysml==0.0.12
# homeassistant.components.smlight
-pysmlight==0.1.3
+pysmlight==0.1.4
# homeassistant.components.snmp
pysnmp==6.2.6
@@ -1850,7 +1853,7 @@ pyspeex-noise==1.0.2
pysqueezebox==0.10.0
# homeassistant.components.suez_water
-pysuezV2==1.3.1
+pysuezV2==1.3.2
# homeassistant.components.switchbee
pyswitchbee==1.8.3
@@ -1880,7 +1883,7 @@ python-fullykiosk==0.0.14
python-homeassistant-analytics==0.8.0
# homeassistant.components.homewizard
-python-homewizard-energy==v6.3.0
+python-homewizard-energy==v7.0.0
# homeassistant.components.izone
python-izone==1.2.9
@@ -1889,7 +1892,7 @@ python-izone==1.2.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.7.7
+python-kasa[speedups]==0.8.0
# homeassistant.components.linkplay
python-linkplay==0.0.20
@@ -1947,7 +1950,7 @@ pytile==2023.12.0
pytomorrowio==0.3.6
# homeassistant.components.touchline_sl
-pytouchlinesl==0.1.9
+pytouchlinesl==0.3.0
# homeassistant.components.traccar
# homeassistant.components.traccar_server
@@ -1960,7 +1963,7 @@ pytradfri[async]==9.0.1
# homeassistant.components.trafikverket_ferry
# homeassistant.components.trafikverket_train
# homeassistant.components.trafikverket_weatherstation
-pytrafikverket==1.0.0
+pytrafikverket==1.1.1
# homeassistant.components.v2c
pytrydan==0.8.0
@@ -2035,25 +2038,25 @@ radiotherm==2.1.0
rapt-ble==0.1.2
# homeassistant.components.refoss
-refoss-ha==1.2.4
+refoss-ha==1.2.5
# homeassistant.components.rainmachine
regenmaschine==2024.03.0
# homeassistant.components.renault
-renault-api==0.2.7
+renault-api==0.2.8
# homeassistant.components.renson
renson-endura-delta==1.7.1
# homeassistant.components.reolink
-reolink-aio==0.11.1
+reolink-aio==0.11.4
# homeassistant.components.rflink
rflink==0.0.66
# homeassistant.components.ring
-ring-doorbell==0.9.12
+ring-doorbell==0.9.13
# homeassistant.components.roku
rokuecp==0.19.3
@@ -2086,7 +2089,7 @@ rxv==0.7.0
samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
-samsungtvws[async,encrypted]==2.6.0
+samsungtvws[async,encrypted]==2.7.1
# homeassistant.components.sanix
sanix==1.0.6
@@ -2095,7 +2098,7 @@ sanix==1.0.6
screenlogicpy==0.10.0
# homeassistant.components.backup
-securetar==2024.2.1
+securetar==2024.11.0
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
@@ -2153,10 +2156,10 @@ snapcast==2.3.6
soco==0.30.6
# homeassistant.components.solarlog
-solarlog_cli==0.3.2
+solarlog_cli==0.4.0
# homeassistant.components.solax
-solax==3.1.1
+solax==3.2.1
# homeassistant.components.somfy_mylink
somfy-mylink-synergy==1.0.6
@@ -2171,7 +2174,7 @@ speak2mary==1.4.0
speedtest-cli==2.1.3
# homeassistant.components.spotify
-spotifyaio==0.8.8
+spotifyaio==0.8.11
# homeassistant.components.sql
sqlparse==0.5.0
@@ -2195,7 +2198,7 @@ steamodd==4.21
stookalert==0.1.4
# homeassistant.components.stookwijzer
-stookwijzer==1.3.0
+stookwijzer==1.5.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2259,7 +2262,7 @@ thermobeacon-ble==0.7.0
thermopro-ble==0.10.0
# homeassistant.components.lg_thinq
-thinqconnect==1.0.0
+thinqconnect==1.0.1
# homeassistant.components.tilt_ble
tilt-ble==0.2.3
@@ -2310,7 +2313,7 @@ typedmonarchmoney==0.3.1
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==6.6.0
+uiprotect==6.6.5
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2345,7 +2348,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2024.11.0
+velbus-aio==2024.11.1
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -2354,7 +2357,7 @@ venstarcolortouch==0.19
vilfo-api-client==0.5.0
# homeassistant.components.voip
-voip-utils==0.1.0
+voip-utils==0.2.1
# homeassistant.components.volvooncall
volvooncall==0.10.3
@@ -2382,13 +2385,13 @@ watchdog==2.3.1
weatherflow4py==1.0.6
# homeassistant.components.nasweb
-webio-api==0.1.8
+webio-api==0.1.11
# homeassistant.components.webmin
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
-weheat==2024.11.02
+weheat==2024.11.26
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.18.8
@@ -2433,7 +2436,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
-yalexs-ble==2.5.0
+yalexs-ble==2.5.1
# homeassistant.components.august
# homeassistant.components.yale
@@ -2452,22 +2455,22 @@ youless-api==2.1.2
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp[default]==2024.11.04
+yt-dlp[default]==2024.11.18
# homeassistant.components.zamg
zamg==0.3.6
# homeassistant.components.zeroconf
-zeroconf==0.136.0
+zeroconf==0.136.2
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.39
+zha==0.0.41
# homeassistant.components.zwave_js
-zwave-js-server-python==0.59.1
+zwave-js-server-python==0.60.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index 85e7bfc4eda..b263373f11d 100644
--- a/requirements_test_pre_commit.txt
+++ b/requirements_test_pre_commit.txt
@@ -1,5 +1,5 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.3.0
-ruff==0.7.4
+ruff==0.8.1
yamllint==1.35.1
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 7d53741c661..450469096ea 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -185,10 +185,12 @@ protobuf==5.28.3
# 2.1.18 is the first version that works with our wheel builder
faust-cchardet>=2.1.18
-# websockets 11.0 is missing files in the source distribution
-# which break wheel builds so we need at least 11.0.1
-# https://github.com/aaugustin/websockets/issues/1329
-websockets>=11.0.1
+# websockets 13.1 is the first version to fully support the new
+# asyncio implementation. The legacy implementation is now
+# deprecated as of websockets 14.0.
+# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features
+# https://websockets.readthedocs.io/en/stable/howto/upgrade.html
+websockets>=13.1
# pysnmplib is no longer maintained and does not work with newer
# python
@@ -228,6 +230,19 @@ tenacity!=8.4.0
# 5.0.0 breaks Timeout as a context manager
# TypeError: 'Timeout' object does not support the context manager protocol
async-timeout==4.0.3
+
+# aiofiles keeps getting downgraded by custom components
+# causing newer methods to not be available and breaking
+# some integrations at startup
+# https://github.com/home-assistant/core/issues/127529
+# https://github.com/home-assistant/core/issues/122508
+# https://github.com/home-assistant/core/issues/118004
+aiofiles>=24.1.0
+
+# 0.22.0 causes CI failures on Python 3.13
+# python3 -X dev -m pytest tests/components/matrix
+# python3 -X dev -m pytest tests/components/zha
+rpds-py==0.21.0
"""
GENERATED_MESSAGE = (
@@ -348,8 +363,8 @@ def gather_modules() -> dict[str, list[str]] | None:
gather_requirements_from_manifests(errors, reqs)
gather_requirements_from_modules(errors, reqs)
- for key in reqs:
- reqs[key] = sorted(reqs[key], key=lambda name: (len(name.split(".")), name))
+ for value in reqs.values():
+ value = sorted(value, key=lambda name: (len(name.split(".")), name))
if errors:
print("******* ERROR")
diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py
index f0b9ad25dd0..81670de5afd 100644
--- a/script/hassfest/__main__.py
+++ b/script/hassfest/__main__.py
@@ -23,6 +23,7 @@ from . import (
metadata,
mqtt,
mypy_config,
+ quality_scale,
requirements,
services,
ssdp,
@@ -43,6 +44,7 @@ INTEGRATION_PLUGINS = [
json,
manifest,
mqtt,
+ quality_scale,
requirements,
services,
ssdp,
diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile
index 73edada8992..38b8ba5e8d0 100644
--- a/script/hassfest/docker/Dockerfile
+++ b/script/hassfest/docker/Dockerfile
@@ -14,7 +14,7 @@ WORKDIR "/github/workspace"
COPY . /usr/src/homeassistant
# Uv is only needed during build
-RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \
+RUN --mount=from=ghcr.io/astral-sh/uv:0.5.4,source=/uv,target=/bin/uv \
# Required for PyTurboJPEG
apk add --no-cache libturbojpeg \
&& uv pip install \
@@ -22,8 +22,8 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \
--no-cache \
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
-r /usr/src/homeassistant/requirements.txt \
- stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.4 \
- PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.2 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
+ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.8.1 \
+ PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.5 home-assistant-intents==2024.12.2 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant "
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index 4013c8a6c19..fdbcf5bcb78 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from enum import IntEnum
+from enum import StrEnum, auto
import json
from pathlib import Path
import subprocess
@@ -20,7 +20,7 @@ from voluptuous.humanize import humanize_error
from homeassistant.const import Platform
from homeassistant.helpers import config_validation as cv
-from .model import Config, Integration
+from .model import Config, Integration, ScaledQualityScaleTiers
DOCUMENTATION_URL_SCHEMA = "https"
DOCUMENTATION_URL_HOST = "www.home-assistant.io"
@@ -28,16 +28,20 @@ DOCUMENTATION_URL_PATH_PREFIX = "/integrations/"
DOCUMENTATION_URL_EXCEPTIONS = {"https://www.home-assistant.io/hassio"}
-class QualityScale(IntEnum):
+class NonScaledQualityScaleTiers(StrEnum):
"""Supported manifest quality scales."""
- INTERNAL = -1
- SILVER = 1
- GOLD = 2
- PLATINUM = 3
+ CUSTOM = auto()
+ NO_SCORE = auto()
+ INTERNAL = auto()
+ LEGACY = auto()
-SUPPORTED_QUALITY_SCALES = [enum.name.lower() for enum in QualityScale]
+SUPPORTED_QUALITY_SCALES = [
+ value.name.lower()
+ for enum in [ScaledQualityScaleTiers, NonScaledQualityScaleTiers]
+ for value in enum
+]
SUPPORTED_IOT_CLASSES = [
"assumed_state",
"calculated",
@@ -111,19 +115,6 @@ NO_IOT_CLASS = [
"websocket_api",
"zone",
]
-# Grandfather rule for older integrations
-# https://github.com/home-assistant/developers.home-assistant/pull/1512
-NO_DIAGNOSTICS = [
- "dlna_dms",
- "hyperion",
- "nightscout",
- "pvpc_hourly_pricing",
- "risco",
- "smarttub",
- "songpal",
- "vizio",
- "yeelight",
-]
def documentation_url(value: str) -> str:
@@ -359,36 +350,17 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No
"Virtual integration points to non-existing supported_by integration",
)
- if (quality_scale := integration.manifest.get("quality_scale")) and QualityScale[
- quality_scale.upper()
- ] > QualityScale.SILVER:
+ if (
+ (quality_scale := integration.manifest.get("quality_scale"))
+ and quality_scale.upper() in ScaledQualityScaleTiers
+ and ScaledQualityScaleTiers[quality_scale.upper()]
+ >= ScaledQualityScaleTiers.SILVER
+ ):
if not integration.manifest.get("codeowners"):
integration.add_error(
"manifest",
f"{quality_scale} integration does not have a code owner",
)
- if (
- domain not in NO_DIAGNOSTICS
- and not (integration.path / "diagnostics.py").exists()
- ):
- integration.add_error(
- "manifest",
- f"{quality_scale} integration does not implement diagnostics",
- )
-
- if domain in NO_DIAGNOSTICS:
- if quality_scale and QualityScale[quality_scale.upper()] < QualityScale.GOLD:
- integration.add_error(
- "manifest",
- "{quality_scale} integration should be "
- "removed from NO_DIAGNOSTICS in script/hassfest/manifest.py",
- )
- elif (integration.path / "diagnostics.py").exists():
- integration.add_error(
- "manifest",
- "Implements diagnostics and can be "
- "removed from NO_DIAGNOSTICS in script/hassfest/manifest.py",
- )
if not integration.core:
validate_version(integration)
diff --git a/script/hassfest/model.py b/script/hassfest/model.py
index 63e9b025ed4..377f82b0d5c 100644
--- a/script/hassfest/model.py
+++ b/script/hassfest/model.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
+from enum import IntEnum
import json
import pathlib
from typing import Any, Literal
@@ -230,3 +231,12 @@ class Integration:
self._manifest = manifest
self.manifest_path = manifest_path
+
+
+class ScaledQualityScaleTiers(IntEnum):
+ """Supported manifest quality scales."""
+
+ BRONZE = 1
+ SILVER = 2
+ GOLD = 3
+ PLATINUM = 4
diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py
new file mode 100644
index 00000000000..4f3c7ea7cbc
--- /dev/null
+++ b/script/hassfest/quality_scale.py
@@ -0,0 +1,1401 @@
+"""Validate integration quality scale files."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import voluptuous as vol
+from voluptuous.humanize import humanize_error
+
+from homeassistant.const import Platform
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util.yaml import load_yaml_dict
+
+from .model import Config, Integration, ScaledQualityScaleTiers
+from .quality_scale_validation import (
+ RuleValidationProtocol,
+ config_entry_unloading,
+ config_flow,
+ diagnostics,
+ discovery,
+ reauthentication_flow,
+ reconfiguration_flow,
+ runtime_data,
+ strict_typing,
+ unique_config_entry,
+)
+
+QUALITY_SCALE_TIERS = {value.name.lower(): value for value in ScaledQualityScaleTiers}
+
+
+@dataclass
+class Rule:
+ """Quality scale rules."""
+
+ name: str
+ tier: ScaledQualityScaleTiers
+ validator: RuleValidationProtocol | None = None
+
+
+ALL_RULES = [
+ # BRONZE
+ Rule("action-setup", ScaledQualityScaleTiers.BRONZE),
+ Rule("appropriate-polling", ScaledQualityScaleTiers.BRONZE),
+ Rule("brands", ScaledQualityScaleTiers.BRONZE),
+ Rule("common-modules", ScaledQualityScaleTiers.BRONZE),
+ Rule("config-flow", ScaledQualityScaleTiers.BRONZE, config_flow),
+ Rule("config-flow-test-coverage", ScaledQualityScaleTiers.BRONZE),
+ Rule("dependency-transparency", ScaledQualityScaleTiers.BRONZE),
+ Rule("docs-actions", ScaledQualityScaleTiers.BRONZE),
+ Rule("docs-high-level-description", ScaledQualityScaleTiers.BRONZE),
+ Rule("docs-installation-instructions", ScaledQualityScaleTiers.BRONZE),
+ Rule("docs-removal-instructions", ScaledQualityScaleTiers.BRONZE),
+ Rule("entity-event-setup", ScaledQualityScaleTiers.BRONZE),
+ Rule("entity-unique-id", ScaledQualityScaleTiers.BRONZE),
+ Rule("has-entity-name", ScaledQualityScaleTiers.BRONZE),
+ Rule("runtime-data", ScaledQualityScaleTiers.BRONZE, runtime_data),
+ Rule("test-before-configure", ScaledQualityScaleTiers.BRONZE),
+ Rule("test-before-setup", ScaledQualityScaleTiers.BRONZE),
+ Rule("unique-config-entry", ScaledQualityScaleTiers.BRONZE, unique_config_entry),
+ # SILVER
+ Rule("action-exceptions", ScaledQualityScaleTiers.SILVER),
+ Rule(
+ "config-entry-unloading", ScaledQualityScaleTiers.SILVER, config_entry_unloading
+ ),
+ Rule("docs-configuration-parameters", ScaledQualityScaleTiers.SILVER),
+ Rule("docs-installation-parameters", ScaledQualityScaleTiers.SILVER),
+ Rule("entity-unavailable", ScaledQualityScaleTiers.SILVER),
+ Rule("integration-owner", ScaledQualityScaleTiers.SILVER),
+ Rule("log-when-unavailable", ScaledQualityScaleTiers.SILVER),
+ Rule("parallel-updates", ScaledQualityScaleTiers.SILVER),
+ Rule(
+ "reauthentication-flow", ScaledQualityScaleTiers.SILVER, reauthentication_flow
+ ),
+ Rule("test-coverage", ScaledQualityScaleTiers.SILVER),
+ # GOLD: [
+ Rule("devices", ScaledQualityScaleTiers.GOLD),
+ Rule("diagnostics", ScaledQualityScaleTiers.GOLD, diagnostics),
+ Rule("discovery", ScaledQualityScaleTiers.GOLD, discovery),
+ Rule("discovery-update-info", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-data-update", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-examples", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-known-limitations", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-supported-devices", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-supported-functions", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-troubleshooting", ScaledQualityScaleTiers.GOLD),
+ Rule("docs-use-cases", ScaledQualityScaleTiers.GOLD),
+ Rule("dynamic-devices", ScaledQualityScaleTiers.GOLD),
+ Rule("entity-category", ScaledQualityScaleTiers.GOLD),
+ Rule("entity-device-class", ScaledQualityScaleTiers.GOLD),
+ Rule("entity-disabled-by-default", ScaledQualityScaleTiers.GOLD),
+ Rule("entity-translations", ScaledQualityScaleTiers.GOLD),
+ Rule("exception-translations", ScaledQualityScaleTiers.GOLD),
+ Rule("icon-translations", ScaledQualityScaleTiers.GOLD),
+ Rule("reconfiguration-flow", ScaledQualityScaleTiers.GOLD, reconfiguration_flow),
+ Rule("repair-issues", ScaledQualityScaleTiers.GOLD),
+ Rule("stale-devices", ScaledQualityScaleTiers.GOLD),
+ # PLATINUM
+ Rule("async-dependency", ScaledQualityScaleTiers.PLATINUM),
+ Rule("inject-websession", ScaledQualityScaleTiers.PLATINUM),
+ Rule("strict-typing", ScaledQualityScaleTiers.PLATINUM, strict_typing),
+]
+
+SCALE_RULES = {
+ tier: [rule.name for rule in ALL_RULES if rule.tier == tier]
+ for tier in ScaledQualityScaleTiers
+}
+
+VALIDATORS = {rule.name: rule.validator for rule in ALL_RULES if rule.validator}
+
+RULE_URL = (
+ "Please check the documentation at "
+ "https://developers.home-assistant.io/docs/core/"
+ "integration-quality-scale/rules/{rule_name}/"
+)
+
+INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
+ "abode",
+ "accuweather",
+ "acer_projector",
+ "acmeda",
+ "actiontec",
+ "adax",
+ "adguard",
+ "ads",
+ "advantage_air",
+ "aemet",
+ "aftership",
+ "agent_dvr",
+ "airly",
+ "airnow",
+ "airq",
+ "airthings",
+ "airthings_ble",
+ "airtouch4",
+ "airtouch5",
+ "airvisual",
+ "airvisual_pro",
+ "airzone",
+ "airzone_cloud",
+ "aladdin_connect",
+ "alarmdecoder",
+ "alert",
+ "alexa",
+ "alpha_vantage",
+ "amazon_polly",
+ "amberelectric",
+ "ambient_network",
+ "ambient_station",
+ "amcrest",
+ "ampio",
+ "analytics",
+ "analytics_insights",
+ "android_ip_webcam",
+ "androidtv",
+ "androidtv_remote",
+ "anel_pwrctrl",
+ "anova",
+ "anthemav",
+ "anthropic",
+ "aosmith",
+ "apache_kafka",
+ "apcupsd",
+ "apple_tv",
+ "apprise",
+ "aprilaire",
+ "aprs",
+ "apsystems",
+ "aquacell",
+ "aqualogic",
+ "aquostv",
+ "aranet",
+ "arcam_fmj",
+ "arest",
+ "arris_tg2492lg",
+ "aruba",
+ "arve",
+ "arwn",
+ "aseko_pool_live",
+ "assist_pipeline",
+ "asterisk_mbox",
+ "asuswrt",
+ "atag",
+ "aten_pe",
+ "atome",
+ "august",
+ "aurora",
+ "aurora_abb_powerone",
+ "aussie_broadband",
+ "avea",
+ "avion",
+ "awair",
+ "aws",
+ "axis",
+ "azure_data_explorer",
+ "azure_devops",
+ "azure_event_hub",
+ "azure_service_bus",
+ "backup",
+ "baf",
+ "baidu",
+ "balboa",
+ "bang_olufsen",
+ "bayesian",
+ "bbox",
+ "beewi_smartclim",
+ "bitcoin",
+ "bizkaibus",
+ "blackbird",
+ "blebox",
+ "blink",
+ "blinksticklight",
+ "blockchain",
+ "blue_current",
+ "bluemaestro",
+ "bluesound",
+ "bluetooth",
+ "bluetooth_adapters",
+ "bluetooth_le_tracker",
+ "bluetooth_tracker",
+ "bmw_connected_drive",
+ "bond",
+ "bosch_shc",
+ "braviatv",
+ "bring",
+ "broadlink",
+ "brother",
+ "brottsplatskartan",
+ "browser",
+ "brunt",
+ "bryant_evolution",
+ "bsblan",
+ "bt_home_hub_5",
+ "bt_smarthub",
+ "bthome",
+ "buienradar",
+ "caldav",
+ "cambridge_audio",
+ "canary",
+ "cast",
+ "ccm15",
+ "cert_expiry",
+ "chacon_dio",
+ "channels",
+ "circuit",
+ "cisco_ios",
+ "cisco_mobility_express",
+ "cisco_webex_teams",
+ "citybikes",
+ "clementine",
+ "clickatell",
+ "clicksend",
+ "clicksend_tts",
+ "climacell",
+ "cloud",
+ "cloudflare",
+ "cmus",
+ "co2signal",
+ "coinbase",
+ "color_extractor",
+ "comed_hourly_pricing",
+ "comelit",
+ "comfoconnect",
+ "command_line",
+ "compensation",
+ "concord232",
+ "control4",
+ "coolmaster",
+ "cppm_tracker",
+ "cpuspeed",
+ "crownstone",
+ "cups",
+ "currencylayer",
+ "daikin",
+ "danfoss_air",
+ "datadog",
+ "ddwrt",
+ "deako",
+ "debugpy",
+ "deconz",
+ "decora",
+ "decora_wifi",
+ "delijn",
+ "deluge",
+ "demo",
+ "denon",
+ "denonavr",
+ "derivative",
+ "devialet",
+ "device_sun_light_trigger",
+ "devolo_home_control",
+ "devolo_home_network",
+ "dexcom",
+ "dhcp",
+ "dialogflow",
+ "digital_ocean",
+ "directv",
+ "discogs",
+ "discord",
+ "dlib_face_detect",
+ "dlib_face_identify",
+ "dlink",
+ "dlna_dmr",
+ "dlna_dms",
+ "dnsip",
+ "dominos",
+ "doods",
+ "doorbird",
+ "dormakaba_dkey",
+ "dovado",
+ "downloader",
+ "dremel_3d_printer",
+ "drop_connect",
+ "dsmr",
+ "dsmr_reader",
+ "dte_energy_bridge",
+ "dublin_bus_transport",
+ "duckdns",
+ "duke_energy",
+ "dunehd",
+ "duotecno",
+ "dwd_weather_warnings",
+ "dweet",
+ "dynalite",
+ "eafm",
+ "easyenergy",
+ "ebox",
+ "ebusd",
+ "ecoal_boiler",
+ "ecobee",
+ "ecoforest",
+ "econet",
+ "ecovacs",
+ "ecowitt",
+ "eddystone_temperature",
+ "edimax",
+ "edl21",
+ "efergy",
+ "egardia",
+ "eight_sleep",
+ "electrasmart",
+ "electric_kiwi",
+ "elevenlabs",
+ "eliqonline",
+ "elkm1",
+ "elmax",
+ "elv",
+ "elvia",
+ "emby",
+ "emoncms",
+ "emoncms_history",
+ "emonitor",
+ "emulated_hue",
+ "emulated_kasa",
+ "emulated_roku",
+ "energenie_power_sockets",
+ "energy",
+ "energyzero",
+ "enigma2",
+ "enocean",
+ "enphase_envoy",
+ "entur_public_transport",
+ "environment_canada",
+ "envisalink",
+ "ephember",
+ "epic_games_store",
+ "epion",
+ "epson",
+ "eq3btsmart",
+ "escea",
+ "esphome",
+ "etherscan",
+ "eufy",
+ "eufylife_ble",
+ "everlights",
+ "evil_genius_labs",
+ "evohome",
+ "ezviz",
+ "faa_delays",
+ "facebook",
+ "fail2ban",
+ "familyhub",
+ "fastdotcom",
+ "feedreader",
+ "ffmpeg_motion",
+ "ffmpeg_noise",
+ "fibaro",
+ "fido",
+ "file",
+ "filesize",
+ "filter",
+ "fints",
+ "fireservicerota",
+ "firmata",
+ "fitbit",
+ "fivem",
+ "fixer",
+ "fjaraskupan",
+ "fleetgo",
+ "flexit",
+ "flexit_bacnet",
+ "flic",
+ "flick_electric",
+ "flipr",
+ "flo",
+ "flock",
+ "flume",
+ "flux",
+ "flux_led",
+ "folder",
+ "folder_watcher",
+ "foobot",
+ "forecast_solar",
+ "forked_daapd",
+ "fortios",
+ "foscam",
+ "foursquare",
+ "free_mobile",
+ "freebox",
+ "freedns",
+ "freedompro",
+ "fritzbox",
+ "fritzbox_callmonitor",
+ "fronius",
+ "frontier_silicon",
+ "fujitsu_fglair",
+ "fujitsu_hvac",
+ "futurenow",
+ "fyta",
+ "garadget",
+ "garages_amsterdam",
+ "gardena_bluetooth",
+ "gc100",
+ "gdacs",
+ "generic",
+ "generic_hygrostat",
+ "generic_thermostat",
+ "geniushub",
+ "geo_json_events",
+ "geo_rss_events",
+ "geocaching",
+ "geofency",
+ "geonetnz_quakes",
+ "geonetnz_volcano",
+ "gios",
+ "github",
+ "gitlab_ci",
+ "gitter",
+ "glances",
+ "go2rtc",
+ "goalzero",
+ "gogogate2",
+ "goodwe",
+ "google",
+ "google_assistant",
+ "google_assistant_sdk",
+ "google_cloud",
+ "google_domains",
+ "google_generative_ai_conversation",
+ "google_mail",
+ "google_maps",
+ "google_photos",
+ "google_pubsub",
+ "google_sheets",
+ "google_tasks",
+ "google_translate",
+ "google_travel_time",
+ "google_wifi",
+ "govee_ble",
+ "govee_light_local",
+ "gpsd",
+ "gpslogger",
+ "graphite",
+ "gree",
+ "greeneye_monitor",
+ "greenwave",
+ "group",
+ "growatt_server",
+ "gstreamer",
+ "gtfs",
+ "guardian",
+ "habitica",
+ "harman_kardon_avr",
+ "harmony",
+ "hassio",
+ "haveibeenpwned",
+ "hddtemp",
+ "hdmi_cec",
+ "heatmiser",
+ "heos",
+ "here_travel_time",
+ "hikvision",
+ "hikvisioncam",
+ "hisense_aehw4a1",
+ "history_stats",
+ "hitron_coda",
+ "hive",
+ "hko",
+ "hlk_sw16",
+ "holiday",
+ "home_connect",
+ "homekit",
+ "homekit_controller",
+ "homematic",
+ "homematicip_cloud",
+ "homeworks",
+ "honeywell",
+ "horizon",
+ "hp_ilo",
+ "html5",
+ "http",
+ "huawei_lte",
+ "hue",
+ "huisbaasje",
+ "hunterdouglas_powerview",
+ "husqvarna_automower",
+ "husqvarna_automower_ble",
+ "huum",
+ "hvv_departures",
+ "hydrawise",
+ "hyperion",
+ "ialarm",
+ "iammeter",
+ "iaqualink",
+ "ibeacon",
+ "icloud",
+ "idasen_desk",
+ "idteck_prox",
+ "ifttt",
+ "iglo",
+ "ign_sismologia",
+ "ihc",
+ "imgw_pib",
+ "improv_ble",
+ "incomfort",
+ "influxdb",
+ "inkbird",
+ "insteon",
+ "integration",
+ "intellifire",
+ "intesishome",
+ "ios",
+ "iotawatt",
+ "iotty",
+ "iperf3",
+ "ipma",
+ "ipp",
+ "iqvia",
+ "irish_rail_transport",
+ "iron_os",
+ "isal",
+ "iskra",
+ "islamic_prayer_times",
+ "israel_rail",
+ "iss",
+ "ista_ecotrend",
+ "isy994",
+ "itach",
+ "itunes",
+ "izone",
+ "jellyfin",
+ "jewish_calendar",
+ "joaoapps_join",
+ "juicenet",
+ "justnimbus",
+ "jvc_projector",
+ "kaiterra",
+ "kaleidescape",
+ "kankun",
+ "keba",
+ "keenetic_ndms2",
+ "kef",
+ "kegtron",
+ "keyboard",
+ "keyboard_remote",
+ "keymitt_ble",
+ "kira",
+ "kitchen_sink",
+ "kiwi",
+ "kmtronic",
+ "knocki",
+ "knx",
+ "kodi",
+ "konnected",
+ "kostal_plenticore",
+ "kraken",
+ "kulersky",
+ "kwb",
+ "lacrosse",
+ "lacrosse_view",
+ "lametric",
+ "landisgyr_heat_meter",
+ "lannouncer",
+ "lastfm",
+ "launch_library",
+ "laundrify",
+ "lcn",
+ "ld2410_ble",
+ "leaone",
+ "led_ble",
+ "lektrico",
+ "lg_netcast",
+ "lg_soundbar",
+ "lg_thinq",
+ "lidarr",
+ "life360",
+ "lifx",
+ "lifx_cloud",
+ "lightwave",
+ "limitlessled",
+ "linear_garage_door",
+ "linkplay",
+ "linksys_smart",
+ "linode",
+ "linux_battery",
+ "lirc",
+ "litejet",
+ "litterrobot",
+ "livisi",
+ "llamalab_automate",
+ "local_calendar",
+ "local_file",
+ "local_ip",
+ "local_todo",
+ "location",
+ "locative",
+ "logentries",
+ "logi_circle",
+ "london_air",
+ "london_underground",
+ "lookin",
+ "loqed",
+ "luci",
+ "luftdaten",
+ "lupusec",
+ "lutron",
+ "lutron_caseta",
+ "lw12wifi",
+ "lyric",
+ "madvr",
+ "mailbox",
+ "mailgun",
+ "manual",
+ "manual_mqtt",
+ "map",
+ "marytts",
+ "mastodon",
+ "matrix",
+ "matter",
+ "maxcube",
+ "mazda",
+ "mealie",
+ "meater",
+ "medcom_ble",
+ "media_extractor",
+ "mediaroom",
+ "melcloud",
+ "melissa",
+ "melnor",
+ "meraki",
+ "message_bird",
+ "met",
+ "met_eireann",
+ "meteo_france",
+ "meteoalarm",
+ "meteoclimatic",
+ "metoffice",
+ "mfi",
+ "microbees",
+ "microsoft",
+ "microsoft_face",
+ "microsoft_face_detect",
+ "microsoft_face_identify",
+ "mikrotik",
+ "mill",
+ "min_max",
+ "minecraft_server",
+ "minio",
+ "mjpeg",
+ "moat",
+ "mobile_app",
+ "mochad",
+ "modbus",
+ "modem_callerid",
+ "modern_forms",
+ "moehlenhoff_alpha2",
+ "mold_indicator",
+ "monarch_money",
+ "monoprice",
+ "monzo",
+ "moon",
+ "mopeka",
+ "motion_blinds",
+ "motionblinds_ble",
+ "motioneye",
+ "motionmount",
+ "mpd",
+ "mqtt_eventstream",
+ "mqtt_json",
+ "mqtt_room",
+ "mqtt_statestream",
+ "msteams",
+ "mullvad",
+ "music_assistant",
+ "mutesync",
+ "mvglive",
+ "mycroft",
+ "myq",
+ "mysensors",
+ "mystrom",
+ "mythicbeastsdns",
+ "myuplink",
+ "nad",
+ "nam",
+ "namecheapdns",
+ "nanoleaf",
+ "nasweb",
+ "neato",
+ "nederlandse_spoorwegen",
+ "ness_alarm",
+ "nest",
+ "netatmo",
+ "netdata",
+ "netgear",
+ "netgear_lte",
+ "netio",
+ "network",
+ "neurio_energy",
+ "nexia",
+ "nextbus",
+ "nextcloud",
+ "nextdns",
+ "nfandroidtv",
+ "nibe_heatpump",
+ "nice_go",
+ "nightscout",
+ "niko_home_control",
+ "nilu",
+ "nina",
+ "nissan_leaf",
+ "nmap_tracker",
+ "nmbs",
+ "no_ip",
+ "noaa_tides",
+ "nobo_hub",
+ "nordpool",
+ "norway_air",
+ "notify_events",
+ "notion",
+ "nsw_fuel_station",
+ "nsw_rural_fire_service_feed",
+ "nuheat",
+ "nuki",
+ "numato",
+ "nut",
+ "nws",
+ "nx584",
+ "nyt_games",
+ "nzbget",
+ "oasa_telematics",
+ "obihai",
+ "octoprint",
+ "oem",
+ "ohmconnect",
+ "ollama",
+ "ombi",
+ "omnilogic",
+ "oncue",
+ "ondilo_ico",
+ "onewire",
+ "onkyo",
+ "onvif",
+ "open_meteo",
+ "openai_conversation",
+ "openalpr_cloud",
+ "openerz",
+ "openevse",
+ "openexchangerates",
+ "opengarage",
+ "openhardwaremonitor",
+ "openhome",
+ "opensensemap",
+ "opensky",
+ "opentherm_gw",
+ "openuv",
+ "openweathermap",
+ "opnsense",
+ "opower",
+ "opple",
+ "oralb",
+ "oru",
+ "orvibo",
+ "osoenergy",
+ "osramlightify",
+ "otbr",
+ "otp",
+ "ourgroceries",
+ "overkiz",
+ "ovo_energy",
+ "owntracks",
+ "p1_monitor",
+ "panasonic_bluray",
+ "panasonic_viera",
+ "pandora",
+ "panel_iframe",
+ "peco",
+ "pegel_online",
+ "pencom",
+ "permobil",
+ "persistent_notification",
+ "person",
+ "philips_js",
+ "pi_hole",
+ "picnic",
+ "picotts",
+ "pilight",
+ "ping",
+ "pioneer",
+ "pjlink",
+ "plaato",
+ "plant",
+ "plex",
+ "plum_lightpad",
+ "pocketcasts",
+ "point",
+ "poolsense",
+ "powerwall",
+ "private_ble_device",
+ "profiler",
+ "progettihwsw",
+ "proliphix",
+ "prometheus",
+ "prosegur",
+ "prowl",
+ "proximity",
+ "proxmoxve",
+ "prusalink",
+ "ps4",
+ "pulseaudio_loopback",
+ "pure_energie",
+ "purpleair",
+ "push",
+ "pushbullet",
+ "pushover",
+ "pushsafer",
+ "pvoutput",
+ "pvpc_hourly_pricing",
+ "pyload",
+ "qbittorrent",
+ "qingping",
+ "qld_bushfire",
+ "qnap",
+ "qnap_qsw",
+ "qrcode",
+ "quantum_gateway",
+ "qvr_pro",
+ "qwikswitch",
+ "rabbitair",
+ "rachio",
+ "radarr",
+ "radio_browser",
+ "radiotherm",
+ "raincloud",
+ "rainforest_eagle",
+ "rainforest_raven",
+ "rainmachine",
+ "random",
+ "rapt_ble",
+ "raspyrfm",
+ "rdw",
+ "recollect_waste",
+ "recorder",
+ "recswitch",
+ "reddit",
+ "refoss",
+ "rejseplanen",
+ "remember_the_milk",
+ "remote_rpi_gpio",
+ "renson",
+ "repetier",
+ "rest",
+ "rest_command",
+ "rflink",
+ "rfxtrx",
+ "rhasspy",
+ "ridwell",
+ "ring",
+ "ripple",
+ "risco",
+ "rituals_perfume_genie",
+ "rmvtransport",
+ "roborock",
+ "rocketchat",
+ "roku",
+ "romy",
+ "roomba",
+ "roon",
+ "route53",
+ "rova",
+ "rpi_camera",
+ "rpi_power",
+ "rss_feed_template",
+ "rtorrent",
+ "rtsp_to_webrtc",
+ "ruckus_unleashed",
+ "russound_rio",
+ "russound_rnet",
+ "ruuvi_gateway",
+ "ruuvitag_ble",
+ "rympro",
+ "sabnzbd",
+ "saj",
+ "samsungtv",
+ "sanix",
+ "satel_integra",
+ "schlage",
+ "schluter",
+ "scrape",
+ "screenlogic",
+ "scsgate",
+ "season",
+ "sendgrid",
+ "sense",
+ "sensibo",
+ "sensirion_ble",
+ "sensorpro",
+ "sensorpush",
+ "sensoterra",
+ "sentry",
+ "senz",
+ "serial",
+ "serial_pm",
+ "sesame",
+ "seven_segments",
+ "seventeentrack",
+ "sfr_box",
+ "sharkiq",
+ "shell_command",
+ "shelly",
+ "shodan",
+ "shopping_list",
+ "sia",
+ "sigfox",
+ "sighthound",
+ "signal_messenger",
+ "simplefin",
+ "simplepush",
+ "simplisafe",
+ "simulated",
+ "sinch",
+ "sisyphus",
+ "sky_hub",
+ "sky_remote",
+ "skybeacon",
+ "skybell",
+ "slack",
+ "sleepiq",
+ "slide",
+ "slimproto",
+ "sma",
+ "smappee",
+ "smart_meter_texas",
+ "smartthings",
+ "smarttub",
+ "smarty",
+ "smhi",
+ "smlight",
+ "sms",
+ "smtp",
+ "snapcast",
+ "snips",
+ "snmp",
+ "snooz",
+ "solaredge",
+ "solaredge_local",
+ "solax",
+ "soma",
+ "somfy_mylink",
+ "sonarr",
+ "songpal",
+ "sonos",
+ "sony_projector",
+ "soundtouch",
+ "spaceapi",
+ "spc",
+ "speedtestdotnet",
+ "spider",
+ "splunk",
+ "spotify",
+ "sql",
+ "squeezebox",
+ "srp_energy",
+ "ssdp",
+ "starline",
+ "starlingbank",
+ "starlink",
+ "startca",
+ "statistics",
+ "statsd",
+ "steam_online",
+ "steamist",
+ "stiebel_eltron",
+ "stookalert",
+ "stream",
+ "streamlabswater",
+ "subaru",
+ "suez_water",
+ "sun",
+ "sunweg",
+ "supervisord",
+ "supla",
+ "surepetcare",
+ "swiss_hydrological_data",
+ "swiss_public_transport",
+ "swisscom",
+ "switch_as_x",
+ "switchbee",
+ "switchbot",
+ "switchbot_cloud",
+ "switcher_kis",
+ "switchmate",
+ "syncthing",
+ "syncthru",
+ "synology_chat",
+ "synology_dsm",
+ "synology_srm",
+ "syslog",
+ "system_bridge",
+ "systemmonitor",
+ "tado",
+ "tailscale",
+ "tailwind",
+ "tami4",
+ "tank_utility",
+ "tankerkoenig",
+ "tapsaff",
+ "tasmota",
+ "tautulli",
+ "tcp",
+ "technove",
+ "ted5000",
+ "telegram",
+ "telegram_bot",
+ "tellduslive",
+ "tellstick",
+ "telnet",
+ "temper",
+ "template",
+ "tensorflow",
+ "tesla_fleet",
+ "tesla_wall_connector",
+ "teslemetry",
+ "tessie",
+ "tfiac",
+ "thermobeacon",
+ "thermopro",
+ "thermoworks_smoke",
+ "thethingsnetwork",
+ "thingspeak",
+ "thinkingcleaner",
+ "thomson",
+ "thread",
+ "threshold",
+ "tibber",
+ "tikteck",
+ "tile",
+ "tilt_ble",
+ "time_date",
+ "tmb",
+ "tod",
+ "todoist",
+ "tolo",
+ "tomato",
+ "tomorrowio",
+ "toon",
+ "torque",
+ "totalconnect",
+ "touchline",
+ "touchline_sl",
+ "tplink",
+ "tplink_lte",
+ "tplink_omada",
+ "traccar",
+ "traccar_server",
+ "tractive",
+ "tradfri",
+ "trafikverket_camera",
+ "trafikverket_ferry",
+ "trafikverket_train",
+ "trafikverket_weatherstation",
+ "transmission",
+ "transport_nsw",
+ "travisci",
+ "trend",
+ "triggercmd",
+ "tuya",
+ "twilio",
+ "twilio_call",
+ "twilio_sms",
+ "twinkly",
+ "twitch",
+ "twitter",
+ "ubus",
+ "uk_transport",
+ "ukraine_alarm",
+ "unifi",
+ "unifi_direct",
+ "unifiled",
+ "unifiprotect",
+ "universal",
+ "upb",
+ "upc_connect",
+ "upcloud",
+ "upnp",
+ "uptime",
+ "uptimerobot",
+ "usb",
+ "usgs_earthquakes_feed",
+ "utility_meter",
+ "uvc",
+ "v2c",
+ "vallox",
+ "vasttrafik",
+ "velbus",
+ "velux",
+ "venstar",
+ "vera",
+ "verisure",
+ "versasense",
+ "version",
+ "vesync",
+ "viaggiatreno",
+ "vilfo",
+ "vivotek",
+ "vizio",
+ "vlc",
+ "vlc_telnet",
+ "vodafone_station",
+ "voicerss",
+ "voip",
+ "volkszaehler",
+ "volumio",
+ "volvooncall",
+ "vulcan",
+ "vultr",
+ "w800rf32",
+ "wake_on_lan",
+ "wallbox",
+ "waqi",
+ "waterfurnace",
+ "watson_iot",
+ "watson_tts",
+ "watttime",
+ "waze_travel_time",
+ "weatherflow",
+ "weatherflow_cloud",
+ "weatherkit",
+ "webmin",
+ "webostv",
+ "weheat",
+ "wemo",
+ "whirlpool",
+ "whois",
+ "wiffi",
+ "wilight",
+ "wirelesstag",
+ "withings",
+ "wiz",
+ "wled",
+ "wmspro",
+ "wolflink",
+ "workday",
+ "worldclock",
+ "worldtidesinfo",
+ "worxlandroid",
+ "ws66i",
+ "wsdot",
+ "wyoming",
+ "x10",
+ "xbox",
+ "xeoma",
+ "xiaomi",
+ "xiaomi_aqara",
+ "xiaomi_ble",
+ "xiaomi_miio",
+ "xiaomi_tv",
+ "xmpp",
+ "xs1",
+ "yale",
+ "yale_smart_alarm",
+ "yalexs_ble",
+ "yamaha",
+ "yamaha_musiccast",
+ "yandex_transport",
+ "yandextts",
+ "yardian",
+ "yeelight",
+ "yeelightsunflower",
+ "yi",
+ "yolink",
+ "youless",
+ "youtube",
+ "zabbix",
+ "zamg",
+ "zengge",
+ "zeroconf",
+ "zerproc",
+ "zestimate",
+ "zeversolar",
+ "zha",
+ "zhong_hong",
+ "ziggo_mediabox_xl",
+ "zodiac",
+ "zoneminder",
+ "zwave_js",
+ "zwave_me",
+]
+
+NO_QUALITY_SCALE = [
+ *{platform.value for platform in Platform},
+ "api",
+ "application_credentials",
+ "auth",
+ "automation",
+ "blueprint",
+ "config",
+ "configurator",
+ "counter",
+ "default_config",
+ "device_automation",
+ "device_tracker",
+ "diagnostics",
+ "ffmpeg",
+ "file_upload",
+ "frontend",
+ "hardkernel",
+ "hardware",
+ "history",
+ "homeassistant",
+ "homeassistant_alerts",
+ "homeassistant_green",
+ "homeassistant_hardware",
+ "homeassistant_sky_connect",
+ "homeassistant_yellow",
+ "image_upload",
+ "input_boolean",
+ "input_button",
+ "input_datetime",
+ "input_number",
+ "input_select",
+ "input_text",
+ "intent_script",
+ "intent",
+ "logbook",
+ "logger",
+ "lovelace",
+ "media_source",
+ "my",
+ "onboarding",
+ "panel_custom",
+ "proxy",
+ "python_script",
+ "raspberry_pi",
+ "recovery_mode",
+ "repairs",
+ "schedule",
+ "script",
+ "search",
+ "system_health",
+ "system_log",
+ "tag",
+ "timer",
+ "trace",
+ "webhook",
+ "websocket_api",
+ "zone",
+]
+
+SCHEMA = vol.Schema(
+ {
+ vol.Required("rules"): vol.Schema(
+ {
+ vol.Optional(rule.name): vol.Any(
+ vol.In(["todo", "done"]),
+ vol.Schema(
+ {
+ vol.Required("status"): vol.In(["todo", "done"]),
+ vol.Optional("comment"): str,
+ }
+ ),
+ vol.Schema(
+ {
+ vol.Required("status"): "exempt",
+ vol.Required("comment"): str,
+ }
+ ),
+ )
+ for rule in ALL_RULES
+ }
+ )
+ }
+)
+
+
+def validate_iqs_file(config: Config, integration: Integration) -> None:
+ """Validate quality scale file for integration."""
+ if not integration.core:
+ return
+
+ declared_quality_scale = QUALITY_SCALE_TIERS.get(integration.quality_scale)
+
+ iqs_file = integration.path / "quality_scale.yaml"
+ has_file = iqs_file.is_file()
+ if not has_file:
+ if (
+ integration.domain not in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE
+ and integration.domain not in NO_QUALITY_SCALE
+ and integration.integration_type != "virtual"
+ ):
+ integration.add_error(
+ "quality_scale",
+ "Quality scale definition not found. New integrations are required to at least reach the Bronze tier.",
+ )
+ return
+ if declared_quality_scale is not None:
+ integration.add_error(
+ "quality_scale",
+ "Quality scale definition not found. Integrations that set a manifest quality scale must have a quality scale definition.",
+ )
+ return
+ return
+ if integration.integration_type == "virtual":
+ integration.add_error(
+ "quality_scale",
+ "Virtual integrations are not allowed to have a quality scale file.",
+ )
+ return
+ if integration.domain in NO_QUALITY_SCALE:
+ integration.add_error(
+ "quality_scale",
+ "This integration is not supposed to have a quality scale file.",
+ )
+ return
+ if integration.domain in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE:
+ integration.add_error(
+ "quality_scale",
+ "Quality scale file found! Please remove from script/hassfest/quality_scale.py",
+ )
+ return
+ name = str(iqs_file)
+
+ try:
+ data = load_yaml_dict(name)
+ except HomeAssistantError:
+ integration.add_error("quality_scale", "Invalid quality_scale.yaml")
+ return
+
+ try:
+ SCHEMA(data)
+ except vol.Invalid as err:
+ integration.add_error(
+ "quality_scale", f"Invalid {name}: {humanize_error(data, err)}"
+ )
+
+ rules_met = set[str]()
+ for rule_name, rule_value in data.get("rules", {}).items():
+ status = rule_value["status"] if isinstance(rule_value, dict) else rule_value
+ if status not in {"done", "exempt"}:
+ continue
+ rules_met.add(rule_name)
+ if (
+ status == "done"
+ and (validator := VALIDATORS.get(rule_name))
+ and (errors := validator.validate(integration))
+ ):
+ for error in errors:
+ integration.add_error("quality_scale", f"[{rule_name}] {error}")
+ integration.add_error("quality_scale", RULE_URL.format(rule_name=rule_name))
+
+ # An integration must have all the necessary rules for the declared
+ # quality scale, and all the rules below.
+ if declared_quality_scale is None:
+ return
+
+ for scale in ScaledQualityScaleTiers:
+ if scale > declared_quality_scale:
+ break
+ required_rules = set(SCALE_RULES[scale])
+ if missing_rules := (required_rules - rules_met):
+ friendly_rule_str = "\n".join(
+ f" {rule}: todo" for rule in sorted(missing_rules)
+ )
+ integration.add_error(
+ "quality_scale",
+ f"Quality scale tier {scale.name.lower()} requires quality scale rules to be met:\n{friendly_rule_str}",
+ )
+
+
+def validate(integrations: dict[str, Integration], config: Config) -> None:
+ """Handle YAML files inside integrations."""
+ for integration in integrations.values():
+ validate_iqs_file(config, integration)
diff --git a/script/hassfest/quality_scale_validation/__init__.py b/script/hassfest/quality_scale_validation/__init__.py
new file mode 100644
index 00000000000..836c1082763
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/__init__.py
@@ -0,0 +1,15 @@
+"""Integration quality scale rules."""
+
+from typing import Protocol
+
+from script.hassfest.model import Integration
+
+
+class RuleValidationProtocol(Protocol):
+ """Protocol for rule validation."""
+
+ def validate(self, integration: Integration) -> list[str] | None:
+ """Validate a quality scale rule.
+
+ Returns error (if any).
+ """
diff --git a/script/hassfest/quality_scale_validation/config_entry_unloading.py b/script/hassfest/quality_scale_validation/config_entry_unloading.py
new file mode 100644
index 00000000000..50f42752bf6
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/config_entry_unloading.py
@@ -0,0 +1,30 @@
+"""Enforce that the integration implements entry unloading.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-entry-unloading/
+"""
+
+import ast
+
+from script.hassfest.model import Integration
+
+
+def _has_unload_entry_function(module: ast.Module) -> bool:
+ """Test if the module defines `async_unload_entry` function."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name == "async_unload_entry"
+ for item in module.body
+ )
+
+
+def validate(integration: Integration) -> list[str] | None:
+ """Validate that the integration has a config flow."""
+
+ init_file = integration.path / "__init__.py"
+ init = ast.parse(init_file.read_text())
+
+ if not _has_unload_entry_function(init):
+ return [
+ "Integration does not support config entry unloading "
+ "(is missing `async_unload_entry` in __init__.py)"
+ ]
+ return None
diff --git a/script/hassfest/quality_scale_validation/config_flow.py b/script/hassfest/quality_scale_validation/config_flow.py
new file mode 100644
index 00000000000..e1361d6550f
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/config_flow.py
@@ -0,0 +1,24 @@
+"""Enforce that the integration implements config flow.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow/
+"""
+
+from script.hassfest.model import Integration
+
+
+def validate(integration: Integration) -> list[str] | None:
+ """Validate that the integration implements config flow."""
+
+ if not integration.config_flow:
+ return [
+ "Integration does not set config_flow in its manifest "
+ f"homeassistant/components/{integration.domain}/manifest.json",
+ ]
+
+ config_flow_file = integration.path / "config_flow.py"
+ if not config_flow_file.exists():
+ return [
+ "Integration does not implement config flow (is missing config_flow.py)",
+ ]
+
+ return None
diff --git a/script/hassfest/quality_scale_validation/diagnostics.py b/script/hassfest/quality_scale_validation/diagnostics.py
new file mode 100644
index 00000000000..99f067d6500
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/diagnostics.py
@@ -0,0 +1,42 @@
+"""Enforce that the integration implements diagnostics.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/diagnostics/
+"""
+
+import ast
+
+from script.hassfest.model import Integration
+
+DIAGNOSTICS_FUNCTIONS = {
+ "async_get_config_entry_diagnostics",
+ "async_get_device_diagnostics",
+}
+
+
+def _has_diagnostics_function(module: ast.Module) -> bool:
+ """Test if the module defines at least one of diagnostic functions."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name in DIAGNOSTICS_FUNCTIONS
+ for item in ast.walk(module)
+ )
+
+
+def validate(integration: Integration) -> list[str] | None:
+ """Validate that the integration implements diagnostics."""
+
+ diagnostics_file = integration.path / "diagnostics.py"
+ if not diagnostics_file.exists():
+ return [
+ "Integration does implement diagnostics platform "
+ "(is missing diagnostics.py)",
+ ]
+
+ diagnostics = ast.parse(diagnostics_file.read_text())
+
+ if not _has_diagnostics_function(diagnostics):
+ return [
+ f"Integration is missing one of {DIAGNOSTICS_FUNCTIONS} "
+ f"in {diagnostics_file}"
+ ]
+
+ return None
diff --git a/script/hassfest/quality_scale_validation/discovery.py b/script/hassfest/quality_scale_validation/discovery.py
new file mode 100644
index 00000000000..a4f01ce0269
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/discovery.py
@@ -0,0 +1,46 @@
+"""Enforce that the integration supports discovery.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/discovery/
+"""
+
+import ast
+
+from script.hassfest.model import Integration
+
+DISCOVERY_FUNCTIONS = [
+ "async_step_discovery",
+ "async_step_bluetooth",
+ "async_step_hassio",
+ "async_step_homekit",
+ "async_step_mqtt",
+ "async_step_ssdp",
+ "async_step_zeroconf",
+ "async_step_dhcp",
+ "async_step_usb",
+]
+
+
+def _has_discovery_function(module: ast.Module) -> bool:
+ """Test if the module defines at least one of the discovery functions."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name in DISCOVERY_FUNCTIONS
+ for item in ast.walk(module)
+ )
+
+
+def validate(integration: Integration) -> list[str] | None:
+ """Validate that the integration implements diagnostics."""
+
+ config_flow_file = integration.path / "config_flow.py"
+ if not config_flow_file.exists():
+ return ["Integration is missing config_flow.py"]
+
+ config_flow = ast.parse(config_flow_file.read_text())
+
+ if not _has_discovery_function(config_flow):
+ return [
+ f"Integration is missing one of {DISCOVERY_FUNCTIONS} "
+ f"in {config_flow_file}"
+ ]
+
+ return None
diff --git a/script/hassfest/quality_scale_validation/reauthentication_flow.py b/script/hassfest/quality_scale_validation/reauthentication_flow.py
new file mode 100644
index 00000000000..311f8a2429d
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/reauthentication_flow.py
@@ -0,0 +1,30 @@
+"""Enforce that the integration implements reauthentication flow.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reauthentication-flow/
+"""
+
+import ast
+
+from script.hassfest.model import Integration
+
+
+def _has_step_reauth_function(module: ast.Module) -> bool:
+ """Test if the module defines `async_step_reauth` function."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name == "async_step_reauth"
+ for item in ast.walk(module)
+ )
+
+
+def validate(integration: Integration) -> list[str] | None:
+ """Validate that the integration has a reauthentication flow."""
+
+ config_flow_file = integration.path / "config_flow.py"
+ config_flow = ast.parse(config_flow_file.read_text())
+
+ if not _has_step_reauth_function(config_flow):
+ return [
+ "Integration does not support a reauthentication flow "
+ f"(is missing `async_step_reauth` in {config_flow_file})"
+ ]
+ return None
diff --git a/script/hassfest/quality_scale_validation/reconfiguration_flow.py b/script/hassfest/quality_scale_validation/reconfiguration_flow.py
new file mode 100644
index 00000000000..de3b5dcba62
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/reconfiguration_flow.py
@@ -0,0 +1,30 @@
+"""Enforce that the integration implements reconfiguration flow.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow/
+"""
+
+import ast
+
+from script.hassfest.model import Integration
+
+
+def _has_step_reconfigure_function(module: ast.Module) -> bool:
+ """Test if the module defines a function."""
+ return any(
+ type(item) is ast.AsyncFunctionDef and item.name == "async_step_reconfigure"
+ for item in ast.walk(module)
+ )
+
+
+def validate(integration: Integration) -> list[str] | None:
+ """Validate that the integration has a reconfiguration flow."""
+
+ config_flow_file = integration.path / "config_flow.py"
+ config_flow = ast.parse(config_flow_file.read_text())
+
+ if not _has_step_reconfigure_function(config_flow):
+ return [
+ "Integration does not support a reconfiguration flow "
+ f"(is missing `async_step_reconfigure` in {config_flow_file})"
+ ]
+ return None
diff --git a/script/hassfest/quality_scale_validation/runtime_data.py b/script/hassfest/quality_scale_validation/runtime_data.py
new file mode 100644
index 00000000000..765db43d1e3
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/runtime_data.py
@@ -0,0 +1,53 @@
+"""Enforce that the integration uses ConfigEntry.runtime_data to store runtime data.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/runtime-data
+"""
+
+import ast
+
+from script.hassfest.model import Integration
+
+
+def _sets_runtime_data(
+ async_setup_entry_function: ast.AsyncFunctionDef, config_entry_argument: ast.arg
+) -> bool:
+ """Check that `entry.runtime` gets set within `async_setup_entry`."""
+ for node in ast.walk(async_setup_entry_function):
+ if (
+ isinstance(node, ast.Attribute)
+ and isinstance(node.value, ast.Name)
+ and node.value.id == config_entry_argument.arg
+ and node.attr == "runtime_data"
+ and isinstance(node.ctx, ast.Store)
+ ):
+ return True
+ return False
+
+
+def _get_setup_entry_function(module: ast.Module) -> ast.AsyncFunctionDef | None:
+ """Get async_setup_entry function."""
+ for item in module.body:
+ if isinstance(item, ast.AsyncFunctionDef) and item.name == "async_setup_entry":
+ return item
+ return None
+
+
+def validate(integration: Integration) -> list[str] | None:
+ """Validate correct use of ConfigEntry.runtime_data."""
+ init_file = integration.path / "__init__.py"
+ init = ast.parse(init_file.read_text())
+
+ # Should not happen, but better to be safe
+ if not (async_setup_entry := _get_setup_entry_function(init)):
+ return [f"Could not find `async_setup_entry` in {init_file}"]
+ if len(async_setup_entry.args.args) != 2:
+ return [f"async_setup_entry has incorrect signature in {init_file}"]
+ config_entry_argument = async_setup_entry.args.args[1]
+
+ if not _sets_runtime_data(async_setup_entry, config_entry_argument):
+ return [
+ "Integration does not set entry.runtime_data in async_setup_entry"
+ f"({init_file})"
+ ]
+
+ return None
diff --git a/script/hassfest/quality_scale_validation/strict_typing.py b/script/hassfest/quality_scale_validation/strict_typing.py
new file mode 100644
index 00000000000..285746a9eb6
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/strict_typing.py
@@ -0,0 +1,35 @@
+"""Enforce that the integration has strict typing enabled.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/strict-typing/
+"""
+
+from functools import lru_cache
+from pathlib import Path
+import re
+
+from script.hassfest.model import Integration
+
+_STRICT_TYPING_FILE = Path(".strict-typing")
+_COMPONENT_REGEX = r"homeassistant.components.([^.]+).*"
+
+
+@lru_cache
+def _strict_typing_components() -> set[str]:
+ return set(
+ {
+ match.group(1)
+ for line in _STRICT_TYPING_FILE.read_text(encoding="utf-8").splitlines()
+ if (match := re.match(_COMPONENT_REGEX, line)) is not None
+ }
+ )
+
+
+def validate(integration: Integration) -> list[str] | None:
+ """Validate that the integration has strict typing enabled."""
+
+ if integration.domain not in _strict_typing_components():
+ return [
+ "Integration does not have strict typing enabled "
+ "(is missing from .strict-typing)"
+ ]
+ return None
diff --git a/script/hassfest/quality_scale_validation/unique_config_entry.py b/script/hassfest/quality_scale_validation/unique_config_entry.py
new file mode 100644
index 00000000000..eaa879bb05e
--- /dev/null
+++ b/script/hassfest/quality_scale_validation/unique_config_entry.py
@@ -0,0 +1,49 @@
+"""Enforce that the integration prevents duplicates from being configured.
+
+https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/unique-config-entry/
+"""
+
+import ast
+
+from script.hassfest.model import Integration
+
+
+def _has_method_call(module: ast.Module, name: str) -> bool:
+ """Test if the module calls a specific method."""
+ return any(
+ type(item.func) is ast.Attribute and item.func.attr == name
+ for item in ast.walk(module)
+ if isinstance(item, ast.Call)
+ )
+
+
+def _has_abort_entries_match(module: ast.Module) -> bool:
+ """Test if the module calls `_async_abort_entries_match`."""
+ return _has_method_call(module, "_async_abort_entries_match")
+
+
+def _has_abort_unique_id_configured(module: ast.Module) -> bool:
+ """Test if the module calls defines (and checks for) a unique_id."""
+ return _has_method_call(module, "async_set_unique_id") and _has_method_call(
+ module, "_abort_if_unique_id_configured"
+ )
+
+
+def validate(integration: Integration) -> list[str] | None:
+ """Validate that the integration prevents duplicate devices."""
+
+ if integration.manifest.get("single_config_entry"):
+ return None
+
+ config_flow_file = integration.path / "config_flow.py"
+ config_flow = ast.parse(config_flow_file.read_text())
+
+ if not (
+ _has_abort_entries_match(config_flow)
+ or _has_abort_unique_id_configured(config_flow)
+ ):
+ return [
+ "Integration doesn't prevent the same device or service from being "
+ f"set up twice in {config_flow_file}"
+ ]
+ return None
diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py
index 2c3b9b4d99b..2fb70b6e0be 100644
--- a/script/hassfest/translations.py
+++ b/script/hassfest/translations.py
@@ -172,6 +172,9 @@ def gen_data_entry_schema(
vol.Optional("sections"): {
str: {
vol.Optional("data"): {str: translation_value_validator},
+ vol.Optional("data_description"): {
+ str: translation_value_validator
+ },
vol.Optional("description"): translation_value_validator,
vol.Optional("name"): translation_value_validator,
},
@@ -368,6 +371,9 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
},
slug_validator=translation_key_validator,
),
+ vol.Optional(
+ "unit_of_measurement"
+ ): translation_value_validator,
},
slug_validator=translation_key_validator,
),
diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py
index 48fcc0a4589..fe3e5bb3875 100644
--- a/script/hassfest/zeroconf.py
+++ b/script/hassfest/zeroconf.py
@@ -55,19 +55,19 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str:
# HomeKit models are matched on starting string, make sure none overlap.
warned = set()
- for key in homekit_dict:
+ for key, value in homekit_dict.items():
if key in warned:
continue
# n^2 yoooo
- for key_2 in homekit_dict:
+ for key_2, value_2 in homekit_dict.items():
if key == key_2 or key_2 in warned:
continue
if key.startswith(key_2) or key_2.startswith(key):
integration.add_error(
"zeroconf",
- f"Integrations {homekit_dict[key]} and {homekit_dict[key_2]} "
+ f"Integrations {value} and {value_2} "
"have overlapping HomeKit models",
)
warned.add(key)
diff --git a/script/json_schemas/manifest_schema.json b/script/json_schemas/manifest_schema.json
index 40f08fd2c85..7349f12b55a 100644
--- a/script/json_schemas/manifest_schema.json
+++ b/script/json_schemas/manifest_schema.json
@@ -308,7 +308,7 @@
"quality_scale": {
"description": "The quality scale of the integration.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#integration-quality-scale",
"type": "string",
- "enum": ["internal", "silver", "gold", "platinum"]
+ "enum": ["bronze", "silver", "gold", "platinum", "internal", "legacy"]
},
"requirements": {
"description": "The PyPI package requirements for the integration. The package has to be pinned to a specific version.\nhttps://developers.home-assistant.io/docs/creating_integration_manifest/#requirements",
diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py
index 8cc4cee3b10..f92f90115ce 100644
--- a/script/translations/deduplicate.py
+++ b/script/translations/deduplicate.py
@@ -7,8 +7,7 @@ from pathlib import Path
from homeassistant.const import Platform
from . import upload
-from .develop import flatten_translations
-from .util import get_base_arg_parser, load_json_from_path
+from .util import flatten_translations, get_base_arg_parser, load_json_from_path
def get_arguments() -> argparse.Namespace:
diff --git a/script/translations/develop.py b/script/translations/develop.py
index 00465e1bc24..9e3a2ded046 100644
--- a/script/translations/develop.py
+++ b/script/translations/develop.py
@@ -9,7 +9,7 @@ import sys
from . import download, upload
from .const import INTEGRATIONS_DIR
-from .util import get_base_arg_parser
+from .util import flatten_translations, get_base_arg_parser
def valid_integration(integration):
@@ -32,29 +32,6 @@ def get_arguments() -> argparse.Namespace:
return parser.parse_args()
-def flatten_translations(translations):
- """Flatten all translations."""
- stack = [iter(translations.items())]
- key_stack = []
- flattened_translations = {}
- while stack:
- for k, v in stack[-1]:
- key_stack.append(k)
- if isinstance(v, dict):
- stack.append(iter(v.items()))
- break
- if isinstance(v, str):
- common_key = "::".join(key_stack)
- flattened_translations[common_key] = v
- key_stack.pop()
- else:
- stack.pop()
- if key_stack:
- key_stack.pop()
-
- return flattened_translations
-
-
def substitute_translation_references(integration_strings, flattened_translations):
"""Recursively processes all translation strings for the integration."""
result = {}
diff --git a/script/translations/download.py b/script/translations/download.py
index 756de46fb61..3fa7065d058 100755
--- a/script/translations/download.py
+++ b/script/translations/download.py
@@ -7,10 +7,11 @@ import json
from pathlib import Path
import re
import subprocess
+from typing import Any
from .const import CLI_2_DOCKER_IMAGE, CORE_PROJECT_ID, INTEGRATIONS_DIR
from .error import ExitApp
-from .util import get_lokalise_token, load_json_from_path
+from .util import flatten_translations, get_lokalise_token, load_json_from_path
FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json")
DOWNLOAD_DIR = Path("build/translations-download").absolute()
@@ -103,7 +104,15 @@ def save_language_translations(lang, translations):
f"Skipping {lang} for {component}, as the integration doesn't seem to exist."
)
continue
+ if not (
+ Path("homeassistant") / "components" / component / "strings.json"
+ ).exists():
+ print(
+ f"Skipping {lang} for {component}, as the integration doesn't have a strings.json file."
+ )
+ continue
path.parent.mkdir(parents=True, exist_ok=True)
+ base_translations = pick_keys(component, base_translations)
save_json(path, base_translations)
if "platform" not in component_translations:
@@ -131,6 +140,32 @@ def delete_old_translations():
fil.unlink()
+def get_current_keys(component: str) -> dict[str, Any]:
+ """Get the current keys for a component."""
+ strings_path = Path("homeassistant") / "components" / component / "strings.json"
+ return load_json_from_path(strings_path)
+
+
+def pick_keys(component: str, translations: dict[str, Any]) -> dict[str, Any]:
+ """Pick the keys that are in the current strings."""
+ flat_translations = flatten_translations(translations)
+ flat_current_keys = flatten_translations(get_current_keys(component))
+ flatten_result = {}
+ for key in flat_current_keys:
+ if key in flat_translations:
+ flatten_result[key] = flat_translations[key]
+ result = {}
+ for key, value in flatten_result.items():
+ parts = key.split("::")
+ d = result
+ for part in parts[:-1]:
+ if part not in d:
+ d[part] = {}
+ d = d[part]
+ d[parts[-1]] = value
+ return result
+
+
def run():
"""Run the script."""
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
diff --git a/script/translations/util.py b/script/translations/util.py
index 8892bb46b7a..d78b2c4faff 100644
--- a/script/translations/util.py
+++ b/script/translations/util.py
@@ -66,3 +66,26 @@ def load_json_from_path(path: pathlib.Path) -> Any:
return json.loads(path.read_text())
except json.JSONDecodeError as err:
raise JSONDecodeErrorWithPath(err.msg, err.doc, err.pos, path) from err
+
+
+def flatten_translations(translations):
+ """Flatten all translations."""
+ stack = [iter(translations.items())]
+ key_stack = []
+ flattened_translations = {}
+ while stack:
+ for k, v in stack[-1]:
+ key_stack.append(k)
+ if isinstance(v, dict):
+ stack.append(iter(v.items()))
+ break
+ if isinstance(v, str):
+ common_key = "::".join(key_stack)
+ flattened_translations[common_key] = v
+ key_stack.pop()
+ else:
+ stack.pop()
+ if key_stack:
+ key_stack.pop()
+
+ return flattened_translations
diff --git a/tests/auth/test_jwt_wrapper.py b/tests/auth/test_jwt_wrapper.py
index 297d4dd5d7f..f9295a7791c 100644
--- a/tests/auth/test_jwt_wrapper.py
+++ b/tests/auth/test_jwt_wrapper.py
@@ -6,6 +6,12 @@ import pytest
from homeassistant.auth import jwt_wrapper
+async def test_all_default_options_are_in_verify_options() -> None:
+ """Test that all default options in _VERIFY_OPTIONS."""
+ for option in jwt_wrapper._PyJWTWithVerify._get_default_options():
+ assert option in jwt_wrapper._VERIFY_OPTIONS
+
+
async def test_reject_access_token_with_impossible_large_size() -> None:
"""Test rejecting access tokens with impossible sizes."""
with pytest.raises(jwt.DecodeError):
diff --git a/tests/common.py b/tests/common.py
index 8bd45e4d7f8..ac6f10b8c44 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -491,7 +491,7 @@ _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
def _async_fire_time_changed(
hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool
) -> None:
- timestamp = dt_util.utc_to_timestamp(utc_datetime)
+ timestamp = utc_datetime.timestamp()
for task in list(get_scheduled_timer_handles(hass.loop)):
if not isinstance(task, asyncio.TimerHandle):
continue
@@ -1815,3 +1815,20 @@ async def snapshot_platform(
state = hass.states.get(entity_entry.entity_id)
assert state, f"State not found for {entity_entry.entity_id}"
assert state == snapshot(name=f"{entity_entry.entity_id}-state")
+
+
+def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None:
+ """Reset translation cache for specified components.
+
+ Use this if you are mocking a core component (for example via
+ mock_integration), to ensure that the mocked translations are not
+ persisted in the shared session cache.
+ """
+ translations_cache = translation._async_get_translations_cache(hass)
+ for loaded_components in translations_cache.cache_data.loaded.values():
+ for component_to_unload in components:
+ loaded_components.discard(component_to_unload)
+ for loaded_categories in translations_cache.cache_data.cache.values():
+ for loaded_components in loaded_categories.values():
+ for component_to_unload in components:
+ loaded_components.pop(component_to_unload, None)
diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py
index 9fca6dcbdd3..ed71cb550a7 100644
--- a/tests/components/abode/test_init.py
+++ b/tests/components/abode/test_init.py
@@ -13,7 +13,6 @@ from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.data_entry_flow import FlowResultType
from .common import setup_platform
@@ -63,25 +62,23 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
async def test_invalid_credentials(hass: HomeAssistant) -> None:
"""Test Abode credentials changing."""
- with (
- patch(
- "homeassistant.components.abode.Abode",
- side_effect=AbodeAuthenticationException(
- (HTTPStatus.BAD_REQUEST, "auth error")
- ),
+ with patch(
+ "homeassistant.components.abode.Abode",
+ side_effect=AbodeAuthenticationException(
+ (HTTPStatus.BAD_REQUEST, "auth error")
),
- patch(
- "homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth",
- return_value={
- "type": FlowResultType.FORM,
- "flow_id": "mock_flow",
- "step_id": "reauth_confirm",
- },
- ) as mock_async_step_reauth,
):
- await setup_platform(hass, ALARM_DOMAIN)
+ config_entry = await setup_platform(hass, ALARM_DOMAIN)
+ await hass.async_block_till_done()
- mock_async_step_reauth.assert_called_once()
+ assert config_entry.state is ConfigEntryState.SETUP_ERROR
+
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+ assert flows[0]["step_id"] == "reauth_confirm"
+
+ hass.config_entries.flow.async_abort(flows[0]["flow_id"])
+ assert not hass.config_entries.flow.async_progress()
async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> None:
diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py
index fc9000a39f8..d556a20fa90 100644
--- a/tests/components/abode/test_light.py
+++ b/tests/components/abode/test_light.py
@@ -45,7 +45,7 @@ async def test_attributes(hass: HomeAssistant) -> None:
state = hass.states.get(DEVICE_ID)
assert state.state == STATE_ON
assert state.attributes.get(ATTR_BRIGHTNESS) == 204
- assert state.attributes.get(ATTR_RGB_COLOR) == (0, 63, 255)
+ assert state.attributes.get(ATTR_RGB_COLOR) == (0, 64, 255)
assert state.attributes.get(ATTR_COLOR_TEMP) is None
assert state.attributes.get(ATTR_DEVICE_ID) == "ZB:db5b1a"
assert not state.attributes.get("battery_low")
diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py
index 7e3c19c6c5a..ff151f3b096 100644
--- a/tests/components/acaia/conftest.py
+++ b/tests/components/acaia/conftest.py
@@ -52,9 +52,10 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_scale: MagicMock
-) -> None:
+) -> MockConfigEntry:
"""Set up the acaia integration for testing."""
await setup_integration(hass, mock_config_entry)
+ return mock_config_entry
@pytest.fixture
@@ -70,6 +71,7 @@ def mock_scale() -> Generator[MagicMock]:
scale.connected = True
scale.mac = "aa:bb:cc:dd:ee:ff"
scale.model = "Lunar"
+ scale.last_disconnect_time = "1732181388.1895587"
scale.timer_running = True
scale.heartbeat_task = None
scale.process_queue_task = None
@@ -77,4 +79,6 @@ def mock_scale() -> Generator[MagicMock]:
battery_level=42, units=AcaiaUnitOfMass.OUNCES
)
scale.weight = 123.45
+ scale.timer = 23
+ scale.flow_rate = 1.23
yield scale
diff --git a/tests/components/acaia/snapshots/test_diagnostics.ambr b/tests/components/acaia/snapshots/test_diagnostics.ambr
new file mode 100644
index 00000000000..df5e4d36555
--- /dev/null
+++ b/tests/components/acaia/snapshots/test_diagnostics.ambr
@@ -0,0 +1,16 @@
+# serializer version: 1
+# name: test_diagnostics
+ dict({
+ 'device_state': dict({
+ 'auto_off_time': 0,
+ 'battery_level': 42,
+ 'beeps': True,
+ 'units': 'ounces',
+ }),
+ 'last_disconnect_time': '1732181388.1895587',
+ 'mac': 'aa:bb:cc:dd:ee:ff',
+ 'model': 'Lunar',
+ 'timer': 23,
+ 'weight': 123.45,
+ })
+# ---
diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr
index 1cc3d8dbbc0..7011b20f68c 100644
--- a/tests/components/acaia/snapshots/test_init.ambr
+++ b/tests/components/acaia/snapshots/test_init.ambr
@@ -5,6 +5,10 @@
'config_entries': ,
'configuration_url': None,
'connections': set({
+ tuple(
+ 'bluetooth',
+ 'aa:bb:cc:dd:ee:ff',
+ ),
}),
'disabled_by': None,
'entry_type': None,
diff --git a/tests/components/acaia/snapshots/test_sensor.ambr b/tests/components/acaia/snapshots/test_sensor.ambr
index 46995877b4f..c3c8ce966ee 100644
--- a/tests/components/acaia/snapshots/test_sensor.ambr
+++ b/tests/components/acaia/snapshots/test_sensor.ambr
@@ -50,6 +50,60 @@
'state': '42',
})
# ---
+# name: test_sensors[sensor.lunar_ddeeff_volume_flow_rate-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.lunar_ddeeff_volume_flow_rate',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ 'sensor': dict({
+ 'suggested_display_precision': 1,
+ }),
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Volume flow rate',
+ 'platform': 'acaia',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': None,
+ 'unique_id': 'aa:bb:cc:dd:ee:ff_flow_rate',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[sensor.lunar_ddeeff_volume_flow_rate-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'volume_flow_rate',
+ 'friendly_name': 'LUNAR-DDEEFF Volume flow rate',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.lunar_ddeeff_volume_flow_rate',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '1.23',
+ })
+# ---
# name: test_sensors[sensor.lunar_ddeeff_weight-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
diff --git a/tests/components/acaia/test_diagnostics.py b/tests/components/acaia/test_diagnostics.py
new file mode 100644
index 00000000000..77f6306b068
--- /dev/null
+++ b/tests/components/acaia/test_diagnostics.py
@@ -0,0 +1,22 @@
+"""Tests for the diagnostics data provided by the Acaia integration."""
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+from tests.components.diagnostics import get_diagnostics_for_config_entry
+from tests.typing import ClientSessionGenerator
+
+
+async def test_diagnostics(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ init_integration: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test diagnostics."""
+ assert (
+ await get_diagnostics_for_config_entry(hass, hass_client, init_integration)
+ == snapshot
+ )
diff --git a/tests/components/alarm_control_panel/__init__.py b/tests/components/alarm_control_panel/__init__.py
index 1ef1161edd0..1f43c567844 100644
--- a/tests/components/alarm_control_panel/__init__.py
+++ b/tests/components/alarm_control_panel/__init__.py
@@ -1 +1,27 @@
"""The tests for Alarm control panel platforms."""
+
+from homeassistant.components.alarm_control_panel import (
+ DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+
+async def help_async_setup_entry_init(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> bool:
+ """Set up test config entry."""
+ await hass.config_entries.async_forward_entry_setups(
+ config_entry, [ALARM_CONTROL_PANEL_DOMAIN]
+ )
+ return True
+
+
+async def help_async_unload_entry(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> bool:
+ """Unload test config emntry."""
+ return await hass.config_entries.async_unload_platforms(
+ config_entry, [Platform.ALARM_CONTROL_PANEL]
+ )
diff --git a/tests/components/alarm_control_panel/conftest.py b/tests/components/alarm_control_panel/conftest.py
index 3e82b935493..ddf67b27860 100644
--- a/tests/components/alarm_control_panel/conftest.py
+++ b/tests/components/alarm_control_panel/conftest.py
@@ -1,7 +1,7 @@
"""Fixturs for Alarm Control Panel tests."""
-from collections.abc import Generator
-from unittest.mock import MagicMock
+from collections.abc import AsyncGenerator, Generator
+from unittest.mock import MagicMock, patch
import pytest
@@ -13,7 +13,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.components.alarm_control_panel.const import CodeFormat
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import entity_registry as er, frame
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import MockAlarm
@@ -107,6 +107,22 @@ class MockFlow(ConfigFlow):
"""Test flow."""
+@pytest.fixture(name="mock_as_custom_component")
+async def mock_frame(hass: HomeAssistant) -> AsyncGenerator[None]:
+ """Mock frame."""
+ with patch(
+ "homeassistant.helpers.frame.get_integration_frame",
+ return_value=frame.IntegrationFrame(
+ custom_integration=True,
+ integration="alarm_control_panel",
+ module="test_init.py",
+ relative_filename="test_init.py",
+ frame=frame.get_current_frame(),
+ ),
+ ):
+ yield
+
+
@pytest.fixture(autouse=True)
def config_flow_fixture(hass: HomeAssistant) -> Generator[None]:
"""Mock config flow."""
diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py
index 89a2a2a2b1a..84d27a96db2 100644
--- a/tests/components/alarm_control_panel/test_init.py
+++ b/tests/components/alarm_control_panel/test_init.py
@@ -1,6 +1,5 @@
"""Test for the alarm control panel const module."""
-from types import ModuleType
from typing import Any
from unittest.mock import patch
@@ -12,7 +11,6 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
CodeFormat,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CODE,
SERVICE_ALARM_ARM_AWAY,
@@ -25,20 +23,17 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers import entity_registry as er, frame
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
-from .conftest import TEST_DOMAIN, MockAlarmControlPanel
+from . import help_async_setup_entry_init, help_async_unload_entry
+from .conftest import MockAlarmControlPanel
from tests.common import (
MockConfigEntry,
MockModule,
- MockPlatform,
- help_test_all,
- import_and_test_deprecated_constant_enum,
mock_integration,
- mock_platform,
+ setup_test_component_platform,
)
@@ -59,53 +54,6 @@ async def help_test_async_alarm_control_panel_service(
await hass.async_block_till_done()
-@pytest.mark.parametrize(
- "module",
- [alarm_control_panel, alarm_control_panel.const],
-)
-def test_all(module: ModuleType) -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(module)
-
-
-@pytest.mark.parametrize(
- "code_format",
- list(alarm_control_panel.CodeFormat),
-)
-@pytest.mark.parametrize(
- "module",
- [alarm_control_panel, alarm_control_panel.const],
-)
-def test_deprecated_constant_code_format(
- caplog: pytest.LogCaptureFixture,
- code_format: alarm_control_panel.CodeFormat,
- module: ModuleType,
-) -> None:
- """Test deprecated format constants."""
- import_and_test_deprecated_constant_enum(
- caplog, module, code_format, "FORMAT_", "2025.1"
- )
-
-
-@pytest.mark.parametrize(
- "entity_feature",
- list(alarm_control_panel.AlarmControlPanelEntityFeature),
-)
-@pytest.mark.parametrize(
- "module",
- [alarm_control_panel, alarm_control_panel.const],
-)
-def test_deprecated_support_alarm_constants(
- caplog: pytest.LogCaptureFixture,
- entity_feature: alarm_control_panel.AlarmControlPanelEntityFeature,
- module: ModuleType,
-) -> None:
- """Test deprecated support alarm constants."""
- import_and_test_deprecated_constant_enum(
- caplog, module, entity_feature, "SUPPORT_ALARM_", "2025.1"
- )
-
-
def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None:
"""Test deprecated supported features ints."""
@@ -297,6 +245,7 @@ async def test_alarm_control_panel_with_default_code(
mock_alarm_control_panel_entity.calls_disarm.assert_called_with("1234")
+@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
async def test_alarm_control_panel_not_log_deprecated_state_warning(
hass: HomeAssistant,
mock_alarm_control_panel_entity: MockAlarmControlPanel,
@@ -305,9 +254,14 @@ async def test_alarm_control_panel_not_log_deprecated_state_warning(
"""Test correctly using alarm_state doesn't log issue or raise repair."""
state = hass.states.get(mock_alarm_control_panel_entity.entity_id)
assert state is not None
- assert "Entities should implement the 'alarm_state' property and" not in caplog.text
+ assert (
+ "the 'alarm_state' property and return its state using the AlarmControlPanelState enum"
+ not in caplog.text
+ )
+@pytest.mark.usefixtures("mock_as_custom_component")
+@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop(
hass: HomeAssistant,
code_format: CodeFormat | None,
@@ -317,23 +271,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop
) -> None:
"""Test incorrectly using state property does log issue and raise repair."""
- async def async_setup_entry_init(
- hass: HomeAssistant, config_entry: ConfigEntry
- ) -> bool:
- """Set up test config entry."""
- await hass.config_entries.async_forward_entry_setups(
- config_entry, [ALARM_CONTROL_PANEL_DOMAIN]
- )
- return True
-
- mock_integration(
- hass,
- MockModule(
- TEST_DOMAIN,
- async_setup_entry=async_setup_entry_init,
- ),
- )
-
class MockLegacyAlarmControlPanel(MockAlarmControlPanel):
"""Mocked alarm control entity."""
@@ -358,37 +295,38 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop
code_format=code_format,
code_arm_required=code_arm_required,
)
-
- async def async_setup_entry_platform(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
- ) -> None:
- """Set up test alarm control panel platform via config entry."""
- async_add_entities([entity])
-
- mock_platform(
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+ mock_integration(
hass,
- f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}",
- MockPlatform(async_setup_entry=async_setup_entry_platform),
+ MockModule(
+ "test",
+ async_setup_entry=help_async_setup_entry_init,
+ async_unload_entry=help_async_unload_entry,
+ ),
+ built_in=False,
)
-
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- config_entry = MockConfigEntry(domain=TEST_DOMAIN)
- config_entry.add_to_hass(hass)
- assert await hass.config_entries.async_setup(config_entry.entry_id)
- await hass.async_block_till_done()
+ setup_test_component_platform(
+ hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True
+ )
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(entity.entity_id)
assert state is not None
- assert "Entities should implement the 'alarm_state' property and" in caplog.text
+ assert (
+ "Detected that custom integration 'alarm_control_panel' is setting state"
+ " directly. Entity None (.MockLegacyAlarmControlPanel'>) should implement"
+ " the 'alarm_state' property and return its state using the AlarmControlPanelState"
+ " enum at test_init.py, line 123: yield. This will stop working in Home Assistant"
+ " 2025.11, please create a bug report at" in caplog.text
+ )
+@pytest.mark.usefixtures("mock_as_custom_component")
+@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state_attr(
hass: HomeAssistant,
code_format: CodeFormat | None,
@@ -398,23 +336,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state
) -> None:
"""Test incorrectly using _attr_state attribute does log issue and raise repair."""
- async def async_setup_entry_init(
- hass: HomeAssistant, config_entry: ConfigEntry
- ) -> bool:
- """Set up test config entry."""
- await hass.config_entries.async_forward_entry_setups(
- config_entry, [ALARM_CONTROL_PANEL_DOMAIN]
- )
- return True
-
- mock_integration(
- hass,
- MockModule(
- TEST_DOMAIN,
- async_setup_entry=async_setup_entry_init,
- ),
- )
-
class MockLegacyAlarmControlPanel(MockAlarmControlPanel):
"""Mocked alarm control entity."""
@@ -438,59 +359,56 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state
code_format=code_format,
code_arm_required=code_arm_required,
)
-
- async def async_setup_entry_platform(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
- ) -> None:
- """Set up test alarm control panel platform via config entry."""
- async_add_entities([entity])
-
- mock_platform(
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+ mock_integration(
hass,
- f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}",
- MockPlatform(async_setup_entry=async_setup_entry_platform),
+ MockModule(
+ "test",
+ async_setup_entry=help_async_setup_entry_init,
+ async_unload_entry=help_async_unload_entry,
+ ),
)
-
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- config_entry = MockConfigEntry(domain=TEST_DOMAIN)
- config_entry.add_to_hass(hass)
- assert await hass.config_entries.async_setup(config_entry.entry_id)
- await hass.async_block_till_done()
+ setup_test_component_platform(
+ hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True
+ )
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(entity.entity_id)
assert state is not None
- assert "Entities should implement the 'alarm_state' property and" not in caplog.text
+ assert (
+ "Detected that custom integration 'alarm_control_panel' is setting state directly."
+ not in caplog.text
+ )
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- await help_test_async_alarm_control_panel_service(
- hass, entity.entity_id, SERVICE_ALARM_DISARM
- )
+ await help_test_async_alarm_control_panel_service(
+ hass, entity.entity_id, SERVICE_ALARM_DISARM
+ )
- assert "Entities should implement the 'alarm_state' property and" in caplog.text
+ assert (
+ "Detected that custom integration 'alarm_control_panel' is setting state directly."
+ " Entity alarm_control_panel.test_alarm_control_panel"
+ " (.MockLegacyAlarmControlPanel'>) should implement the 'alarm_state' property"
+ " and return its state using the AlarmControlPanelState enum at test_init.py, line 123:"
+ " yield. This will stop working in Home Assistant 2025.11,"
+ " please create a bug report at" in caplog.text
+ )
caplog.clear()
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- await help_test_async_alarm_control_panel_service(
- hass, entity.entity_id, SERVICE_ALARM_DISARM
- )
+ await help_test_async_alarm_control_panel_service(
+ hass, entity.entity_id, SERVICE_ALARM_DISARM
+ )
# Test we only log once
- assert "Entities should implement the 'alarm_state' property and" not in caplog.text
+ assert (
+ "Detected that custom integration 'alarm_control_panel' is setting state directly."
+ not in caplog.text
+ )
+@pytest.mark.usefixtures("mock_as_custom_component")
+@patch.object(frame, "_REPORTED_INTEGRATIONS", set())
async def test_alarm_control_panel_deprecated_state_does_not_break_state(
hass: HomeAssistant,
code_format: CodeFormat | None,
@@ -500,23 +418,6 @@ async def test_alarm_control_panel_deprecated_state_does_not_break_state(
) -> None:
"""Test using _attr_state attribute does not break state."""
- async def async_setup_entry_init(
- hass: HomeAssistant, config_entry: ConfigEntry
- ) -> bool:
- """Set up test config entry."""
- await hass.config_entries.async_forward_entry_setups(
- config_entry, [ALARM_CONTROL_PANEL_DOMAIN]
- )
- return True
-
- mock_integration(
- hass,
- MockModule(
- TEST_DOMAIN,
- async_setup_entry=async_setup_entry_init,
- ),
- )
-
class MockLegacyAlarmControlPanel(MockAlarmControlPanel):
"""Mocked alarm control entity."""
@@ -541,43 +442,28 @@ async def test_alarm_control_panel_deprecated_state_does_not_break_state(
code_format=code_format,
code_arm_required=code_arm_required,
)
-
- async def async_setup_entry_platform(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
- ) -> None:
- """Set up test alarm control panel platform via config entry."""
- async_add_entities([entity])
-
- mock_platform(
+ config_entry = MockConfigEntry(domain="test")
+ config_entry.add_to_hass(hass)
+ mock_integration(
hass,
- f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}",
- MockPlatform(async_setup_entry=async_setup_entry_platform),
+ MockModule(
+ "test",
+ async_setup_entry=help_async_setup_entry_init,
+ async_unload_entry=help_async_unload_entry,
+ ),
)
-
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- config_entry = MockConfigEntry(domain=TEST_DOMAIN)
- config_entry.add_to_hass(hass)
- assert await hass.config_entries.async_setup(config_entry.entry_id)
- await hass.async_block_till_done()
+ setup_test_component_platform(
+ hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True
+ )
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(entity.entity_id)
assert state is not None
assert state.state == "armed_away"
- with patch.object(
- MockLegacyAlarmControlPanel,
- "__module__",
- "tests.custom_components.test.alarm_control_panel",
- ):
- await help_test_async_alarm_control_panel_service(
- hass, entity.entity_id, SERVICE_ALARM_DISARM
- )
+ await help_test_async_alarm_control_panel_service(
+ hass, entity.entity_id, SERVICE_ALARM_DISARM
+ )
state = hass.states.get(entity.entity_id)
assert state is not None
diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py
index 68010a6a711..e4a46db7d34 100644
--- a/tests/components/alexa/test_smart_home.py
+++ b/tests/components/alexa/test_smart_home.py
@@ -4546,6 +4546,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
"tilt_position_attr_in_service_call",
"supported_features",
"service_call",
+ "stop_feature_enabled",
),
[
(
@@ -4556,6 +4557,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.set_cover_tilt_position",
+ True,
),
(
0,
@@ -4565,6 +4567,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.close_cover_tilt",
+ True,
),
(
99,
@@ -4574,6 +4577,7 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.set_cover_tilt_position",
+ True,
),
(
100,
@@ -4583,36 +4587,42 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.open_cover_tilt",
+ True,
),
(
0,
0,
CoverEntityFeature.SET_TILT_POSITION,
"cover.set_cover_tilt_position",
+ False,
),
(
60,
60,
CoverEntityFeature.SET_TILT_POSITION,
"cover.set_cover_tilt_position",
+ False,
),
(
100,
100,
CoverEntityFeature.SET_TILT_POSITION,
"cover.set_cover_tilt_position",
+ False,
),
(
0,
0,
CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT,
"cover.set_cover_tilt_position",
+ False,
),
(
100,
100,
CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.CLOSE_TILT,
"cover.set_cover_tilt_position",
+ False,
),
],
ids=[
@@ -4633,6 +4643,7 @@ async def test_cover_tilt_position(
tilt_position_attr_in_service_call: int | None,
supported_features: CoverEntityFeature,
service_call: str,
+ stop_feature_enabled: bool,
) -> None:
"""Test cover discovery and tilt position using rangeController."""
device = (
@@ -4651,12 +4662,24 @@ async def test_cover_tilt_position(
assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
assert appliance["friendlyName"] == "Test cover tilt range"
+ expected_interfaces: dict[bool, list[str]] = {
+ False: [
+ "Alexa.PowerController",
+ "Alexa.RangeController",
+ "Alexa.EndpointHealth",
+ "Alexa",
+ ],
+ True: [
+ "Alexa.PowerController",
+ "Alexa.RangeController",
+ "Alexa.PlaybackController",
+ "Alexa.EndpointHealth",
+ "Alexa",
+ ],
+ }
+
capabilities = assert_endpoint_capabilities(
- appliance,
- "Alexa.PowerController",
- "Alexa.RangeController",
- "Alexa.EndpointHealth",
- "Alexa",
+ appliance, *expected_interfaces[stop_feature_enabled]
)
range_capability = get_capability(capabilities, "Alexa.RangeController")
@@ -4713,6 +4736,7 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None:
appliance,
"Alexa.PowerController",
"Alexa.RangeController",
+ "Alexa.PlaybackController",
"Alexa.EndpointHealth",
"Alexa",
)
@@ -4767,6 +4791,66 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None:
)
+@pytest.mark.parametrize(
+ ("supported_stop_features", "cover_stop_calls", "cover_stop_tilt_calls"),
+ [
+ (CoverEntityFeature(0), 0, 0),
+ (CoverEntityFeature.STOP, 1, 0),
+ (CoverEntityFeature.STOP_TILT, 0, 1),
+ (CoverEntityFeature.STOP | CoverEntityFeature.STOP_TILT, 1, 1),
+ ],
+ ids=["no_stop", "stop_cover", "stop_cover_tilt", "stop_cover_and_stop_cover_tilt"],
+)
+async def test_cover_stop(
+ hass: HomeAssistant,
+ supported_stop_features: CoverEntityFeature,
+ cover_stop_calls: int,
+ cover_stop_tilt_calls: int,
+) -> None:
+ """Test cover and cover tilt can be stopped."""
+
+ base_features = (
+ CoverEntityFeature.OPEN
+ | CoverEntityFeature.CLOSE
+ | CoverEntityFeature.OPEN_TILT
+ | CoverEntityFeature.CLOSE_TILT
+ | CoverEntityFeature.SET_POSITION
+ | CoverEntityFeature.SET_TILT_POSITION
+ )
+
+ device = (
+ "cover.test_semantics",
+ "open",
+ {
+ "friendly_name": "Test cover semantics",
+ "device_class": "blind",
+ "supported_features": int(base_features | supported_stop_features),
+ "current_position": 30,
+ "tilt_position": 30,
+ },
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance["endpointId"] == "cover#test_semantics"
+ assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
+ assert appliance["friendlyName"] == "Test cover semantics"
+
+ calls_stop = async_mock_service(hass, "cover", "stop_cover")
+ calls_stop_tilt = async_mock_service(hass, "cover", "stop_cover_tilt")
+
+ context = Context()
+ request = get_new_request(
+ "Alexa.PlaybackController", "Stop", "cover#test_semantics"
+ )
+ await smart_home.async_handle_message(
+ hass, get_default_config(hass), request, context
+ )
+ await hass.async_block_till_done()
+
+ assert len(calls_stop) == cover_stop_calls
+ assert len(calls_stop_tilt) == cover_stop_tilt_calls
+
+
async def test_cover_semantics_position_and_tilt(hass: HomeAssistant) -> None:
"""Test cover discovery and semantics with position and tilt support."""
device = (
@@ -4790,10 +4874,30 @@ async def test_cover_semantics_position_and_tilt(hass: HomeAssistant) -> None:
appliance,
"Alexa.PowerController",
"Alexa.RangeController",
+ "Alexa.PlaybackController",
"Alexa.EndpointHealth",
"Alexa",
)
+ playback_controller_capability = get_capability(
+ capabilities, "Alexa.PlaybackController"
+ )
+ assert playback_controller_capability is not None
+ assert playback_controller_capability["supportedOperations"] == ["Stop"]
+
+ # Assert both the cover and tilt stop calls are invoked
+ stop_cover_tilt_calls = async_mock_service(hass, "cover", "stop_cover_tilt")
+ await assert_request_calls_service(
+ "Alexa.PlaybackController",
+ "Stop",
+ "cover#test_semantics",
+ "cover.stop_cover",
+ hass,
+ )
+ assert len(stop_cover_tilt_calls) == 1
+ call = stop_cover_tilt_calls[0]
+ assert call.data == {"entity_id": "cover.test_semantics"}
+
# Assert for Position Semantics
position_capability = get_capability(
capabilities, "Alexa.RangeController", "cover.position"
diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py
index 2bc65fdd558..971f3690a0d 100644
--- a/tests/components/amberelectric/helpers.py
+++ b/tests/components/amberelectric/helpers.py
@@ -2,73 +2,82 @@
from datetime import datetime, timedelta
-from amberelectric.model.actual_interval import ActualInterval
-from amberelectric.model.channel import ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.forecast_interval import ForecastInterval
-from amberelectric.model.interval import Descriptor, SpikeStatus
+from amberelectric.models.actual_interval import ActualInterval
+from amberelectric.models.channel import ChannelType
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.forecast_interval import ForecastInterval
+from amberelectric.models.interval import Interval
+from amberelectric.models.price_descriptor import PriceDescriptor
+from amberelectric.models.spike_status import SpikeStatus
from dateutil import parser
-def generate_actual_interval(
- channel_type: ChannelType, end_time: datetime
-) -> ActualInterval:
+def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> Interval:
"""Generate a mock actual interval."""
start_time = end_time - timedelta(minutes=30)
- return ActualInterval(
- duration=30,
- spot_per_kwh=1.0,
- per_kwh=8.0,
- date=start_time.date(),
- nem_time=end_time,
- start_time=start_time,
- end_time=end_time,
- renewables=50,
- channel_type=channel_type.value,
- spike_status=SpikeStatus.NO_SPIKE.value,
- descriptor=Descriptor.LOW.value,
+ return Interval(
+ ActualInterval(
+ type="ActualInterval",
+ duration=30,
+ spot_per_kwh=1.0,
+ per_kwh=8.0,
+ date=start_time.date(),
+ nem_time=end_time,
+ start_time=start_time,
+ end_time=end_time,
+ renewables=50,
+ channel_type=channel_type,
+ spike_status=SpikeStatus.NONE,
+ descriptor=PriceDescriptor.LOW,
+ )
)
def generate_current_interval(
channel_type: ChannelType, end_time: datetime
-) -> CurrentInterval:
+) -> Interval:
"""Generate a mock current price."""
start_time = end_time - timedelta(minutes=30)
- return CurrentInterval(
- duration=30,
- spot_per_kwh=1.0,
- per_kwh=8.0,
- date=start_time.date(),
- nem_time=end_time,
- start_time=start_time,
- end_time=end_time,
- renewables=50.6,
- channel_type=channel_type.value,
- spike_status=SpikeStatus.NO_SPIKE.value,
- descriptor=Descriptor.EXTREMELY_LOW.value,
- estimate=True,
+ return Interval(
+ CurrentInterval(
+ type="CurrentInterval",
+ duration=30,
+ spot_per_kwh=1.0,
+ per_kwh=8.0,
+ date=start_time.date(),
+ nem_time=end_time,
+ start_time=start_time,
+ end_time=end_time,
+ renewables=50.6,
+ channel_type=channel_type,
+ spike_status=SpikeStatus.NONE,
+ descriptor=PriceDescriptor.EXTREMELYLOW,
+ estimate=True,
+ )
)
def generate_forecast_interval(
channel_type: ChannelType, end_time: datetime
-) -> ForecastInterval:
+) -> Interval:
"""Generate a mock forecast interval."""
start_time = end_time - timedelta(minutes=30)
- return ForecastInterval(
- duration=30,
- spot_per_kwh=1.1,
- per_kwh=8.8,
- date=start_time.date(),
- nem_time=end_time,
- start_time=start_time,
- end_time=end_time,
- renewables=50,
- channel_type=channel_type.value,
- spike_status=SpikeStatus.NO_SPIKE.value,
- descriptor=Descriptor.VERY_LOW.value,
- estimate=True,
+ return Interval(
+ ForecastInterval(
+ type="ForecastInterval",
+ duration=30,
+ spot_per_kwh=1.1,
+ per_kwh=8.8,
+ date=start_time.date(),
+ nem_time=end_time,
+ start_time=start_time,
+ end_time=end_time,
+ renewables=50,
+ channel_type=channel_type,
+ spike_status=SpikeStatus.NONE,
+ descriptor=PriceDescriptor.VERYLOW,
+ estimate=True,
+ )
)
@@ -94,31 +103,31 @@ GENERAL_CHANNEL = [
CONTROLLED_LOAD_CHANNEL = [
generate_current_interval(
- ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T08:30:00+10:00")
+ ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00")
),
generate_forecast_interval(
- ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:00:00+10:00")
+ ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T09:00:00+10:00")
),
generate_forecast_interval(
- ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T09:30:00+10:00")
+ ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T09:30:00+10:00")
),
generate_forecast_interval(
- ChannelType.CONTROLLED_LOAD, parser.parse("2021-09-21T10:00:00+10:00")
+ ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T10:00:00+10:00")
),
]
FEED_IN_CHANNEL = [
generate_current_interval(
- ChannelType.FEED_IN, parser.parse("2021-09-21T08:30:00+10:00")
+ ChannelType.FEEDIN, parser.parse("2021-09-21T08:30:00+10:00")
),
generate_forecast_interval(
- ChannelType.FEED_IN, parser.parse("2021-09-21T09:00:00+10:00")
+ ChannelType.FEEDIN, parser.parse("2021-09-21T09:00:00+10:00")
),
generate_forecast_interval(
- ChannelType.FEED_IN, parser.parse("2021-09-21T09:30:00+10:00")
+ ChannelType.FEEDIN, parser.parse("2021-09-21T09:30:00+10:00")
),
generate_forecast_interval(
- ChannelType.FEED_IN, parser.parse("2021-09-21T10:00:00+10:00")
+ ChannelType.FEEDIN, parser.parse("2021-09-21T10:00:00+10:00")
),
]
diff --git a/tests/components/amberelectric/test_binary_sensor.py b/tests/components/amberelectric/test_binary_sensor.py
index 2c1ee22b644..6a6ca372bc2 100644
--- a/tests/components/amberelectric/test_binary_sensor.py
+++ b/tests/components/amberelectric/test_binary_sensor.py
@@ -5,10 +5,10 @@ from __future__ import annotations
from collections.abc import AsyncGenerator
from unittest.mock import Mock, patch
-from amberelectric.model.channel import ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.interval import SpikeStatus
-from amberelectric.model.tariff_information import TariffInformation
+from amberelectric.models.channel import ChannelType
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.spike_status import SpikeStatus
+from amberelectric.models.tariff_information import TariffInformation
from dateutil import parser
import pytest
@@ -42,10 +42,10 @@ async def setup_no_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
- instance.get_current_price = Mock(return_value=GENERAL_CHANNEL)
+ instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -65,7 +65,7 @@ async def setup_potential_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
general_channel: list[CurrentInterval] = [
@@ -73,8 +73,8 @@ async def setup_potential_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
]
- general_channel[0].spike_status = SpikeStatus.POTENTIAL
- instance.get_current_price = Mock(return_value=general_channel)
+ general_channel[0].actual_instance.spike_status = SpikeStatus.POTENTIAL
+ instance.get_current_prices = Mock(return_value=general_channel)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -94,7 +94,7 @@ async def setup_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
general_channel: list[CurrentInterval] = [
@@ -102,8 +102,8 @@ async def setup_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]:
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
]
- general_channel[0].spike_status = SpikeStatus.SPIKE
- instance.get_current_price = Mock(return_value=general_channel)
+ general_channel[0].actual_instance.spike_status = SpikeStatus.SPIKE
+ instance.get_current_prices = Mock(return_value=general_channel)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -156,7 +156,7 @@ async def setup_inactive_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mo
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
general_channel: list[CurrentInterval] = [
@@ -164,8 +164,10 @@ async def setup_inactive_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mo
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
]
- general_channel[0].tariff_information = TariffInformation(demandWindow=False)
- instance.get_current_price = Mock(return_value=general_channel)
+ general_channel[0].actual_instance.tariff_information = TariffInformation(
+ demandWindow=False
+ )
+ instance.get_current_prices = Mock(return_value=general_channel)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -185,7 +187,7 @@ async def setup_active_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mock
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
general_channel: list[CurrentInterval] = [
@@ -193,8 +195,10 @@ async def setup_active_demand_window(hass: HomeAssistant) -> AsyncGenerator[Mock
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
]
- general_channel[0].tariff_information = TariffInformation(demandWindow=True)
- instance.get_current_price = Mock(return_value=general_channel)
+ general_channel[0].actual_instance.tariff_information = TariffInformation(
+ demandWindow=True
+ )
+ instance.get_current_prices = Mock(return_value=general_channel)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py
index 030b82d3596..b394977b0e8 100644
--- a/tests/components/amberelectric/test_config_flow.py
+++ b/tests/components/amberelectric/test_config_flow.py
@@ -5,7 +5,8 @@ from datetime import date
from unittest.mock import Mock, patch
from amberelectric import ApiException
-from amberelectric.model.site import Site, SiteStatus
+from amberelectric.models.site import Site
+from amberelectric.models.site_status import SiteStatus
import pytest
from homeassistant.components.amberelectric.config_flow import filter_sites
@@ -28,7 +29,7 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry")
def mock_invalid_key_api() -> Generator:
"""Return an authentication error."""
- with patch("amberelectric.api.AmberApi.create") as mock:
+ with patch("amberelectric.AmberApi") as mock:
mock.return_value.get_sites.side_effect = ApiException(status=403)
yield mock
@@ -36,7 +37,7 @@ def mock_invalid_key_api() -> Generator:
@pytest.fixture(name="api_error")
def mock_api_error() -> Generator:
"""Return an authentication error."""
- with patch("amberelectric.api.AmberApi.create") as mock:
+ with patch("amberelectric.AmberApi") as mock:
mock.return_value.get_sites.side_effect = ApiException(status=500)
yield mock
@@ -45,16 +46,36 @@ def mock_api_error() -> Generator:
def mock_single_site_api() -> Generator:
"""Return a single site."""
site = Site(
- "01FG0AGP818PXK0DWHXJRRT2DH",
- "11111111111",
- [],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2002, 1, 1),
- None,
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.ACTIVE,
+ active_from=date(2002, 1, 1),
+ closed_on=None,
+ interval_length=30,
)
- with patch("amberelectric.api.AmberApi.create") as mock:
+ with patch("amberelectric.AmberApi") as mock:
+ mock.return_value.get_sites.return_value = [site]
+ yield mock
+
+
+@pytest.fixture(name="single_site_closed_no_close_date_api")
+def single_site_closed_no_close_date_api() -> Generator:
+ """Return a single closed site with no closed date."""
+ site = Site(
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.CLOSED,
+ active_from=None,
+ closed_on=None,
+ interval_length=30,
+ )
+
+ with patch("amberelectric.AmberApi") as mock:
mock.return_value.get_sites.return_value = [site]
yield mock
@@ -63,16 +84,17 @@ def mock_single_site_api() -> Generator:
def mock_single_site_pending_api() -> Generator:
"""Return a single site."""
site = Site(
- "01FG0AGP818PXK0DWHXJRRT2DH",
- "11111111111",
- [],
- "Jemena",
- SiteStatus.PENDING,
- None,
- None,
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.PENDING,
+ active_from=None,
+ closed_on=None,
+ interval_length=30,
)
- with patch("amberelectric.api.AmberApi.create") as mock:
+ with patch("amberelectric.AmberApi") as mock:
mock.return_value.get_sites.return_value = [site]
yield mock
@@ -82,35 +104,38 @@ def mock_single_site_rejoin_api() -> Generator:
"""Return a single site."""
instance = Mock()
site_1 = Site(
- "01HGD9QB72HB3DWQNJ6SSCGXGV",
- "11111111111",
- [],
- "Jemena",
- SiteStatus.CLOSED,
- date(2002, 1, 1),
- date(2002, 6, 1),
+ id="01HGD9QB72HB3DWQNJ6SSCGXGV",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.CLOSED,
+ active_from=date(2002, 1, 1),
+ closed_on=date(2002, 6, 1),
+ interval_length=30,
)
site_2 = Site(
- "01FG0AGP818PXK0DWHXJRRT2DH",
- "11111111111",
- [],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2003, 1, 1),
- None,
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111111",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.ACTIVE,
+ active_from=date(2003, 1, 1),
+ closed_on=None,
+ interval_length=30,
)
site_3 = Site(
- "01FG0AGP818PXK0DWHXJRRT2DH",
- "11111111112",
- [],
- "Jemena",
- SiteStatus.CLOSED,
- date(2003, 1, 1),
- date(2003, 6, 1),
+ id="01FG0AGP818PXK0DWHXJRRT2DH",
+ nmi="11111111112",
+ channels=[],
+ network="Jemena",
+ status=SiteStatus.CLOSED,
+ active_from=date(2003, 1, 1),
+ closed_on=date(2003, 6, 1),
+ interval_length=30,
)
instance.get_sites.return_value = [site_1, site_2, site_3]
- with patch("amberelectric.api.AmberApi.create", return_value=instance):
+ with patch("amberelectric.AmberApi", return_value=instance):
yield instance
@@ -120,7 +145,7 @@ def mock_no_site_api() -> Generator:
instance = Mock()
instance.get_sites.return_value = []
- with patch("amberelectric.api.AmberApi.create", return_value=instance):
+ with patch("amberelectric.AmberApi", return_value=instance):
yield instance
@@ -188,6 +213,39 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None:
assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH"
+async def test_single_closed_site_no_closed_date(
+ hass: HomeAssistant, single_site_closed_no_close_date_api: Mock
+) -> None:
+ """Test single closed site with no closed date."""
+ initial_result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert initial_result.get("type") is FlowResultType.FORM
+ assert initial_result.get("step_id") == "user"
+
+ # Test filling in API key
+ enter_api_key_result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_API_TOKEN: API_KEY},
+ )
+ assert enter_api_key_result.get("type") is FlowResultType.FORM
+ assert enter_api_key_result.get("step_id") == "site"
+
+ select_site_result = await hass.config_entries.flow.async_configure(
+ enter_api_key_result["flow_id"],
+ {CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"},
+ )
+
+ # Show available sites
+ assert select_site_result.get("type") is FlowResultType.CREATE_ENTRY
+ assert select_site_result.get("title") == "Home"
+ data = select_site_result.get("data")
+ assert data
+ assert data[CONF_API_TOKEN] == API_KEY
+ assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH"
+
+
async def test_single_site_rejoin(
hass: HomeAssistant, single_site_rejoin_api: Mock
) -> None:
diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py
index cb3912cb5ac..0a8f5b874fa 100644
--- a/tests/components/amberelectric/test_coordinator.py
+++ b/tests/components/amberelectric/test_coordinator.py
@@ -7,10 +7,12 @@ from datetime import date
from unittest.mock import Mock, patch
from amberelectric import ApiException
-from amberelectric.model.channel import Channel, ChannelType
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.interval import Descriptor, SpikeStatus
-from amberelectric.model.site import Site, SiteStatus
+from amberelectric.models.channel import Channel, ChannelType
+from amberelectric.models.interval import Interval
+from amberelectric.models.price_descriptor import PriceDescriptor
+from amberelectric.models.site import Site
+from amberelectric.models.site_status import SiteStatus
+from amberelectric.models.spike_status import SpikeStatus
from dateutil import parser
import pytest
@@ -38,37 +40,40 @@ def mock_api_current_price() -> Generator:
instance = Mock()
general_site = Site(
- GENERAL_ONLY_SITE_ID,
- "11111111111",
- [Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100")],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2021, 1, 1),
- None,
+ id=GENERAL_ONLY_SITE_ID,
+ nmi="11111111111",
+ channels=[Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100")],
+ network="Jemena",
+ status=SiteStatus("active"),
+ activeFrom=date(2021, 1, 1),
+ closedOn=None,
+ interval_length=30,
)
general_and_controlled_load = Site(
- GENERAL_AND_CONTROLLED_SITE_ID,
- "11111111112",
- [
+ id=GENERAL_AND_CONTROLLED_SITE_ID,
+ nmi="11111111112",
+ channels=[
Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"),
- Channel(identifier="E2", type=ChannelType.CONTROLLED_LOAD, tariff="A180"),
+ Channel(identifier="E2", type=ChannelType.CONTROLLEDLOAD, tariff="A180"),
],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2021, 1, 1),
- None,
+ network="Jemena",
+ status=SiteStatus("active"),
+ activeFrom=date(2021, 1, 1),
+ closedOn=None,
+ interval_length=30,
)
general_and_feed_in = Site(
- GENERAL_AND_FEED_IN_SITE_ID,
- "11111111113",
- [
+ id=GENERAL_AND_FEED_IN_SITE_ID,
+ nmi="11111111113",
+ channels=[
Channel(identifier="E1", type=ChannelType.GENERAL, tariff="A100"),
- Channel(identifier="E2", type=ChannelType.FEED_IN, tariff="A100"),
+ Channel(identifier="E2", type=ChannelType.FEEDIN, tariff="A100"),
],
- "Jemena",
- SiteStatus.ACTIVE,
- date(2021, 1, 1),
- None,
+ network="Jemena",
+ status=SiteStatus("active"),
+ activeFrom=date(2021, 1, 1),
+ closedOn=None,
+ interval_length=30,
)
instance.get_sites.return_value = [
general_site,
@@ -76,44 +81,46 @@ def mock_api_current_price() -> Generator:
general_and_feed_in,
]
- with patch("amberelectric.api.AmberApi.create", return_value=instance):
+ with patch("amberelectric.AmberApi", return_value=instance):
yield instance
def test_normalize_descriptor() -> None:
"""Test normalizing descriptors works correctly."""
assert normalize_descriptor(None) is None
- assert normalize_descriptor(Descriptor.NEGATIVE) == "negative"
- assert normalize_descriptor(Descriptor.EXTREMELY_LOW) == "extremely_low"
- assert normalize_descriptor(Descriptor.VERY_LOW) == "very_low"
- assert normalize_descriptor(Descriptor.LOW) == "low"
- assert normalize_descriptor(Descriptor.NEUTRAL) == "neutral"
- assert normalize_descriptor(Descriptor.HIGH) == "high"
- assert normalize_descriptor(Descriptor.SPIKE) == "spike"
+ assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative"
+ assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low"
+ assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low"
+ assert normalize_descriptor(PriceDescriptor.LOW) == "low"
+ assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral"
+ assert normalize_descriptor(PriceDescriptor.HIGH) == "high"
+ assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike"
async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None:
"""Test fetching a site with only a general channel."""
- current_price_api.get_current_price.return_value = GENERAL_CHANNEL
+ current_price_api.get_current_prices.return_value = GENERAL_CHANNEL
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
result = await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_ONLY_SITE_ID, next=48
)
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
assert result["current"].get("controlled_load") is None
assert result["forecasts"].get("controlled_load") is None
assert result["current"].get("feed_in") is None
assert result["forecasts"].get("feed_in") is None
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
assert result["grid"]["price_spike"] == "none"
@@ -122,12 +129,12 @@ async def test_fetch_no_general_site(
) -> None:
"""Test fetching a site with no general channel."""
- current_price_api.get_current_price.return_value = CONTROLLED_LOAD_CHANNEL
+ current_price_api.get_current_prices.return_value = CONTROLLED_LOAD_CHANNEL
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
with pytest.raises(UpdateFailed):
await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_ONLY_SITE_ID, next=48
)
@@ -135,41 +142,45 @@ async def test_fetch_no_general_site(
async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> None:
"""Test that the old values are maintained if a second call fails."""
- current_price_api.get_current_price.return_value = GENERAL_CHANNEL
+ current_price_api.get_current_prices.return_value = GENERAL_CHANNEL
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
result = await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_ONLY_SITE_ID, next=48
)
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
assert result["current"].get("controlled_load") is None
assert result["forecasts"].get("controlled_load") is None
assert result["current"].get("feed_in") is None
assert result["forecasts"].get("feed_in") is None
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
- current_price_api.get_current_price.side_effect = ApiException(status=403)
+ current_price_api.get_current_prices.side_effect = ApiException(status=403)
with pytest.raises(UpdateFailed):
await data_service._async_update_data()
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
assert result["current"].get("controlled_load") is None
assert result["forecasts"].get("controlled_load") is None
assert result["current"].get("feed_in") is None
assert result["forecasts"].get("feed_in") is None
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
assert result["grid"]["price_spike"] == "none"
@@ -178,7 +189,7 @@ async def test_fetch_general_and_controlled_load_site(
) -> None:
"""Test fetching a site with a general and controlled load channel."""
- current_price_api.get_current_price.return_value = (
+ current_price_api.get_current_prices.return_value = (
GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL
)
data_service = AmberUpdateCoordinator(
@@ -186,25 +197,30 @@ async def test_fetch_general_and_controlled_load_site(
)
result = await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_AND_CONTROLLED_SITE_ID, next=48
)
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
- assert result["current"].get("controlled_load") is CONTROLLED_LOAD_CHANNEL[0]
+ assert (
+ result["current"].get("controlled_load")
+ is CONTROLLED_LOAD_CHANNEL[0].actual_instance
+ )
assert result["forecasts"].get("controlled_load") == [
- CONTROLLED_LOAD_CHANNEL[1],
- CONTROLLED_LOAD_CHANNEL[2],
- CONTROLLED_LOAD_CHANNEL[3],
+ CONTROLLED_LOAD_CHANNEL[1].actual_instance,
+ CONTROLLED_LOAD_CHANNEL[2].actual_instance,
+ CONTROLLED_LOAD_CHANNEL[3].actual_instance,
]
assert result["current"].get("feed_in") is None
assert result["forecasts"].get("feed_in") is None
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
assert result["grid"]["price_spike"] == "none"
@@ -213,31 +229,35 @@ async def test_fetch_general_and_feed_in_site(
) -> None:
"""Test fetching a site with a general and feed_in channel."""
- current_price_api.get_current_price.return_value = GENERAL_CHANNEL + FEED_IN_CHANNEL
+ current_price_api.get_current_prices.return_value = (
+ GENERAL_CHANNEL + FEED_IN_CHANNEL
+ )
data_service = AmberUpdateCoordinator(
hass, current_price_api, GENERAL_AND_FEED_IN_SITE_ID
)
result = await data_service._async_update_data()
- current_price_api.get_current_price.assert_called_with(
+ current_price_api.get_current_prices.assert_called_with(
GENERAL_AND_FEED_IN_SITE_ID, next=48
)
- assert result["current"].get("general") == GENERAL_CHANNEL[0]
+ assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
assert result["forecasts"].get("general") == [
- GENERAL_CHANNEL[1],
- GENERAL_CHANNEL[2],
- GENERAL_CHANNEL[3],
+ GENERAL_CHANNEL[1].actual_instance,
+ GENERAL_CHANNEL[2].actual_instance,
+ GENERAL_CHANNEL[3].actual_instance,
]
assert result["current"].get("controlled_load") is None
assert result["forecasts"].get("controlled_load") is None
- assert result["current"].get("feed_in") is FEED_IN_CHANNEL[0]
+ assert result["current"].get("feed_in") is FEED_IN_CHANNEL[0].actual_instance
assert result["forecasts"].get("feed_in") == [
- FEED_IN_CHANNEL[1],
- FEED_IN_CHANNEL[2],
- FEED_IN_CHANNEL[3],
+ FEED_IN_CHANNEL[1].actual_instance,
+ FEED_IN_CHANNEL[2].actual_instance,
+ FEED_IN_CHANNEL[3].actual_instance,
]
- assert result["grid"]["renewables"] == round(GENERAL_CHANNEL[0].renewables)
+ assert result["grid"]["renewables"] == round(
+ GENERAL_CHANNEL[0].actual_instance.renewables
+ )
assert result["grid"]["price_spike"] == "none"
@@ -246,13 +266,13 @@ async def test_fetch_potential_spike(
) -> None:
"""Test fetching a site with only a general channel."""
- general_channel: list[CurrentInterval] = [
+ general_channel: list[Interval] = [
generate_current_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
- ),
+ )
]
- general_channel[0].spike_status = SpikeStatus.POTENTIAL
- current_price_api.get_current_price.return_value = general_channel
+ general_channel[0].actual_instance.spike_status = SpikeStatus.POTENTIAL
+ current_price_api.get_current_prices.return_value = general_channel
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
result = await data_service._async_update_data()
assert result["grid"]["price_spike"] == "potential"
@@ -261,13 +281,13 @@ async def test_fetch_potential_spike(
async def test_fetch_spike(hass: HomeAssistant, current_price_api: Mock) -> None:
"""Test fetching a site with only a general channel."""
- general_channel: list[CurrentInterval] = [
+ general_channel: list[Interval] = [
generate_current_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
- ),
+ )
]
- general_channel[0].spike_status = SpikeStatus.SPIKE
- current_price_api.get_current_price.return_value = general_channel
+ general_channel[0].actual_instance.spike_status = SpikeStatus.SPIKE
+ current_price_api.get_current_prices.return_value = general_channel
data_service = AmberUpdateCoordinator(hass, current_price_api, GENERAL_ONLY_SITE_ID)
result = await data_service._async_update_data()
assert result["grid"]["price_spike"] == "spike"
diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py
index 3a5626d14d5..203b65d6df6 100644
--- a/tests/components/amberelectric/test_sensor.py
+++ b/tests/components/amberelectric/test_sensor.py
@@ -3,8 +3,9 @@
from collections.abc import AsyncGenerator
from unittest.mock import Mock, patch
-from amberelectric.model.current_interval import CurrentInterval
-from amberelectric.model.range import Range
+from amberelectric.models.current_interval import CurrentInterval
+from amberelectric.models.interval import Interval
+from amberelectric.models.range import Range
import pytest
from homeassistant.components.amberelectric.const import (
@@ -44,10 +45,10 @@ async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]:
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
- instance.get_current_price = Mock(return_value=GENERAL_CHANNEL)
+ instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@@ -68,10 +69,10 @@ async def setup_general_and_controlled_load(
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
- instance.get_current_price = Mock(
+ instance.get_current_prices = Mock(
return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL
)
assert await async_setup_component(hass, DOMAIN, {})
@@ -92,10 +93,10 @@ async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock]
instance = Mock()
with patch(
- "amberelectric.api.AmberApi.create",
+ "amberelectric.AmberApi",
return_value=instance,
) as mock_update:
- instance.get_current_price = Mock(
+ instance.get_current_prices = Mock(
return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL
)
assert await async_setup_component(hass, DOMAIN, {})
@@ -126,7 +127,7 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) ->
assert attributes.get("range_max") is None
with_range: list[CurrentInterval] = GENERAL_CHANNEL
- with_range[0].range = Range(7.8, 12.4)
+ with_range[0].actual_instance.range = Range(min=7.8, max=12.4)
setup_general.get_current_price.return_value = with_range
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
@@ -211,8 +212,8 @@ async def test_general_forecast_sensor(
assert first_forecast.get("range_min") is None
assert first_forecast.get("range_max") is None
- with_range: list[CurrentInterval] = GENERAL_CHANNEL
- with_range[1].range = Range(7.8, 12.4)
+ with_range: list[Interval] = GENERAL_CHANNEL
+ with_range[1].actual_instance.range = Range(min=7.8, max=12.4)
setup_general.get_current_price.return_value = with_range
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
diff --git a/tests/components/apsystems/test_init.py b/tests/components/apsystems/test_init.py
new file mode 100644
index 00000000000..c85c4094e30
--- /dev/null
+++ b/tests/components/apsystems/test_init.py
@@ -0,0 +1,25 @@
+"""Test the APSystem setup."""
+
+from unittest.mock import AsyncMock
+
+from APsystemsEZ1 import InverterReturnedError
+
+from homeassistant.components.apsystems.const import DOMAIN
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import HomeAssistant
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry
+
+
+async def test_update_failed(
+ hass: HomeAssistant,
+ mock_apsystems: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test update failed."""
+ mock_apsystems.get_output_data.side_effect = InverterReturnedError
+ await setup_integration(hass, mock_config_entry)
+ entry = hass.config_entries.async_entries(DOMAIN)[0]
+ assert entry.state is ConfigEntryState.SETUP_RETRY
diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr
index 7f77dada3be..c70d3944f88 100644
--- a/tests/components/assist_pipeline/snapshots/test_init.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_init.ambr
@@ -77,7 +77,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
}),
'type': ,
@@ -166,7 +166,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
}),
'type': ,
@@ -255,7 +255,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
}),
'type': ,
@@ -368,7 +368,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
}),
'type': ,
diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
index b806c6faf23..566fb129959 100644
--- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr
+++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr
@@ -73,7 +73,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
})
# ---
@@ -154,7 +154,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
})
# ---
@@ -247,7 +247,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
})
# ---
@@ -350,7 +350,7 @@
'tts_output': dict({
'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D",
'mime_type': 'audio/mpeg',
- 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3',
+ 'url': '/api/tts_proxy/test_token.mp3',
}),
})
# ---
diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py
index bdca27d527f..b177530219e 100644
--- a/tests/components/assist_pipeline/test_init.py
+++ b/tests/components/assist_pipeline/test_init.py
@@ -70,21 +70,24 @@ async def test_pipeline_from_audio_stream_auto(
yield make_10ms_chunk(b"part2")
yield b""
- await assist_pipeline.async_pipeline_from_audio_stream(
- hass,
- context=Context(),
- event_callback=events.append,
- stt_metadata=stt.SpeechMetadata(
- language="",
- format=stt.AudioFormats.WAV,
- codec=stt.AudioCodecs.PCM,
- bit_rate=stt.AudioBitRates.BITRATE_16,
- sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
- channel=stt.AudioChannels.CHANNEL_MONO,
- ),
- stt_stream=audio_data(),
- audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await assist_pipeline.async_pipeline_from_audio_stream(
+ hass,
+ context=Context(),
+ event_callback=events.append,
+ stt_metadata=stt.SpeechMetadata(
+ language="",
+ format=stt.AudioFormats.WAV,
+ codec=stt.AudioCodecs.PCM,
+ bit_rate=stt.AudioBitRates.BITRATE_16,
+ sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
+ channel=stt.AudioChannels.CHANNEL_MONO,
+ ),
+ stt_stream=audio_data(),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
+ )
assert process_events(events) == snapshot
assert len(mock_stt_provider_entity.received) == 2
@@ -133,23 +136,26 @@ async def test_pipeline_from_audio_stream_legacy(
assert msg["success"]
pipeline_id = msg["result"]["id"]
- # Use the created pipeline
- await assist_pipeline.async_pipeline_from_audio_stream(
- hass,
- context=Context(),
- event_callback=events.append,
- stt_metadata=stt.SpeechMetadata(
- language="en-UK",
- format=stt.AudioFormats.WAV,
- codec=stt.AudioCodecs.PCM,
- bit_rate=stt.AudioBitRates.BITRATE_16,
- sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
- channel=stt.AudioChannels.CHANNEL_MONO,
- ),
- stt_stream=audio_data(),
- pipeline_id=pipeline_id,
- audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ # Use the created pipeline
+ await assist_pipeline.async_pipeline_from_audio_stream(
+ hass,
+ context=Context(),
+ event_callback=events.append,
+ stt_metadata=stt.SpeechMetadata(
+ language="en-UK",
+ format=stt.AudioFormats.WAV,
+ codec=stt.AudioCodecs.PCM,
+ bit_rate=stt.AudioBitRates.BITRATE_16,
+ sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
+ channel=stt.AudioChannels.CHANNEL_MONO,
+ ),
+ stt_stream=audio_data(),
+ pipeline_id=pipeline_id,
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
+ )
assert process_events(events) == snapshot
assert len(mock_stt_provider.received) == 2
@@ -198,23 +204,26 @@ async def test_pipeline_from_audio_stream_entity(
assert msg["success"]
pipeline_id = msg["result"]["id"]
- # Use the created pipeline
- await assist_pipeline.async_pipeline_from_audio_stream(
- hass,
- context=Context(),
- event_callback=events.append,
- stt_metadata=stt.SpeechMetadata(
- language="en-UK",
- format=stt.AudioFormats.WAV,
- codec=stt.AudioCodecs.PCM,
- bit_rate=stt.AudioBitRates.BITRATE_16,
- sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
- channel=stt.AudioChannels.CHANNEL_MONO,
- ),
- stt_stream=audio_data(),
- pipeline_id=pipeline_id,
- audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ # Use the created pipeline
+ await assist_pipeline.async_pipeline_from_audio_stream(
+ hass,
+ context=Context(),
+ event_callback=events.append,
+ stt_metadata=stt.SpeechMetadata(
+ language="en-UK",
+ format=stt.AudioFormats.WAV,
+ codec=stt.AudioCodecs.PCM,
+ bit_rate=stt.AudioBitRates.BITRATE_16,
+ sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
+ channel=stt.AudioChannels.CHANNEL_MONO,
+ ),
+ stt_stream=audio_data(),
+ pipeline_id=pipeline_id,
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
+ )
assert process_events(events) == snapshot
assert len(mock_stt_provider_entity.received) == 2
@@ -362,25 +371,28 @@ async def test_pipeline_from_audio_stream_wake_word(
yield b""
- await assist_pipeline.async_pipeline_from_audio_stream(
- hass,
- context=Context(),
- event_callback=events.append,
- stt_metadata=stt.SpeechMetadata(
- language="",
- format=stt.AudioFormats.WAV,
- codec=stt.AudioCodecs.PCM,
- bit_rate=stt.AudioBitRates.BITRATE_16,
- sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
- channel=stt.AudioChannels.CHANNEL_MONO,
- ),
- stt_stream=audio_data(),
- start_stage=assist_pipeline.PipelineStage.WAKE_WORD,
- wake_word_settings=assist_pipeline.WakeWordSettings(
- audio_seconds_to_buffer=1.5
- ),
- audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await assist_pipeline.async_pipeline_from_audio_stream(
+ hass,
+ context=Context(),
+ event_callback=events.append,
+ stt_metadata=stt.SpeechMetadata(
+ language="",
+ format=stt.AudioFormats.WAV,
+ codec=stt.AudioCodecs.PCM,
+ bit_rate=stt.AudioBitRates.BITRATE_16,
+ sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
+ channel=stt.AudioChannels.CHANNEL_MONO,
+ ),
+ stt_stream=audio_data(),
+ start_stage=assist_pipeline.PipelineStage.WAKE_WORD,
+ wake_word_settings=assist_pipeline.WakeWordSettings(
+ audio_seconds_to_buffer=1.5
+ ),
+ audio_settings=assist_pipeline.AudioSettings(is_vad_enabled=False),
+ )
assert process_events(events) == snapshot
@@ -941,7 +953,7 @@ async def test_sentence_trigger_overrides_conversation_agent(
init_components,
pipeline_data: assist_pipeline.pipeline.PipelineData,
) -> None:
- """Test that sentence triggers are checked before the conversation agent."""
+ """Test that sentence triggers are checked before a non-default conversation agent."""
assert await async_setup_component(
hass,
"automation",
@@ -975,9 +987,16 @@ async def test_sentence_trigger_overrides_conversation_agent(
start_stage=assist_pipeline.PipelineStage.INTENT,
end_stage=assist_pipeline.PipelineStage.INTENT,
event_callback=events.append,
+ intent_agent="test-agent", # not the default agent
),
)
- await pipeline_input.validate()
+
+ # Ensure prepare succeeds
+ with patch(
+ "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info",
+ return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"),
+ ):
+ await pipeline_input.validate()
with patch(
"homeassistant.components.assist_pipeline.pipeline.conversation.async_converse"
diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py
index 9fb02e228d8..5ce3b1020d0 100644
--- a/tests/components/assist_pipeline/test_select.py
+++ b/tests/components/assist_pipeline/test_select.py
@@ -184,7 +184,7 @@ async def test_select_entity_changing_vad_sensitivity(
hass: HomeAssistant,
init_select: MockConfigEntry,
) -> None:
- """Test entity tracking pipeline changes."""
+ """Test entity tracking vad sensitivity changes."""
config_entry = init_select # nicer naming
config_entry.mock_state(hass, ConfigEntryState.LOADED)
@@ -192,7 +192,7 @@ async def test_select_entity_changing_vad_sensitivity(
assert state is not None
assert state.state == VadSensitivity.DEFAULT.value
- # Change select to new pipeline
+ # Change select to new sensitivity
await hass.services.async_call(
"select",
"select_option",
diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py
index c9bc3ef41de..c1caf6f86a4 100644
--- a/tests/components/assist_pipeline/test_websocket.py
+++ b/tests/components/assist_pipeline/test_websocket.py
@@ -119,85 +119,88 @@ async def test_audio_pipeline(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "stt",
- "end_stage": "tts",
- "input": {
- "sample_rate": 44100,
- },
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "stt",
+ "end_stage": "tts",
+ "input": {
+ "sample_rate": 44100,
+ },
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"]
+ # result
+ msg = await client.receive_json()
+ assert msg["success"]
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
+ events.append(msg["event"])
- # stt
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # stt
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # End of audio stream (handler id + empty payload)
- await client.send_bytes(bytes([handler_id]))
+ # End of audio stream (handler id + empty payload)
+ await client.send_bytes(bytes([handler_id]))
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # intent
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # intent
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # text-to-speech
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # text-to-speech
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- pipeline_data: PipelineData = hass.data[DOMAIN]
- pipeline_id = list(pipeline_data.pipeline_debug)[0]
- pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
+ pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_id = list(pipeline_data.pipeline_debug)[0]
+ pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/pipeline_debug/get",
- "pipeline_id": pipeline_id,
- "pipeline_run_id": pipeline_run_id,
- }
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"events": events}
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/pipeline_debug/get",
+ "pipeline_id": pipeline_id,
+ "pipeline_run_id": pipeline_run_id,
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"events": events}
async def test_audio_pipeline_with_wake_word_timeout(
@@ -210,49 +213,52 @@ async def test_audio_pipeline_with_wake_word_timeout(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "wake_word",
- "end_stage": "tts",
- "input": {
- "sample_rate": SAMPLE_RATE,
- "timeout": 1,
- },
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "wake_word",
+ "end_stage": "tts",
+ "input": {
+ "sample_rate": SAMPLE_RATE,
+ "timeout": 1,
+ },
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"], msg
+ # result
+ msg = await client.receive_json()
+ assert msg["success"], msg
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # wake_word
- msg = await client.receive_json()
- assert msg["event"]["type"] == "wake_word-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # wake_word
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "wake_word-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # 2 seconds of silence
- await client.send_bytes(bytes([1]) + bytes(2 * BYTES_ONE_SECOND))
+ # 2 seconds of silence
+ await client.send_bytes(bytes([1]) + bytes(2 * BYTES_ONE_SECOND))
- # Time out error
- msg = await client.receive_json()
- assert msg["event"]["type"] == "error"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # Time out error
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "error"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
async def test_audio_pipeline_with_wake_word_no_timeout(
@@ -265,98 +271,101 @@ async def test_audio_pipeline_with_wake_word_no_timeout(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "wake_word",
- "end_stage": "tts",
- "input": {"sample_rate": SAMPLE_RATE, "timeout": 0, "no_vad": True},
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "wake_word",
+ "end_stage": "tts",
+ "input": {"sample_rate": SAMPLE_RATE, "timeout": 0, "no_vad": True},
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"], msg
-
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
- events.append(msg["event"])
-
- # wake_word
- msg = await client.receive_json()
- assert msg["event"]["type"] == "wake_word-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
-
- # "audio"
- await client.send_bytes(bytes([handler_id]) + make_10ms_chunk(b"wake word"))
-
- async with asyncio.timeout(1):
+ # result
msg = await client.receive_json()
- assert msg["event"]["type"] == "wake_word-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ assert msg["success"], msg
- # stt
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
+ events.append(msg["event"])
- # End of audio stream (handler id + empty payload)
- await client.send_bytes(bytes([handler_id]))
+ # wake_word
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "wake_word-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # "audio"
+ await client.send_bytes(bytes([handler_id]) + make_10ms_chunk(b"wake word"))
- # intent
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ async with asyncio.timeout(1):
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "wake_word-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # stt
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # text-to-speech
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # End of audio stream (handler id + empty payload)
+ await client.send_bytes(bytes([handler_id]))
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # intent
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- pipeline_data: PipelineData = hass.data[DOMAIN]
- pipeline_id = list(pipeline_data.pipeline_debug)[0]
- pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/pipeline_debug/get",
- "pipeline_id": pipeline_id,
- "pipeline_run_id": pipeline_run_id,
- }
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"events": events}
+ # text-to-speech
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
+
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
+
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
+
+ pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_id = list(pipeline_data.pipeline_debug)[0]
+ pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
+
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/pipeline_debug/get",
+ "pipeline_id": pipeline_id,
+ "pipeline_run_id": pipeline_run_id,
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"events": events}
async def test_audio_pipeline_no_wake_word_engine(
@@ -1540,99 +1549,102 @@ async def test_audio_pipeline_debug(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "stt",
- "end_stage": "tts",
- "input": {
- "sample_rate": 44100,
- },
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "stt",
+ "end_stage": "tts",
+ "input": {
+ "sample_rate": 44100,
+ },
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"]
+ # result
+ msg = await client.receive_json()
+ assert msg["success"]
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
+ events.append(msg["event"])
- # stt
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # stt
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # End of audio stream (handler id + empty payload)
- await client.send_bytes(bytes([handler_id]))
+ # End of audio stream (handler id + empty payload)
+ await client.send_bytes(bytes([handler_id]))
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # intent
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # intent
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # text-to-speech
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # text-to-speech
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # Get the id of the pipeline
- await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"})
- msg = await client.receive_json()
- assert msg["success"]
- assert len(msg["result"]["pipelines"]) == 1
+ # Get the id of the pipeline
+ await client.send_json_auto_id({"type": "assist_pipeline/pipeline/list"})
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert len(msg["result"]["pipelines"]) == 1
- pipeline_id = msg["result"]["pipelines"][0]["id"]
+ pipeline_id = msg["result"]["pipelines"][0]["id"]
- # Get the id for the run
- await client.send_json_auto_id(
- {"type": "assist_pipeline/pipeline_debug/list", "pipeline_id": pipeline_id}
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"pipeline_runs": [ANY]}
+ # Get the id for the run
+ await client.send_json_auto_id(
+ {"type": "assist_pipeline/pipeline_debug/list", "pipeline_id": pipeline_id}
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"pipeline_runs": [ANY]}
- pipeline_run_id = msg["result"]["pipeline_runs"][0]["pipeline_run_id"]
+ pipeline_run_id = msg["result"]["pipeline_runs"][0]["pipeline_run_id"]
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/pipeline_debug/get",
- "pipeline_id": pipeline_id,
- "pipeline_run_id": pipeline_run_id,
- }
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"events": events}
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/pipeline_debug/get",
+ "pipeline_id": pipeline_id,
+ "pipeline_run_id": pipeline_run_id,
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"events": events}
async def test_pipeline_debug_list_runs_wrong_pipeline(
@@ -1787,94 +1799,97 @@ async def test_audio_pipeline_with_enhancements(
events = []
client = await hass_ws_client(hass)
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/run",
- "start_stage": "stt",
- "end_stage": "tts",
- "input": {
- "sample_rate": SAMPLE_RATE,
- # Enhancements
- "noise_suppression_level": 2,
- "auto_gain_dbfs": 15,
- "volume_multiplier": 2.0,
- },
- }
- )
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/run",
+ "start_stage": "stt",
+ "end_stage": "tts",
+ "input": {
+ "sample_rate": SAMPLE_RATE,
+ # Enhancements
+ "noise_suppression_level": 2,
+ "auto_gain_dbfs": 15,
+ "volume_multiplier": 2.0,
+ },
+ }
+ )
- # result
- msg = await client.receive_json()
- assert msg["success"]
+ # result
+ msg = await client.receive_json()
+ assert msg["success"]
- # run start
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-start"
- msg["event"]["data"]["pipeline"] = ANY
- assert msg["event"]["data"] == snapshot
- handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
- events.append(msg["event"])
+ # run start
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-start"
+ msg["event"]["data"]["pipeline"] = ANY
+ assert msg["event"]["data"] == snapshot
+ handler_id = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"]
+ events.append(msg["event"])
- # stt
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # stt
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # One second of silence.
- # This will pass through the audio enhancement pipeline, but we don't test
- # the actual output.
- await client.send_bytes(bytes([handler_id]) + bytes(BYTES_ONE_SECOND))
+ # One second of silence.
+ # This will pass through the audio enhancement pipeline, but we don't test
+ # the actual output.
+ await client.send_bytes(bytes([handler_id]) + bytes(BYTES_ONE_SECOND))
- # End of audio stream (handler id + empty payload)
- await client.send_bytes(bytes([handler_id]))
+ # End of audio stream (handler id + empty payload)
+ await client.send_bytes(bytes([handler_id]))
- msg = await client.receive_json()
- assert msg["event"]["type"] == "stt-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "stt-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # intent
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # intent
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "intent-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "intent-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # text-to-speech
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-start"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # text-to-speech
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-start"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- msg = await client.receive_json()
- assert msg["event"]["type"] == "tts-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "tts-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- # run end
- msg = await client.receive_json()
- assert msg["event"]["type"] == "run-end"
- assert msg["event"]["data"] == snapshot
- events.append(msg["event"])
+ # run end
+ msg = await client.receive_json()
+ assert msg["event"]["type"] == "run-end"
+ assert msg["event"]["data"] == snapshot
+ events.append(msg["event"])
- pipeline_data: PipelineData = hass.data[DOMAIN]
- pipeline_id = list(pipeline_data.pipeline_debug)[0]
- pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
+ pipeline_data: PipelineData = hass.data[DOMAIN]
+ pipeline_id = list(pipeline_data.pipeline_debug)[0]
+ pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0]
- await client.send_json_auto_id(
- {
- "type": "assist_pipeline/pipeline_debug/get",
- "pipeline_id": pipeline_id,
- "pipeline_run_id": pipeline_run_id,
- }
- )
- msg = await client.receive_json()
- assert msg["success"]
- assert msg["result"] == {"events": events}
+ await client.send_json_auto_id(
+ {
+ "type": "assist_pipeline/pipeline_debug/get",
+ "pipeline_id": pipeline_id,
+ "pipeline_run_id": pipeline_run_id,
+ }
+ )
+ msg = await client.receive_json()
+ assert msg["success"]
+ assert msg["result"] == {"events": events}
async def test_wake_word_cooldown_same_id(
diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py
index 1b8c98e299c..eb177a35cfb 100644
--- a/tests/components/august/test_lock.py
+++ b/tests/components/august/test_lock.py
@@ -20,8 +20,9 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import ServiceNotSupported
from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from .mocks import (
@@ -453,8 +454,9 @@ async def test_open_throws_hass_service_not_supported_error(
hass: HomeAssistant,
) -> None:
"""Test open throws correct error on entity does not support this service error."""
+ await async_setup_component(hass, "homeassistant", {})
mocked_lock_detail = await _mock_operative_august_lock_detail(hass)
await _create_august_with_devices(hass, [mocked_lock_detail])
data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"}
- with pytest.raises(HomeAssistantError, match="does not support this service"):
+ with pytest.raises(ServiceNotSupported, match="does not support action"):
await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True)
diff --git a/tests/components/autarco/test_config_flow.py b/tests/components/autarco/test_config_flow.py
index 621ad7f55c8..47c6a2fb084 100644
--- a/tests/components/autarco/test_config_flow.py
+++ b/tests/components/autarco/test_config_flow.py
@@ -1,6 +1,6 @@
"""Test the Autarco config flow."""
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, patch
from autarco import AutarcoAuthenticationError, AutarcoConnectionError
import pytest
@@ -92,6 +92,7 @@ async def test_exceptions(
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"base": error}
+ # Recover from error
mock_autarco_client.get_account.side_effect = None
result = await hass.config_entries.flow.async_configure(
@@ -99,3 +100,72 @@ async def test_exceptions(
user_input={CONF_EMAIL: "test@autarco.com", CONF_PASSWORD: "test-password"},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
+
+
+async def test_step_reauth(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test reauth flow."""
+ mock_config_entry.add_to_hass(hass)
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("step_id") == "reauth_confirm"
+
+ with patch("homeassistant.components.autarco.config_flow.Autarco", autospec=True):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_PASSWORD: "new-password"},
+ )
+
+ assert result.get("type") is FlowResultType.ABORT
+ assert result.get("reason") == "reauth_successful"
+
+ assert len(hass.config_entries.async_entries()) == 1
+ assert mock_config_entry.data[CONF_PASSWORD] == "new-password"
+
+
+@pytest.mark.parametrize(
+ ("exception", "error"),
+ [
+ (AutarcoConnectionError, "cannot_connect"),
+ (AutarcoAuthenticationError, "invalid_auth"),
+ ],
+)
+async def test_step_reauth_exceptions(
+ hass: HomeAssistant,
+ mock_autarco_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ mock_setup_entry: AsyncMock,
+ exception: Exception,
+ error: str,
+) -> None:
+ """Test exceptions in reauth flow."""
+ mock_autarco_client.get_account.side_effect = exception
+ mock_config_entry.add_to_hass(hass)
+ result = await mock_config_entry.start_reauth_flow(hass)
+
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("step_id") == "reauth_confirm"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_PASSWORD: "new-password"},
+ )
+ assert result.get("type") is FlowResultType.FORM
+ assert result.get("errors") == {"base": error}
+
+ # Recover from error
+ mock_autarco_client.get_account.side_effect = None
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={CONF_PASSWORD: "new-password"},
+ )
+ assert result.get("type") is FlowResultType.ABORT
+ assert result.get("reason") == "reauth_successful"
+
+ assert len(hass.config_entries.async_entries()) == 1
+ assert mock_config_entry.data[CONF_PASSWORD] == "new-password"
diff --git a/tests/components/autarco/test_init.py b/tests/components/autarco/test_init.py
index 81c5f947251..2707c53d35f 100644
--- a/tests/components/autarco/test_init.py
+++ b/tests/components/autarco/test_init.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from unittest.mock import AsyncMock
+from autarco import AutarcoAuthenticationError
+
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -26,3 +28,20 @@ async def test_load_unload_entry(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
+
+
+async def test_setup_entry_exception(
+ hass: HomeAssistant,
+ mock_autarco_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test ConfigEntryNotReady when API raises an exception during entry setup."""
+ mock_config_entry.add_to_hass(hass)
+ mock_autarco_client.get_site.side_effect = AutarcoAuthenticationError
+
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
+
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+ assert flows[0]["step_id"] == "reauth_confirm"
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index 2bdc0f7516b..98d8bf0396e 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -50,7 +50,6 @@ from homeassistant.helpers.script import (
SCRIPT_MODE_SINGLE,
_async_stop_scripts_at_shutdown,
)
-from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo
from homeassistant.setup import async_setup_component
from homeassistant.util import yaml
import homeassistant.util.dt as dt_util
@@ -62,8 +61,6 @@ from tests.common import (
async_capture_events,
async_fire_time_changed,
async_mock_service,
- help_test_all,
- import_and_test_deprecated_constant,
mock_restore_cache,
)
from tests.components.logbook.common import MockRow, mock_humanify
@@ -3153,30 +3150,6 @@ async def test_websocket_config(
assert msg["error"]["code"] == "not_found"
-def test_all() -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(automation)
-
-
-@pytest.mark.parametrize(
- ("constant_name", "replacement"),
- [
- ("AutomationActionType", TriggerActionType),
- ("AutomationTriggerData", TriggerData),
- ("AutomationTriggerInfo", TriggerInfo),
- ],
-)
-def test_deprecated_constants(
- caplog: pytest.LogCaptureFixture,
- constant_name: str,
- replacement: Any,
-) -> None:
- """Test deprecated automation constants."""
- import_and_test_deprecated_constant(
- caplog, automation, constant_name, replacement.__name__, replacement, "2025.1"
- )
-
-
async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> None:
"""Test an automation that turns off another automation."""
hass.set_state(CoreState.not_running)
diff --git a/tests/components/azure_data_explorer/test_config_flow.py b/tests/components/azure_data_explorer/test_config_flow.py
index a700299be33..13ff6a8bb13 100644
--- a/tests/components/azure_data_explorer/test_config_flow.py
+++ b/tests/components/azure_data_explorer/test_config_flow.py
@@ -25,7 +25,7 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) ->
BASE_CONFIG.copy(),
)
- assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result2["title"] == "cluster.region.kusto.windows.net"
mock_setup_entry.assert_called_once()
@@ -59,12 +59,12 @@ async def test_config_flow_errors(
result["flow_id"],
BASE_CONFIG.copy(),
)
- assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["type"] == data_entry_flow.FlowResultType.FORM
assert result2["errors"] == {"base": expected}
await hass.async_block_till_done()
- assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["type"] == data_entry_flow.FlowResultType.FORM
# Retest error handling if error is corrected and connection is successful
@@ -77,4 +77,4 @@ async def test_config_flow_errors(
await hass.async_block_till_done()
- assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py
deleted file mode 100644
index 631c774e63c..00000000000
--- a/tests/components/backup/conftest.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""Test fixtures for the Backup integration."""
-
-from __future__ import annotations
-
-from collections.abc import Generator
-from pathlib import Path
-from unittest.mock import MagicMock, Mock, patch
-
-import pytest
-
-from homeassistant.core import HomeAssistant
-
-
-@pytest.fixture(name="mocked_json_bytes")
-def mocked_json_bytes_fixture() -> Generator[Mock]:
- """Mock json_bytes."""
- with patch(
- "homeassistant.components.backup.manager.json_bytes",
- return_value=b"{}", # Empty JSON
- ) as mocked_json_bytes:
- yield mocked_json_bytes
-
-
-@pytest.fixture(name="mocked_tarfile")
-def mocked_tarfile_fixture() -> Generator[Mock]:
- """Mock tarfile."""
- with patch(
- "homeassistant.components.backup.manager.SecureTarFile"
- ) as mocked_tarfile:
- yield mocked_tarfile
-
-
-@pytest.fixture(name="mock_backup_generation")
-def mock_backup_generation_fixture(
- hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
-) -> Generator[None]:
- """Mock backup generator."""
-
- def _mock_iterdir(path: Path) -> list[Path]:
- if not path.name.endswith("testing_config"):
- return []
- return [
- Path("test.txt"),
- Path(".DS_Store"),
- Path(".storage"),
- ]
-
- with (
- patch("pathlib.Path.iterdir", _mock_iterdir),
- patch("pathlib.Path.stat", MagicMock(st_size=123)),
- patch("pathlib.Path.is_file", lambda x: x.name != ".storage"),
- patch(
- "pathlib.Path.is_dir",
- lambda x: x.name == ".storage",
- ),
- patch(
- "pathlib.Path.exists",
- lambda x: x != Path(hass.config.path("backups")),
- ),
- patch(
- "pathlib.Path.is_symlink",
- lambda _: False,
- ),
- patch(
- "pathlib.Path.mkdir",
- MagicMock(),
- ),
- patch(
- "homeassistant.components.backup.manager.HAVERSION",
- "2025.1.0",
- ),
- ):
- yield
diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr
index 42eb524e529..096df37d704 100644
--- a/tests/components/backup/snapshots/test_websocket.ambr
+++ b/tests/components/backup/snapshots/test_websocket.ambr
@@ -210,23 +210,16 @@
dict({
'id': 1,
'result': dict({
- 'slug': '27f5c632',
+ 'date': '1970-01-01T00:00:00.000Z',
+ 'name': 'Test',
+ 'path': 'abc123.tar',
+ 'size': 0.0,
+ 'slug': 'abc123',
}),
'success': True,
'type': 'result',
})
# ---
-# name: test_generate[without_hassio].1
- dict({
- 'event': dict({
- 'done': True,
- 'stage': None,
- 'success': True,
- }),
- 'id': 1,
- 'type': 'event',
- })
-# ---
# name: test_info[with_hassio]
dict({
'error': dict({
diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py
index 9d24964aedf..a3f70267643 100644
--- a/tests/components/backup/test_manager.py
+++ b/tests/components/backup/test_manager.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-import asyncio
+from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
import aiohttp
@@ -10,10 +10,7 @@ from multidict import CIMultiDict, CIMultiDictProxy
import pytest
from homeassistant.components.backup import BackupManager
-from homeassistant.components.backup.manager import (
- BackupPlatformProtocol,
- BackupProgress,
-)
+from homeassistant.components.backup.manager import BackupPlatformProtocol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
@@ -23,30 +20,59 @@ from .common import TEST_BACKUP
from tests.common import MockPlatform, mock_platform
-async def _mock_backup_generation(
- manager: BackupManager, mocked_json_bytes: Mock, mocked_tarfile: Mock
-) -> None:
+async def _mock_backup_generation(manager: BackupManager):
"""Mock backup generator."""
- progress: list[BackupProgress] = []
+ def _mock_iterdir(path: Path) -> list[Path]:
+ if not path.name.endswith("testing_config"):
+ return []
+ return [
+ Path("test.txt"),
+ Path(".DS_Store"),
+ Path(".storage"),
+ ]
- def on_progress(_progress: BackupProgress) -> None:
- """Mock progress callback."""
- progress.append(_progress)
+ with (
+ patch(
+ "homeassistant.components.backup.manager.SecureTarFile"
+ ) as mocked_tarfile,
+ patch("pathlib.Path.iterdir", _mock_iterdir),
+ patch("pathlib.Path.stat", MagicMock(st_size=123)),
+ patch("pathlib.Path.is_file", lambda x: x.name != ".storage"),
+ patch(
+ "pathlib.Path.is_dir",
+ lambda x: x.name == ".storage",
+ ),
+ patch(
+ "pathlib.Path.exists",
+ lambda x: x != manager.backup_dir,
+ ),
+ patch(
+ "pathlib.Path.is_symlink",
+ lambda _: False,
+ ),
+ patch(
+ "pathlib.Path.mkdir",
+ MagicMock(),
+ ),
+ patch(
+ "homeassistant.components.backup.manager.json_bytes",
+ return_value=b"{}", # Empty JSON
+ ) as mocked_json_bytes,
+ patch(
+ "homeassistant.components.backup.manager.HAVERSION",
+ "2025.1.0",
+ ),
+ ):
+ await manager.async_create_backup()
- assert manager.backup_task is None
- await manager.async_create_backup(on_progress=on_progress)
- assert manager.backup_task is not None
- assert progress == []
-
- await manager.backup_task
- assert progress == [BackupProgress(done=True, stage=None, success=True)]
-
- assert mocked_json_bytes.call_count == 1
- backup_json_dict = mocked_json_bytes.call_args[0][0]
- assert isinstance(backup_json_dict, dict)
- assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"}
- assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0])
+ assert mocked_json_bytes.call_count == 1
+ backup_json_dict = mocked_json_bytes.call_args[0][0]
+ assert isinstance(backup_json_dict, dict)
+ assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"}
+ assert manager.backup_dir.as_posix() in str(
+ mocked_tarfile.call_args_list[0][0][0]
+ )
async def _setup_mock_domain(
@@ -150,26 +176,21 @@ async def test_getting_backup_that_does_not_exist(
async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
"""Test generate backup."""
- event = asyncio.Event()
manager = BackupManager(hass)
- manager.backup_task = hass.async_create_task(event.wait())
+ manager.backing_up = True
with pytest.raises(HomeAssistantError, match="Backup already in progress"):
- await manager.async_create_backup(on_progress=None)
- event.set()
+ await manager.async_create_backup()
-@pytest.mark.usefixtures("mock_backup_generation")
async def test_async_create_backup(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
- mocked_json_bytes: Mock,
- mocked_tarfile: Mock,
) -> None:
"""Test generate backup."""
manager = BackupManager(hass)
manager.loaded_backups = True
- await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
+ await _mock_backup_generation(manager)
assert "Generated new backup with slug " in caplog.text
assert "Creating backup directory" in caplog.text
@@ -226,9 +247,7 @@ async def test_not_loading_bad_platforms(
)
-async def test_exception_plaform_pre(
- hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
-) -> None:
+async def test_exception_plaform_pre(hass: HomeAssistant) -> None:
"""Test exception in pre step."""
manager = BackupManager(hass)
manager.loaded_backups = True
@@ -245,12 +264,10 @@ async def test_exception_plaform_pre(
)
with pytest.raises(HomeAssistantError):
- await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
+ await _mock_backup_generation(manager)
-async def test_exception_plaform_post(
- hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
-) -> None:
+async def test_exception_plaform_post(hass: HomeAssistant) -> None:
"""Test exception in post step."""
manager = BackupManager(hass)
manager.loaded_backups = True
@@ -267,7 +284,7 @@ async def test_exception_plaform_post(
)
with pytest.raises(HomeAssistantError):
- await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
+ await _mock_backup_generation(manager)
async def test_loading_platforms_when_running_async_pre_backup_actions(
diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py
index 3e031f172ae..125ba8adaad 100644
--- a/tests/components/backup/test_websocket.py
+++ b/tests/components/backup/test_websocket.py
@@ -2,7 +2,6 @@
from unittest.mock import patch
-from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
@@ -116,30 +115,29 @@ async def test_remove(
@pytest.mark.parametrize(
- ("with_hassio", "number_of_messages"),
+ "with_hassio",
[
- pytest.param(True, 1, id="with_hassio"),
- pytest.param(False, 2, id="without_hassio"),
+ pytest.param(True, id="with_hassio"),
+ pytest.param(False, id="without_hassio"),
],
)
-@pytest.mark.usefixtures("mock_backup_generation")
async def test_generate(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
- freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
with_hassio: bool,
- number_of_messages: int,
) -> None:
"""Test generating a backup."""
await setup_backup_integration(hass, with_hassio=with_hassio)
client = await hass_ws_client(hass)
- freezer.move_to("2024-11-13 12:01:00+01:00")
await hass.async_block_till_done()
- await client.send_json_auto_id({"type": "backup/generate"})
- for _ in range(number_of_messages):
+ with patch(
+ "homeassistant.components.backup.manager.BackupManager.async_create_backup",
+ return_value=TEST_BACKUP,
+ ):
+ await client.send_json_auto_id({"type": "backup/generate"})
assert snapshot == await client.receive_json()
diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
new file mode 100644
index 00000000000..e9540b5cec6
--- /dev/null
+++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
@@ -0,0 +1,67 @@
+# serializer version: 1
+# name: test_async_get_config_entry_diagnostics
+ dict({
+ 'config_entry': dict({
+ 'data': dict({
+ 'host': '192.168.0.1',
+ 'jid': '1111.1111111.11111111@products.bang-olufsen.com',
+ 'model': 'Beosound Balance',
+ 'name': 'Beosound Balance-11111111',
+ }),
+ 'disabled_by': None,
+ 'discovery_keys': dict({
+ }),
+ 'domain': 'bang_olufsen',
+ 'minor_version': 1,
+ 'options': dict({
+ }),
+ 'pref_disable_new_entities': False,
+ 'pref_disable_polling': False,
+ 'source': 'user',
+ 'title': 'Beosound Balance-11111111',
+ 'unique_id': '11111111',
+ 'version': 1,
+ }),
+ 'media_player': dict({
+ 'attributes': dict({
+ 'beolink': dict({
+ 'listeners': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'peers': dict({
+ 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com',
+ 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com',
+ }),
+ 'self': dict({
+ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com',
+ }),
+ }),
+ 'device_class': 'speaker',
+ 'entity_picture_local': None,
+ 'friendly_name': 'Living room Balance',
+ 'group_members': list([
+ 'media_player.beosound_balance_11111111',
+ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
+ 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
+ ]),
+ 'media_content_type': 'music',
+ 'sound_mode': 'Test Listening Mode (123)',
+ 'sound_mode_list': list([
+ 'Test Listening Mode (123)',
+ 'Test Listening Mode (234)',
+ 'Test Listening Mode 2 (345)',
+ ]),
+ 'source_list': list([
+ 'Tidal',
+ 'Line-In',
+ 'HDMI A',
+ ]),
+ 'supported_features': 2095933,
+ }),
+ 'entity_id': 'media_player.beosound_balance_11111111',
+ 'state': 'playing',
+ }),
+ 'websocket_connected': False,
+ })
+# ---
diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr
index ea96e286821..36fcc72aa22 100644
--- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr
+++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr
@@ -23,7 +23,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -72,7 +71,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -122,7 +120,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -172,7 +169,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -222,7 +218,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -272,7 +267,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -321,7 +315,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -370,7 +363,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -420,7 +412,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -467,7 +458,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -517,7 +507,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -564,7 +553,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'media_position': 0,
'sound_mode': 'Test Listening Mode (123)',
@@ -613,7 +601,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -660,7 +647,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -708,7 +694,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -755,7 +740,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'repeat': ,
'shuffle': False,
@@ -802,7 +786,6 @@
'media_player.beosound_balance_22222222',
'media_player.beosound_balance_11111111',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
@@ -849,7 +832,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
- 'icon': 'mdi:speaker-wireless',
'media_content_type': ,
'sound_mode': 'Test Listening Mode (123)',
'sound_mode_list': list([
diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py
new file mode 100644
index 00000000000..7c99648ace4
--- /dev/null
+++ b/tests/components/bang_olufsen/test_diagnostics.py
@@ -0,0 +1,41 @@
+"""Test bang_olufsen config entry diagnostics."""
+
+from unittest.mock import AsyncMock
+
+from syrupy import SnapshotAssertion
+from syrupy.filters import props
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+from tests.components.diagnostics import get_diagnostics_for_config_entry
+from tests.typing import ClientSessionGenerator
+
+
+async def test_async_get_config_entry_diagnostics(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ mock_config_entry: MockConfigEntry,
+ mock_mozart_client: AsyncMock,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test config entry diagnostics."""
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+
+ result = await get_diagnostics_for_config_entry(
+ hass, hass_client, mock_config_entry
+ )
+
+ assert result == snapshot(
+ exclude=props(
+ "created_at",
+ "entry_id",
+ "id",
+ "last_changed",
+ "last_reported",
+ "last_updated",
+ "media_position_updated_at",
+ "modified_at",
+ )
+ )
diff --git a/tests/components/bang_olufsen/test_websocket.py b/tests/components/bang_olufsen/test_websocket.py
index b17859a4f4e..ecf5b2d011e 100644
--- a/tests/components/bang_olufsen/test_websocket.py
+++ b/tests/components/bang_olufsen/test_websocket.py
@@ -135,7 +135,6 @@ async def test_on_all_notifications_raw(
},
"eventType": "WebSocketEventVolume",
}
- raw_notification_full = raw_notification
# Get device ID for the modified notification that is sent as an event and in the log
assert mock_config_entry.unique_id
@@ -144,12 +143,11 @@ async def test_on_all_notifications_raw(
identifiers={(DOMAIN, mock_config_entry.unique_id)}
)
)
- raw_notification_full.update(
- {
- "device_id": device.id,
- "serial_number": mock_config_entry.unique_id,
- }
- )
+ raw_notification_full = {
+ "device_id": device.id,
+ "serial_number": int(mock_config_entry.unique_id),
+ **raw_notification,
+ }
caplog.set_level(logging.DEBUG)
diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py
index ea0ad05a0db..26b8d919d72 100644
--- a/tests/components/binary_sensor/test_init.py
+++ b/tests/components/binary_sensor/test_init.py
@@ -17,8 +17,6 @@ from tests.common import (
MockConfigEntry,
MockModule,
MockPlatform,
- help_test_all,
- import_and_test_deprecated_constant_enum,
mock_config_flow,
mock_integration,
mock_platform,
@@ -198,22 +196,3 @@ async def test_entity_category_config_raises_error(
"Entity binary_sensor.test2 cannot be added as the entity category is set to config"
in caplog.text
)
-
-
-def test_all() -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(binary_sensor)
-
-
-@pytest.mark.parametrize(
- "device_class",
- list(binary_sensor.BinarySensorDeviceClass),
-)
-def test_deprecated_constant_device_class(
- caplog: pytest.LogCaptureFixture,
- device_class: binary_sensor.BinarySensorDeviceClass,
-) -> None:
- """Test deprecated binary sensor device classes."""
- import_and_test_deprecated_constant_enum(
- caplog, binary_sensor, device_class, "DEVICE_CLASS_", "2025.1"
- )
diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py
index c89ab65ea1d..ec1a8b95e0d 100644
--- a/tests/components/blink/test_config_flow.py
+++ b/tests/components/blink/test_config_flow.py
@@ -55,6 +55,35 @@ async def test_form(hass: HomeAssistant) -> None:
}
assert len(mock_setup_entry.mock_calls) == 1
+ # Now check for duplicates
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+
+ with (
+ patch("homeassistant.components.blink.config_flow.Auth.startup"),
+ patch(
+ "homeassistant.components.blink.config_flow.Auth.check_key_required",
+ return_value=False,
+ ),
+ patch(
+ "homeassistant.components.blink.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"username": "blink@example.com", "password": "example"},
+ )
+ await hass.async_block_till_done()
+
+ assert result2["type"] is FlowResultType.ABORT
+ assert result2["reason"] == "already_configured"
+
+ assert len(mock_setup_entry.mock_calls) == 0
+
async def test_form_2fa(hass: HomeAssistant) -> None:
"""Test we get the 2fa form."""
diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py
index 0bf615de3da..217225628f2 100644
--- a/tests/components/bluesound/test_media_player.py
+++ b/tests/components/bluesound/test_media_player.py
@@ -325,17 +325,17 @@ async def test_attr_bluesound_group(
setup_config_entry_secondary: None,
player_mocks: PlayerMocks,
) -> None:
- """Test the media player grouping."""
+ """Test the media player grouping for leader."""
attr_bluesound_group = hass.states.get(
"media_player.player_name1111"
).attributes.get("bluesound_group")
assert attr_bluesound_group is None
- updated_status = dataclasses.replace(
- player_mocks.player_data.status_long_polling_mock.get(),
- group_name="player-name1111+player-name2222",
+ updated_sync_status = dataclasses.replace(
+ player_mocks.player_data.sync_status_long_polling_mock.get(),
+ slaves=[PairedPlayer("2.2.2.2", 11000)],
)
- player_mocks.player_data.status_long_polling_mock.set(updated_status)
+ player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
# give the long polling loop a chance to update the state; this could be any async call
await hass.async_block_till_done()
@@ -347,6 +347,45 @@ async def test_attr_bluesound_group(
assert attr_bluesound_group == ["player-name1111", "player-name2222"]
+async def test_attr_bluesound_group_for_follower(
+ hass: HomeAssistant,
+ setup_config_entry: None,
+ setup_config_entry_secondary: None,
+ player_mocks: PlayerMocks,
+) -> None:
+ """Test the media player grouping for follower."""
+ attr_bluesound_group = hass.states.get(
+ "media_player.player_name2222"
+ ).attributes.get("bluesound_group")
+ assert attr_bluesound_group is None
+
+ updated_sync_status = dataclasses.replace(
+ player_mocks.player_data.sync_status_long_polling_mock.get(),
+ slaves=[PairedPlayer("2.2.2.2", 11000)],
+ )
+ player_mocks.player_data.sync_status_long_polling_mock.set(updated_sync_status)
+
+ # give the long polling loop a chance to update the state; this could be any async call
+ await hass.async_block_till_done()
+
+ updated_sync_status = dataclasses.replace(
+ player_mocks.player_data_secondary.sync_status_long_polling_mock.get(),
+ master=PairedPlayer("1.1.1.1", 11000),
+ )
+ player_mocks.player_data_secondary.sync_status_long_polling_mock.set(
+ updated_sync_status
+ )
+
+ # give the long polling loop a chance to update the state; this could be any async call
+ await hass.async_block_till_done()
+
+ attr_bluesound_group = hass.states.get(
+ "media_player.player_name2222"
+ ).attributes.get("bluesound_group")
+
+ assert attr_bluesound_group == ["player-name1111", "player-name2222"]
+
+
async def test_volume_up_from_6_to_7(
hass: HomeAssistant,
setup_config_entry: None,
diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py
index 4d280a1d0e5..f490b854749 100644
--- a/tests/components/bmw_connected_drive/__init__.py
+++ b/tests/components/bmw_connected_drive/__init__.py
@@ -9,6 +9,7 @@ import respx
from homeassistant import config_entries
from homeassistant.components.bmw_connected_drive.const import (
+ CONF_CAPTCHA_TOKEN,
CONF_GCID,
CONF_READ_ONLY,
CONF_REFRESH_TOKEN,
@@ -24,8 +25,12 @@ FIXTURE_USER_INPUT = {
CONF_PASSWORD: "p4ssw0rd",
CONF_REGION: "rest_of_world",
}
-FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN"
-FIXTURE_GCID = "SOME_GCID"
+FIXTURE_CAPTCHA_INPUT = {
+ CONF_CAPTCHA_TOKEN: "captcha_token",
+}
+FIXTURE_USER_INPUT_W_CAPTCHA = FIXTURE_USER_INPUT | FIXTURE_CAPTCHA_INPUT
+FIXTURE_REFRESH_TOKEN = "another_token_string"
+FIXTURE_GCID = "DUMMY"
FIXTURE_CONFIG_ENTRY = {
"entry_id": "1",
diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
index 81ef1220069..b87da22a332 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
@@ -4833,7 +4833,7 @@
}),
]),
'info': dict({
- 'gcid': 'SOME_GCID',
+ 'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
@@ -7202,7 +7202,7 @@
}),
]),
'info': dict({
- 'gcid': 'SOME_GCID',
+ 'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
@@ -8925,7 +8925,7 @@
}),
]),
'info': dict({
- 'gcid': 'SOME_GCID',
+ 'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py
index f57f1a304ac..8fa9d9be22b 100644
--- a/tests/components/bmw_connected_drive/test_config_flow.py
+++ b/tests/components/bmw_connected_drive/test_config_flow.py
@@ -4,17 +4,14 @@ from copy import deepcopy
from unittest.mock import patch
from bimmer_connected.api.authentication import MyBMWAuthentication
-from bimmer_connected.models import (
- MyBMWAPIError,
- MyBMWAuthError,
- MyBMWCaptchaMissingError,
-)
+from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
from httpx import RequestError
import pytest
from homeassistant import config_entries
from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN
from homeassistant.components.bmw_connected_drive.const import (
+ CONF_CAPTCHA_TOKEN,
CONF_READ_ONLY,
CONF_REFRESH_TOKEN,
)
@@ -23,10 +20,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
+ FIXTURE_CAPTCHA_INPUT,
FIXTURE_CONFIG_ENTRY,
FIXTURE_GCID,
FIXTURE_REFRESH_TOKEN,
FIXTURE_USER_INPUT,
+ FIXTURE_USER_INPUT_W_CAPTCHA,
)
from tests.common import MockConfigEntry
@@ -61,7 +60,7 @@ async def test_authentication_error(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
- data=FIXTURE_USER_INPUT,
+ data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
)
assert result["type"] is FlowResultType.FORM
@@ -79,7 +78,7 @@ async def test_connection_error(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
- data=FIXTURE_USER_INPUT,
+ data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
)
assert result["type"] is FlowResultType.FORM
@@ -97,7 +96,7 @@ async def test_api_error(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
- data=deepcopy(FIXTURE_USER_INPUT),
+ data=deepcopy(FIXTURE_USER_INPUT_W_CAPTCHA),
)
assert result["type"] is FlowResultType.FORM
@@ -105,6 +104,28 @@ async def test_api_error(hass: HomeAssistant) -> None:
assert result["errors"] == {"base": "cannot_connect"}
+@pytest.mark.usefixtures("bmw_fixture")
+async def test_captcha_flow_missing_error(hass: HomeAssistant) -> None:
+ """Test the external flow with captcha failing once and succeeding the second time."""
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=deepcopy(FIXTURE_USER_INPUT),
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "captcha"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_CAPTCHA_TOKEN: " "}
+ )
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "missing_captcha"}
+
+
async def test_full_user_flow_implementation(hass: HomeAssistant) -> None:
"""Test registering an integration and finishing flow works."""
with (
@@ -118,14 +139,22 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None:
return_value=True,
) as mock_setup_entry,
):
- result2 = await hass.config_entries.flow.async_init(
+ result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
data=deepcopy(FIXTURE_USER_INPUT),
)
- assert result2["type"] is FlowResultType.CREATE_ENTRY
- assert result2["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME]
- assert result2["data"] == FIXTURE_COMPLETE_ENTRY
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "captcha"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], FIXTURE_CAPTCHA_INPUT
+ )
+
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == FIXTURE_COMPLETE_ENTRY[CONF_USERNAME]
+ assert result["data"] == FIXTURE_COMPLETE_ENTRY
assert len(mock_setup_entry.mock_calls) == 1
@@ -206,13 +235,20 @@ async def test_reauth(hass: HomeAssistant) -> None:
assert suggested_values[CONF_PASSWORD] == wrong_password
assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION]
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], deepcopy(FIXTURE_USER_INPUT)
)
await hass.async_block_till_done()
- assert result2["type"] is FlowResultType.ABORT
- assert result2["reason"] == "reauth_successful"
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "captcha"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], FIXTURE_CAPTCHA_INPUT
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reauth_successful"
assert config_entry.data == FIXTURE_COMPLETE_ENTRY
assert len(mock_setup_entry.mock_calls) == 2
@@ -243,13 +279,13 @@ async def test_reauth_unique_id_abort(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
assert result["errors"] == {}
- result2 = await hass.config_entries.flow.async_configure(
+ result = await hass.config_entries.flow.async_configure(
result["flow_id"], {**FIXTURE_USER_INPUT, CONF_REGION: "north_america"}
)
await hass.async_block_till_done()
- assert result2["type"] is FlowResultType.ABORT
- assert result2["reason"] == "account_mismatch"
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "account_mismatch"
assert config_entry.data == config_entry_with_wrong_password["data"]
@@ -279,13 +315,20 @@ async def test_reconfigure(hass: HomeAssistant) -> None:
assert suggested_values[CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD]
assert suggested_values[CONF_REGION] == FIXTURE_USER_INPUT[CONF_REGION]
- result2 = await hass.config_entries.flow.async_configure(
+ result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT
)
await hass.async_block_till_done()
- assert result2["type"] is FlowResultType.ABORT
- assert result2["reason"] == "reconfigure_successful"
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "captcha"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], FIXTURE_CAPTCHA_INPUT
+ )
+
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "reconfigure_successful"
assert config_entry.data == FIXTURE_COMPLETE_ENTRY
@@ -307,40 +350,12 @@ async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
assert result["errors"] == {}
- result2 = await hass.config_entries.flow.async_configure(
+ result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{**FIXTURE_USER_INPUT, CONF_USERNAME: "somebody@email.com"},
)
await hass.async_block_till_done()
- assert result2["type"] is FlowResultType.ABORT
- assert result2["reason"] == "account_mismatch"
+ assert result["type"] is FlowResultType.ABORT
+ assert result["reason"] == "account_mismatch"
assert config_entry.data == FIXTURE_COMPLETE_ENTRY
-
-
-@pytest.mark.usefixtures("bmw_fixture")
-async def test_captcha_flow_not_set(hass: HomeAssistant) -> None:
- """Test the external flow with captcha failing once and succeeding the second time."""
-
- TEST_REGION = "north_america"
-
- # Start flow and open form
- # Start flow and open form
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}
- )
- assert result["type"] is FlowResultType.FORM
- assert result["step_id"] == "user"
-
- # Add login data
- with patch(
- "bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na",
- side_effect=MyBMWCaptchaMissingError(
- "Missing hCaptcha token for North America login"
- ),
- ):
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
- )
- assert result["errors"]["base"] == "missing_captcha"
diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr
index a27c5addd61..4de85859461 100644
--- a/tests/components/brother/snapshots/test_sensor.ambr
+++ b/tests/components/brother/snapshots/test_sensor.ambr
@@ -31,7 +31,7 @@
'supported_features': 0,
'translation_key': 'bw_pages',
'unique_id': '0123456789_bw_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_b_w_pages-state]
@@ -39,7 +39,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW B/W pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_b_w_pages',
@@ -131,7 +131,7 @@
'supported_features': 0,
'translation_key': 'black_drum_page_counter',
'unique_id': '0123456789_black_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_black_drum_page_counter-state]
@@ -139,7 +139,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Black drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_black_drum_page_counter',
@@ -231,7 +231,7 @@
'supported_features': 0,
'translation_key': 'black_drum_remaining_pages',
'unique_id': '0123456789_black_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_black_drum_remaining_pages-state]
@@ -239,7 +239,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Black drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_black_drum_remaining_pages',
@@ -331,7 +331,7 @@
'supported_features': 0,
'translation_key': 'color_pages',
'unique_id': '0123456789_color_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_color_pages-state]
@@ -339,7 +339,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Color pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_color_pages',
@@ -381,7 +381,7 @@
'supported_features': 0,
'translation_key': 'cyan_drum_page_counter',
'unique_id': '0123456789_cyan_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_cyan_drum_page_counter-state]
@@ -389,7 +389,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Cyan drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_cyan_drum_page_counter',
@@ -481,7 +481,7 @@
'supported_features': 0,
'translation_key': 'cyan_drum_remaining_pages',
'unique_id': '0123456789_cyan_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_cyan_drum_remaining_pages-state]
@@ -489,7 +489,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Cyan drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_cyan_drum_remaining_pages',
@@ -581,7 +581,7 @@
'supported_features': 0,
'translation_key': 'drum_page_counter',
'unique_id': '0123456789_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_drum_page_counter-state]
@@ -589,7 +589,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_drum_page_counter',
@@ -681,7 +681,7 @@
'supported_features': 0,
'translation_key': 'drum_remaining_pages',
'unique_id': '0123456789_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_drum_remaining_pages-state]
@@ -689,7 +689,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_drum_remaining_pages',
@@ -731,7 +731,7 @@
'supported_features': 0,
'translation_key': 'duplex_unit_page_counter',
'unique_id': '0123456789_duplex_unit_pages_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_duplex_unit_page_counter-state]
@@ -739,7 +739,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Duplex unit page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_duplex_unit_page_counter',
@@ -878,7 +878,7 @@
'supported_features': 0,
'translation_key': 'magenta_drum_page_counter',
'unique_id': '0123456789_magenta_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_magenta_drum_page_counter-state]
@@ -886,7 +886,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Magenta drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_magenta_drum_page_counter',
@@ -978,7 +978,7 @@
'supported_features': 0,
'translation_key': 'magenta_drum_remaining_pages',
'unique_id': '0123456789_magenta_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_magenta_drum_remaining_pages-state]
@@ -986,7 +986,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Magenta drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_magenta_drum_remaining_pages',
@@ -1078,7 +1078,7 @@
'supported_features': 0,
'translation_key': 'page_counter',
'unique_id': '0123456789_page_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_page_counter-state]
@@ -1086,7 +1086,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_page_counter',
@@ -1224,7 +1224,7 @@
'supported_features': 0,
'translation_key': 'yellow_drum_page_counter',
'unique_id': '0123456789_yellow_drum_counter',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_yellow_drum_page_counter-state]
@@ -1232,7 +1232,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Yellow drum page counter',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_yellow_drum_page_counter',
@@ -1324,7 +1324,7 @@
'supported_features': 0,
'translation_key': 'yellow_drum_remaining_pages',
'unique_id': '0123456789_yellow_drum_remaining_pages',
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
})
# ---
# name: test_sensors[sensor.hl_l2340dw_yellow_drum_remaining_pages-state]
@@ -1332,7 +1332,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'HL-L2340DW Yellow drum remaining pages',
'state_class': ,
- 'unit_of_measurement': 'p',
+ 'unit_of_measurement': 'pages',
}),
'context': ,
'entity_id': 'sensor.hl_l2340dw_yellow_drum_remaining_pages',
diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py
index e46cdd75f2d..7d2db2f8b46 100644
--- a/tests/components/bsblan/conftest.py
+++ b/tests/components/bsblan/conftest.py
@@ -3,7 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
-from bsblan import Device, Info, Sensor, State, StaticState
+from bsblan import Device, HotWaterState, Info, Sensor, State, StaticState
import pytest
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
@@ -58,6 +58,11 @@ def mock_bsblan() -> Generator[MagicMock]:
bsblan.sensor.return_value = Sensor.from_json(
load_fixture("sensor.json", DOMAIN)
)
+ bsblan.hot_water_state.return_value = HotWaterState.from_json(
+ load_fixture("dhw_state.json", DOMAIN)
+ )
+ # mock get_temperature_unit property
+ bsblan.get_temperature_unit = "°C"
yield bsblan
diff --git a/tests/components/bsblan/fixtures/dhw_state.json b/tests/components/bsblan/fixtures/dhw_state.json
new file mode 100644
index 00000000000..41b8c7beda5
--- /dev/null
+++ b/tests/components/bsblan/fixtures/dhw_state.json
@@ -0,0 +1,110 @@
+{
+ "operating_mode": {
+ "name": "DHW operating mode",
+ "error": 0,
+ "value": "On",
+ "desc": "On",
+ "dataType": 1,
+ "readonly": 0,
+ "unit": ""
+ },
+ "nominal_setpoint": {
+ "name": "DHW nominal setpoint",
+ "error": 0,
+ "value": "50.0",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "°C"
+ },
+ "nominal_setpoint_max": {
+ "name": "DHW nominal setpoint maximum",
+ "error": 0,
+ "value": "65.0",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "°C"
+ },
+ "reduced_setpoint": {
+ "name": "DHW reduced setpoint",
+ "error": 0,
+ "value": "40.0",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "°C"
+ },
+ "release": {
+ "name": "DHW release programme",
+ "error": 0,
+ "value": "1",
+ "desc": "Released",
+ "dataType": 1,
+ "readonly": 0,
+ "unit": ""
+ },
+ "legionella_function": {
+ "name": "Legionella function fixed weekday",
+ "error": 0,
+ "value": "0",
+ "desc": "Off",
+ "dataType": 1,
+ "readonly": 0,
+ "unit": ""
+ },
+ "legionella_setpoint": {
+ "name": "Legionella function setpoint",
+ "error": 0,
+ "value": "60.0",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "°C"
+ },
+ "legionella_periodicity": {
+ "name": "Legionella function periodicity",
+ "error": 0,
+ "value": "7",
+ "desc": "Weekly",
+ "dataType": 0,
+ "readonly": 0,
+ "unit": "days"
+ },
+ "legionella_function_day": {
+ "name": "Legionella function day",
+ "error": 0,
+ "value": "6",
+ "desc": "Saturday",
+ "dataType": 1,
+ "readonly": 0,
+ "unit": ""
+ },
+ "legionella_function_time": {
+ "name": "Legionella function time",
+ "error": 0,
+ "value": "12:00",
+ "desc": "",
+ "dataType": 2,
+ "readonly": 0,
+ "unit": ""
+ },
+ "dhw_actual_value_top_temperature": {
+ "name": "DHW temperature actual value",
+ "error": 0,
+ "value": "48.5",
+ "desc": "",
+ "dataType": 0,
+ "readonly": 1,
+ "unit": "°C"
+ },
+ "state_dhw_pump": {
+ "name": "State DHW circulation pump",
+ "error": 0,
+ "value": "0",
+ "desc": "Off",
+ "dataType": 1,
+ "readonly": 1,
+ "unit": ""
+ }
+}
diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr
index 4eb70fe2658..16828fea752 100644
--- a/tests/components/bsblan/snapshots/test_climate.ambr
+++ b/tests/components/bsblan/snapshots/test_climate.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-entry]
+# name: test_celsius_fahrenheit[climate.bsb_lan-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -44,7 +44,7 @@
'unit_of_measurement': None,
})
# ---
-# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-state]
+# name: test_celsius_fahrenheit[climate.bsb_lan-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 18.6,
@@ -72,79 +72,6 @@
'state': 'heat',
})
# ---
-# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-entry]
- EntityRegistryEntrySnapshot({
- 'aliases': set({
- }),
- 'area_id': None,
- 'capabilities': dict({
- 'hvac_modes': list([
- ,
- ,
- ,
- ]),
- 'max_temp': -6.7,
- 'min_temp': -13.3,
- 'preset_modes': list([
- 'eco',
- 'none',
- ]),
- }),
- 'config_entry_id': ,
- 'device_class': None,
- 'device_id': ,
- 'disabled_by': None,
- 'domain': 'climate',
- 'entity_category': None,
- 'entity_id': 'climate.bsb_lan',
- 'has_entity_name': True,
- 'hidden_by': None,
- 'icon': None,
- 'id': ,
- 'labels': set({
- }),
- 'name': None,
- 'options': dict({
- }),
- 'original_device_class': None,
- 'original_icon': None,
- 'original_name': None,
- 'platform': 'bsblan',
- 'previous_unique_id': None,
- 'supported_features': ,
- 'translation_key': None,
- 'unique_id': '00:80:41:19:69:90-climate',
- 'unit_of_measurement': None,
- })
-# ---
-# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-state]
- StateSnapshot({
- 'attributes': ReadOnlyDict({
- 'current_temperature': -7.4,
- 'friendly_name': 'BSB-LAN',
- 'hvac_modes': list([
- ,
- ,
- ,
- ]),
- 'max_temp': -6.7,
- 'min_temp': -13.3,
- 'preset_mode': 'none',
- 'preset_modes': list([
- 'eco',
- 'none',
- ]),
- 'supported_features': ,
- 'temperature': -7.5,
- }),
- 'context': ,
- 'entity_id': 'climate.bsb_lan',
- 'last_changed': ,
- 'last_reported': ,
- 'last_updated': ,
- 'state': 'heat',
- })
-# ---
# name: test_climate_entity_properties[climate.bsb_lan-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
diff --git a/tests/components/bsblan/snapshots/test_water_heater.ambr b/tests/components/bsblan/snapshots/test_water_heater.ambr
new file mode 100644
index 00000000000..c1a13b764c0
--- /dev/null
+++ b/tests/components/bsblan/snapshots/test_water_heater.ambr
@@ -0,0 +1,68 @@
+# serializer version: 1
+# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'max_temp': 65.0,
+ 'min_temp': 40.0,
+ 'operation_list': list([
+ 'eco',
+ 'off',
+ 'on',
+ ]),
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'water_heater',
+ 'entity_category': None,
+ 'entity_id': 'water_heater.bsb_lan',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': None,
+ 'platform': 'bsblan',
+ 'previous_unique_id': None,
+ 'supported_features': ,
+ 'translation_key': None,
+ 'unique_id': '00:80:41:19:69:90',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_water_heater_states[dhw_state.json][water_heater.bsb_lan-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'current_temperature': 48.5,
+ 'friendly_name': 'BSB-LAN',
+ 'max_temp': 65.0,
+ 'min_temp': 40.0,
+ 'operation_list': list([
+ 'eco',
+ 'off',
+ 'on',
+ ]),
+ 'operation_mode': 'on',
+ 'supported_features': ,
+ 'target_temp_high': None,
+ 'target_temp_low': None,
+ 'temperature': 50.0,
+ }),
+ 'context': ,
+ 'entity_id': 'water_heater.bsb_lan',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py
index c519c3043da..7ee12c5fa1a 100644
--- a/tests/components/bsblan/test_climate.py
+++ b/tests/components/bsblan/test_climate.py
@@ -3,12 +3,11 @@
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock
-from bsblan import BSBLANError, StaticState
+from bsblan import BSBLANError
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.components.bsblan.const import DOMAIN
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
@@ -27,37 +26,19 @@ import homeassistant.helpers.entity_registry as er
from . import setup_with_selected_platforms
-from tests.common import (
- MockConfigEntry,
- async_fire_time_changed,
- load_json_object_fixture,
- snapshot_platform,
-)
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "climate.bsb_lan"
-@pytest.mark.parametrize(
- ("static_file"),
- [
- ("static.json"),
- ("static_F.json"),
- ],
-)
async def test_celsius_fahrenheit(
hass: HomeAssistant,
mock_bsblan: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
- static_file: str,
) -> None:
"""Test Celsius and Fahrenheit temperature units."""
-
- static_data = load_json_object_fixture(static_file, DOMAIN)
-
- mock_bsblan.static_values.return_value = StaticState.from_dict(static_data)
-
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@@ -75,21 +56,9 @@ async def test_climate_entity_properties(
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
- # Test when current_temperature is "---"
- mock_current_temp = MagicMock()
- mock_current_temp.value = "---"
- mock_bsblan.state.return_value.current_temperature = mock_current_temp
-
- freezer.tick(timedelta(minutes=1))
- async_fire_time_changed(hass)
- await hass.async_block_till_done()
-
- state = hass.states.get(ENTITY_ID)
- assert state.attributes["current_temperature"] is None
-
# Test target_temperature
mock_target_temp = MagicMock()
- mock_target_temp.value = "23.5"
+ mock_target_temp.value = 23.5
mock_bsblan.state.return_value.target_temperature = mock_target_temp
freezer.tick(timedelta(minutes=1))
diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py
index dc22574168d..c95671a1a6b 100644
--- a/tests/components/bsblan/test_sensor.py
+++ b/tests/components/bsblan/test_sensor.py
@@ -1,19 +1,17 @@
"""Tests for the BSB-Lan sensor platform."""
-from datetime import timedelta
-from unittest.mock import AsyncMock, MagicMock
+from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
-import pytest
from syrupy.assertion import SnapshotAssertion
-from homeassistant.const import STATE_UNKNOWN, Platform
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er
from . import setup_with_selected_platforms
-from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+from tests.common import MockConfigEntry, snapshot_platform
ENTITY_CURRENT_TEMP = "sensor.bsb_lan_current_temperature"
ENTITY_OUTSIDE_TEMP = "sensor.bsb_lan_outside_temperature"
@@ -30,37 +28,3 @@ async def test_sensor_entity_properties(
"""Test the sensor entity properties."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
-
-
-@pytest.mark.parametrize(
- ("value", "expected_state"),
- [
- (18.6, "18.6"),
- (None, STATE_UNKNOWN),
- ("---", STATE_UNKNOWN),
- ],
-)
-async def test_current_temperature_scenarios(
- hass: HomeAssistant,
- mock_bsblan: AsyncMock,
- mock_config_entry: MockConfigEntry,
- freezer: FrozenDateTimeFactory,
- value,
- expected_state,
-) -> None:
- """Test various scenarios for current temperature sensor."""
- await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR])
-
- # Set up the mock value
- mock_current_temp = MagicMock()
- mock_current_temp.value = value
- mock_bsblan.sensor.return_value.current_temperature = mock_current_temp
-
- # Trigger an update
- freezer.tick(timedelta(minutes=1))
- async_fire_time_changed(hass)
- await hass.async_block_till_done()
-
- # Check the state
- state = hass.states.get(ENTITY_CURRENT_TEMP)
- assert state.state == expected_state
diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py
new file mode 100644
index 00000000000..ed920774aa5
--- /dev/null
+++ b/tests/components/bsblan/test_water_heater.py
@@ -0,0 +1,210 @@
+"""Tests for the BSB-Lan water heater platform."""
+
+from datetime import timedelta
+from unittest.mock import AsyncMock, MagicMock
+
+from bsblan import BSBLANError
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.water_heater import (
+ ATTR_OPERATION_MODE,
+ DOMAIN as WATER_HEATER_DOMAIN,
+ SERVICE_SET_OPERATION_MODE,
+ SERVICE_SET_TEMPERATURE,
+ STATE_ECO,
+ STATE_OFF,
+ STATE_ON,
+)
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.entity_registry as er
+
+from . import setup_with_selected_platforms
+
+from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
+
+ENTITY_ID = "water_heater.bsb_lan"
+
+
+@pytest.mark.parametrize(
+ ("dhw_file"),
+ [
+ ("dhw_state.json"),
+ ],
+)
+async def test_water_heater_states(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+ entity_registry: er.EntityRegistry,
+ dhw_file: str,
+) -> None:
+ """Test water heater states with different configurations."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_water_heater_entity_properties(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test the water heater entity properties."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ state = hass.states.get(ENTITY_ID)
+ assert state is not None
+
+ # Test when nominal setpoint is "10"
+ mock_setpoint = MagicMock()
+ mock_setpoint.value = 10
+ mock_bsblan.hot_water_state.return_value.nominal_setpoint = mock_setpoint
+
+ freezer.tick(timedelta(minutes=1))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY_ID)
+ assert state.attributes.get("temperature") == 10
+
+
+@pytest.mark.parametrize(
+ ("mode", "bsblan_mode"),
+ [
+ (STATE_ECO, "Eco"),
+ (STATE_OFF, "Off"),
+ (STATE_ON, "On"),
+ ],
+)
+async def test_set_operation_mode(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ mode: str,
+ bsblan_mode: str,
+) -> None:
+ """Test setting operation mode."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_OPERATION_MODE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_OPERATION_MODE: mode,
+ },
+ blocking=True,
+ )
+
+ mock_bsblan.set_hot_water.assert_called_once_with(operating_mode=bsblan_mode)
+
+
+async def test_set_invalid_operation_mode(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test setting invalid operation mode."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ with pytest.raises(
+ HomeAssistantError,
+ match=r"Operation mode invalid_mode is not valid for water_heater\.bsb_lan\. Valid operation modes are: eco, off, on",
+ ):
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_OPERATION_MODE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_OPERATION_MODE: "invalid_mode",
+ },
+ blocking=True,
+ )
+
+
+async def test_set_temperature(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test setting temperature."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_TEMPERATURE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_TEMPERATURE: 50,
+ },
+ blocking=True,
+ )
+
+ mock_bsblan.set_hot_water.assert_called_once_with(nominal_setpoint=50)
+
+
+async def test_set_temperature_failure(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test setting temperature with API failure."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ mock_bsblan.set_hot_water.side_effect = BSBLANError("Test error")
+
+ with pytest.raises(
+ HomeAssistantError, match="An error occurred while setting the temperature"
+ ):
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_TEMPERATURE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_TEMPERATURE: 50,
+ },
+ blocking=True,
+ )
+
+
+async def test_operation_mode_error(
+ hass: HomeAssistant,
+ mock_bsblan: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+) -> None:
+ """Test operation mode setting with API failure."""
+ await setup_with_selected_platforms(
+ hass, mock_config_entry, [Platform.WATER_HEATER]
+ )
+
+ mock_bsblan.set_hot_water.side_effect = BSBLANError("Test error")
+
+ with pytest.raises(
+ HomeAssistantError, match="An error occurred while setting the operation mode"
+ ):
+ await hass.services.async_call(
+ domain=WATER_HEATER_DOMAIN,
+ service=SERVICE_SET_OPERATION_MODE,
+ service_data={
+ ATTR_ENTITY_ID: ENTITY_ID,
+ ATTR_OPERATION_MODE: STATE_ECO,
+ },
+ blocking=True,
+ )
diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py
index 4ad5e11b8e4..36b102b933a 100644
--- a/tests/components/calendar/test_init.py
+++ b/tests/components/calendar/test_init.py
@@ -14,7 +14,8 @@ import voluptuous as vol
from homeassistant.components.calendar import DOMAIN, SERVICE_GET_EVENTS
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported
+from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from .conftest import MockCalendarEntity, MockConfigEntry
@@ -214,8 +215,12 @@ async def test_unsupported_websocket(
async def test_unsupported_create_event_service(hass: HomeAssistant) -> None:
"""Test unsupported service call."""
-
- with pytest.raises(HomeAssistantError, match="does not support this service"):
+ await async_setup_component(hass, "homeassistant", {})
+ with pytest.raises(
+ ServiceNotSupported,
+ match="Entity calendar.calendar_1 does not "
+ "support action calendar.create_event",
+ ):
await hass.services.async_call(
DOMAIN,
"create_event",
diff --git a/tests/components/cambridge_audio/__init__.py b/tests/components/cambridge_audio/__init__.py
index f6b5f48d39d..4e11a728f41 100644
--- a/tests/components/cambridge_audio/__init__.py
+++ b/tests/components/cambridge_audio/__init__.py
@@ -1,5 +1,9 @@
"""Tests for the Cambridge Audio integration."""
+from unittest.mock import AsyncMock
+
+from aiostreammagic.models import CallbackType
+
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -11,3 +15,11 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
+
+
+async def mock_state_update(
+ client: AsyncMock, callback_type: CallbackType = CallbackType.STATE
+) -> None:
+ """Trigger a callback in the media player."""
+ for callback in client.register_state_update_callbacks.call_args_list:
+ await callback[0][0](client, callback_type)
diff --git a/tests/components/cambridge_audio/test_init.py b/tests/components/cambridge_audio/test_init.py
index 4a8c1b668e2..a058f7c8b6c 100644
--- a/tests/components/cambridge_audio/test_init.py
+++ b/tests/components/cambridge_audio/test_init.py
@@ -1,8 +1,10 @@
"""Tests for the Cambridge Audio integration."""
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, Mock
from aiostreammagic import StreamMagicError
+from aiostreammagic.models import CallbackType
+import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.cambridge_audio.const import DOMAIN
@@ -10,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
-from . import setup_integration
+from . import mock_state_update, setup_integration
from tests.common import MockConfigEntry
@@ -43,3 +45,23 @@ async def test_device_info(
)
assert device_entry is not None
assert device_entry == snapshot
+
+
+async def test_disconnect_reconnect_log(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ mock_stream_magic_client: AsyncMock,
+ mock_config_entry: MockConfigEntry,
+ device_registry: dr.DeviceRegistry,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test device registry integration."""
+ await setup_integration(hass, mock_config_entry)
+
+ mock_stream_magic_client.is_connected = Mock(return_value=False)
+ await mock_state_update(mock_stream_magic_client, CallbackType.CONNECTION)
+ assert "Disconnected from device at 192.168.20.218" in caplog.text
+
+ mock_stream_magic_client.is_connected = Mock(return_value=True)
+ await mock_state_update(mock_stream_magic_client, CallbackType.CONNECTION)
+ assert "Reconnected to device at 192.168.20.218" in caplog.text
diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py
index b857e61c235..bb2ccd1aec4 100644
--- a/tests/components/cambridge_audio/test_media_player.py
+++ b/tests/components/cambridge_audio/test_media_player.py
@@ -7,7 +7,6 @@ from aiostreammagic import (
ShuffleMode,
TransportControl,
)
-from aiostreammagic.models import CallbackType
import pytest
from homeassistant.components.media_player import (
@@ -49,18 +48,12 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from . import setup_integration
+from . import mock_state_update, setup_integration
from .const import ENTITY_ID
from tests.common import MockConfigEntry
-async def mock_state_update(client: AsyncMock) -> None:
- """Trigger a callback in the media player."""
- for callback in client.register_state_update_callbacks.call_args_list:
- await callback[0][0](client, CallbackType.STATE)
-
-
async def test_entity_supported_features(
hass: HomeAssistant,
mock_stream_magic_client: AsyncMock,
diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py
index cb25b366029..b529ee3e9b9 100644
--- a/tests/components/camera/conftest.py
+++ b/tests/components/camera/conftest.py
@@ -62,32 +62,17 @@ async def mock_camera_fixture(hass: HomeAssistant) -> AsyncGenerator[None]:
def mock_camera_hls_fixture(mock_camera: None) -> Generator[None]:
"""Initialize a demo camera platform with HLS."""
with patch(
- "homeassistant.components.camera.Camera.frontend_stream_type",
- new_callable=PropertyMock(return_value=StreamType.HLS),
- ):
- yield
-
-
-@pytest.fixture
-async def mock_camera_webrtc_frontendtype_only(
- hass: HomeAssistant,
-) -> AsyncGenerator[None]:
- """Initialize a demo camera platform with WebRTC."""
- assert await async_setup_component(
- hass, "camera", {camera.DOMAIN: {"platform": "demo"}}
- )
- await hass.async_block_till_done()
-
- with patch(
- "homeassistant.components.camera.Camera.frontend_stream_type",
- new_callable=PropertyMock(return_value=StreamType.WEB_RTC),
+ "homeassistant.components.camera.Camera.camera_capabilities",
+ new_callable=PropertyMock(
+ return_value=camera.CameraCapabilities({StreamType.HLS})
+ ),
):
yield
@pytest.fixture
async def mock_camera_webrtc(
- mock_camera_webrtc_frontendtype_only: None,
+ mock_camera: None,
) -> AsyncGenerator[None]:
"""Initialize a demo camera platform with WebRTC."""
@@ -96,9 +81,17 @@ async def mock_camera_webrtc(
) -> None:
send_message(WebRTCAnswer(WEBRTC_ANSWER))
- with patch(
- "homeassistant.components.camera.Camera.async_handle_async_webrtc_offer",
- side_effect=async_handle_async_webrtc_offer,
+ with (
+ patch(
+ "homeassistant.components.camera.Camera.async_handle_async_webrtc_offer",
+ side_effect=async_handle_async_webrtc_offer,
+ ),
+ patch(
+ "homeassistant.components.camera.Camera.camera_capabilities",
+ new_callable=PropertyMock(
+ return_value=camera.CameraCapabilities({StreamType.WEB_RTC})
+ ),
+ ),
):
yield
@@ -168,7 +161,6 @@ async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None:
_attr_supported_features: camera.CameraEntityFeature = (
camera.CameraEntityFeature.STREAM
)
- _attr_frontend_stream_type: camera.StreamType = camera.StreamType.WEB_RTC
async def stream_source(self) -> str | None:
return STREAM_SOURCE
diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py
index af8c220bbe4..32520fcad23 100644
--- a/tests/components/camera/test_init.py
+++ b/tests/components/camera/test_init.py
@@ -27,6 +27,7 @@ from homeassistant.components.camera.helper import get_camera_from_entity_id
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.const import (
ATTR_ENTITY_ID,
+ CONF_PLATFORM,
EVENT_HOMEASSISTANT_STARTED,
STATE_UNAVAILABLE,
)
@@ -801,32 +802,13 @@ async def test_use_stream_for_stills(
@pytest.mark.parametrize(
"module",
- [camera, camera.const],
+ [camera],
)
def test_all(module: ModuleType) -> None:
"""Test module.__all__ is correctly set."""
help_test_all(module)
-@pytest.mark.parametrize(
- "enum",
- list(camera.const.StreamType),
-)
-@pytest.mark.parametrize(
- "module",
- [camera, camera.const],
-)
-def test_deprecated_stream_type_constants(
- caplog: pytest.LogCaptureFixture,
- enum: camera.const.StreamType,
- module: ModuleType,
-) -> None:
- """Test deprecated stream type constants."""
- import_and_test_deprecated_constant_enum(
- caplog, module, enum, "STREAM_TYPE_", "2025.1"
- )
-
-
@pytest.mark.parametrize(
"enum",
list(camera.const.CameraState),
@@ -844,20 +826,6 @@ def test_deprecated_state_constants(
import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10")
-@pytest.mark.parametrize(
- "entity_feature",
- list(camera.CameraEntityFeature),
-)
-def test_deprecated_support_constants(
- caplog: pytest.LogCaptureFixture,
- entity_feature: camera.CameraEntityFeature,
-) -> None:
- """Test deprecated support constants."""
- import_and_test_deprecated_constant_enum(
- caplog, camera, entity_feature, "SUPPORT_", "2025.1"
- )
-
-
def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None:
"""Test deprecated supported features ints."""
@@ -1054,3 +1022,27 @@ async def test_camera_capabilities_changing_native_support(
await hass.async_block_till_done()
await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set())
+
+
+@pytest.mark.usefixtures("enable_custom_integrations")
+async def test_deprecated_frontend_stream_type_logs(
+ hass: HomeAssistant,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test using (_attr_)frontend_stream_type will log."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+ await hass.async_block_till_done()
+
+ for entity_id in (
+ "camera.property_frontend_stream_type",
+ "camera.attr_frontend_stream_type",
+ ):
+ camera_obj = get_camera_from_entity_id(hass, entity_id)
+ assert camera_obj.frontend_stream_type == StreamType.WEB_RTC
+
+ assert (
+ "Detected that custom integration 'test' is overwriting the 'frontend_stream_type' property in the PropertyFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6,"
+ ) in caplog.text
+ assert (
+ "Detected that custom integration 'test' is setting the '_attr_frontend_stream_type' attribute in the AttrFrontendStreamTypeCamera class, which is deprecated and will be removed in Home Assistant 2025.6,"
+ ) in caplog.text
diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py
index 3b75b58c53f..bd92010d242 100644
--- a/tests/components/camera/test_media_source.py
+++ b/tests/components/camera/test_media_source.py
@@ -92,7 +92,7 @@ async def test_browsing_webrtc(hass: HomeAssistant) -> None:
assert item.children[0].media_content_type == FORMAT_CONTENT_TYPE["hls"]
-@pytest.mark.usefixtures("mock_camera_hls")
+@pytest.mark.usefixtures("mock_camera")
async def test_resolving(hass: HomeAssistant) -> None:
"""Test resolving."""
# Adding stream enables HLS camera
@@ -110,7 +110,7 @@ async def test_resolving(hass: HomeAssistant) -> None:
assert item.mime_type == FORMAT_CONTENT_TYPE["hls"]
-@pytest.mark.usefixtures("mock_camera_hls")
+@pytest.mark.usefixtures("mock_camera")
async def test_resolving_errors(hass: HomeAssistant) -> None:
"""Test resolving."""
diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py
index 89bd74be301..a7c6d889409 100644
--- a/tests/components/camera/test_webrtc.py
+++ b/tests/components/camera/test_webrtc.py
@@ -65,7 +65,6 @@ class MockCamera(Camera):
_attr_name = "Test"
_attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM
- _attr_frontend_stream_type: StreamType = StreamType.WEB_RTC
def __init__(self) -> None:
"""Initialize the mock entity."""
@@ -399,7 +398,7 @@ async def test_ws_get_client_config_custom_config(
}
-@pytest.mark.usefixtures("mock_camera_hls")
+@pytest.mark.usefixtures("mock_camera")
async def test_ws_get_client_config_no_rtc_camera(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
@@ -429,10 +428,16 @@ async def provide_webrtc_answer(stream_source: str, offer: str, stream_id: str)
@pytest.fixture(name="mock_rtsp_to_webrtc")
-def mock_rtsp_to_webrtc_fixture(hass: HomeAssistant) -> Generator[Mock]:
+def mock_rtsp_to_webrtc_fixture(
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+) -> Generator[Mock]:
"""Fixture that registers a mock rtsp to webrtc provider."""
mock_provider = Mock(side_effect=provide_webrtc_answer)
unsub = async_register_rtsp_to_web_rtc_provider(hass, "mock_domain", mock_provider)
+ assert (
+ "async_register_rtsp_to_web_rtc_provider is a deprecated function which will"
+ " be removed in HA Core 2025.6. Use async_register_webrtc_provider instead"
+ ) in caplog.text
yield mock_provider
unsub()
@@ -496,7 +501,7 @@ async def test_websocket_webrtc_offer_webrtc_provider_deprecated(
hass_ws_client,
register_test_provider,
WebRTCCandidate(RTCIceCandidate("candidate")),
- {"type": "candidate", "candidate": "candidate"},
+ {"type": "candidate", "candidate": {"candidate": "candidate"}},
)
@@ -505,7 +510,10 @@ async def test_websocket_webrtc_offer_webrtc_provider_deprecated(
[
(
WebRTCCandidate(RTCIceCandidateInit("candidate")),
- {"type": "candidate", "candidate": "candidate"},
+ {
+ "type": "candidate",
+ "candidate": {"candidate": "candidate", "sdpMLineIndex": 0},
+ },
),
(
WebRTCError("webrtc_offer_failed", "error"),
@@ -684,24 +692,33 @@ async def test_websocket_webrtc_offer_failure(
}
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_websocket_webrtc_offer_sync(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
- init_test_integration: MockCamera,
+ caplog: pytest.LogCaptureFixture,
) -> None:
"""Test sync WebRTC stream offer."""
client = await hass_ws_client(hass)
- init_test_integration.set_sync_answer(WEBRTC_ANSWER)
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
- "entity_id": "camera.test",
+ "entity_id": "camera.sync",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
+ assert (
+ "tests.components.camera.conftest",
+ logging.WARNING,
+ (
+ "async_handle_web_rtc_offer was called from camera, this is a deprecated "
+ "function which will be removed in HA Core 2025.6. Use "
+ "async_handle_async_webrtc_offer instead"
+ ),
+ ) in caplog.record_tuples
assert response["type"] == TYPE_RESULT
assert response["success"]
subscription_id = response["id"]
@@ -947,14 +964,34 @@ async def test_rtsp_to_webrtc_offer_not_accepted(
unsub()
+@pytest.mark.parametrize(
+ ("frontend_candidate", "expected_candidate"),
+ [
+ (
+ {"candidate": "candidate", "sdpMLineIndex": 0},
+ RTCIceCandidateInit("candidate"),
+ ),
+ (
+ {"candidate": "candidate", "sdpMLineIndex": 1},
+ RTCIceCandidateInit("candidate", sdp_m_line_index=1),
+ ),
+ (
+ {"candidate": "candidate", "sdpMid": "1"},
+ RTCIceCandidateInit("candidate", sdp_mid="1"),
+ ),
+ ],
+ ids=["candidate", "candidate-mline-index", "candidate-mid"],
+)
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_candidate(
- hass: HomeAssistant, hass_ws_client: WebSocketGenerator
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ frontend_candidate: dict[str, Any],
+ expected_candidate: RTCIceCandidateInit,
) -> None:
"""Test ws webrtc candidate command."""
client = await hass_ws_client(hass)
session_id = "session_id"
- candidate = "candidate"
with patch.object(
get_camera_from_entity_id(hass, "camera.async"), "async_on_webrtc_candidate"
) as mock_on_webrtc_candidate:
@@ -963,15 +1000,64 @@ async def test_ws_webrtc_candidate(
"type": "camera/webrtc/candidate",
"entity_id": "camera.async",
"session_id": session_id,
- "candidate": candidate,
+ "candidate": frontend_candidate,
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
- mock_on_webrtc_candidate.assert_called_once_with(
- session_id, RTCIceCandidateInit(candidate)
+ mock_on_webrtc_candidate.assert_called_once_with(session_id, expected_candidate)
+
+
+@pytest.mark.parametrize(
+ ("message", "expected_error_msg"),
+ [
+ (
+ {"sdpMLineIndex": 0},
+ (
+ 'Field "candidate" of type str is missing in RTCIceCandidateInit instance'
+ " for dictionary value @ data['candidate']. Got {'sdpMLineIndex': 0}"
+ ),
+ ),
+ (
+ {"candidate": "candidate", "sdpMLineIndex": -1},
+ (
+ "sdpMLineIndex must be greater than or equal to 0 for dictionary value @ "
+ "data['candidate']. Got {'candidate': 'candidate', 'sdpMLineIndex': -1}"
+ ),
+ ),
+ ],
+ ids=[
+ "candidate missing",
+ "spd_mline_index smaller than 0",
+ ],
+)
+@pytest.mark.usefixtures("mock_test_webrtc_cameras")
+async def test_ws_webrtc_candidate_invalid_candidate_message(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ message: dict,
+ expected_error_msg: str,
+) -> None:
+ """Test ws WebRTC candidate command for a camera with a different stream_type."""
+ client = await hass_ws_client(hass)
+ with patch("homeassistant.components.camera.Camera.async_on_webrtc_candidate"):
+ await client.send_json_auto_id(
+ {
+ "type": "camera/webrtc/candidate",
+ "entity_id": "camera.async",
+ "session_id": "session_id",
+ "candidate": message,
+ }
)
+ response = await client.receive_json()
+
+ assert response["type"] == TYPE_RESULT
+ assert not response["success"]
+ assert response["error"] == {
+ "code": "invalid_format",
+ "message": expected_error_msg,
+ }
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
@@ -985,7 +1071,7 @@ async def test_ws_webrtc_candidate_not_supported(
"type": "camera/webrtc/candidate",
"entity_id": "camera.sync",
"session_id": "session_id",
- "candidate": "candidate",
+ "candidate": {"candidate": "candidate"},
}
)
response = await client.receive_json()
@@ -1015,14 +1101,14 @@ async def test_ws_webrtc_candidate_webrtc_provider(
"type": "camera/webrtc/candidate",
"entity_id": "camera.demo_camera",
"session_id": session_id,
- "candidate": candidate,
+ "candidate": {"candidate": candidate, "sdpMLineIndex": 1},
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
mock_on_webrtc_candidate.assert_called_once_with(
- session_id, RTCIceCandidateInit(candidate)
+ session_id, RTCIceCandidateInit(candidate, sdp_m_line_index=1)
)
@@ -1037,7 +1123,7 @@ async def test_ws_webrtc_candidate_invalid_entity(
"type": "camera/webrtc/candidate",
"entity_id": "camera.does_not_exist",
"session_id": "session_id",
- "candidate": "candidate",
+ "candidate": {"candidate": "candidate"},
}
)
response = await client.receive_json()
@@ -1081,7 +1167,7 @@ async def test_ws_webrtc_candidate_invalid_stream_type(
"type": "camera/webrtc/candidate",
"entity_id": "camera.demo_camera",
"session_id": "session_id",
- "candidate": "candidate",
+ "candidate": {"candidate": "candidate"},
}
)
response = await client.receive_json()
diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py
index a492d9805b5..4b5a578ecc4 100644
--- a/tests/components/climate/test_device_trigger.py
+++ b/tests/components/climate/test_device_trigger.py
@@ -48,7 +48,7 @@ async def test_get_triggers(
)
hass.states.async_set(
entity_entry.entity_id,
- const.HVAC_MODE_COOL,
+ HVACMode.COOL,
{
const.ATTR_HVAC_ACTION: HVACAction.IDLE,
const.ATTR_CURRENT_HUMIDITY: 23,
diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py
index aa162e0b683..a7f47668612 100644
--- a/tests/components/climate/test_init.py
+++ b/tests/components/climate/test_init.py
@@ -3,14 +3,12 @@
from __future__ import annotations
from enum import Enum
-from types import ModuleType
from typing import Any
from unittest.mock import MagicMock, Mock, patch
import pytest
import voluptuous as vol
-from homeassistant.components import climate
from homeassistant.components.climate import (
DOMAIN,
SET_TEMPERATURE_SCHEMA,
@@ -24,6 +22,7 @@ from homeassistant.components.climate.const import (
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -31,8 +30,11 @@ from homeassistant.components.climate.const import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
+ SWING_HORIZONTAL_OFF,
+ SWING_HORIZONTAL_ON,
ClimateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
@@ -54,9 +56,6 @@ from tests.common import (
MockModule,
MockPlatform,
async_mock_service,
- help_test_all,
- import_and_test_deprecated_constant,
- import_and_test_deprecated_constant_enum,
mock_integration,
mock_platform,
setup_test_component_platform,
@@ -104,6 +103,7 @@ class MockClimateEntity(MockEntity, ClimateEntity):
ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.SWING_MODE
+ | ClimateEntityFeature.SWING_HORIZONTAL_MODE
)
_attr_preset_mode = "home"
_attr_preset_modes = ["home", "away"]
@@ -111,6 +111,8 @@ class MockClimateEntity(MockEntity, ClimateEntity):
_attr_fan_modes = ["auto", "off"]
_attr_swing_mode = "auto"
_attr_swing_modes = ["auto", "off"]
+ _attr_swing_horizontal_mode = "on"
+ _attr_swing_horizontal_modes = [SWING_HORIZONTAL_ON, SWING_HORIZONTAL_OFF]
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature = 20
_attr_target_temperature_high = 25
@@ -144,6 +146,10 @@ class MockClimateEntity(MockEntity, ClimateEntity):
"""Set swing mode."""
self._attr_swing_mode = swing_mode
+ def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set horizontal swing mode."""
+ self._attr_swing_horizontal_mode = swing_horizontal_mode
+
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
self._attr_hvac_mode = hvac_mode
@@ -194,67 +200,14 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s
(enum_field, constant_prefix)
for enum_field in enum
if enum_field
- not in [ClimateEntityFeature.TURN_ON, ClimateEntityFeature.TURN_OFF]
+ not in [
+ ClimateEntityFeature.TURN_ON,
+ ClimateEntityFeature.TURN_OFF,
+ ClimateEntityFeature.SWING_HORIZONTAL_MODE,
+ ]
]
-@pytest.mark.parametrize(
- "module",
- [climate, climate.const],
-)
-def test_all(module: ModuleType) -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(module)
-
-
-@pytest.mark.parametrize(
- ("enum", "constant_prefix"),
- _create_tuples(climate.ClimateEntityFeature, "SUPPORT_")
- + _create_tuples(climate.HVACMode, "HVAC_MODE_"),
-)
-@pytest.mark.parametrize(
- "module",
- [climate, climate.const],
-)
-def test_deprecated_constants(
- caplog: pytest.LogCaptureFixture,
- enum: Enum,
- constant_prefix: str,
- module: ModuleType,
-) -> None:
- """Test deprecated constants."""
- import_and_test_deprecated_constant_enum(
- caplog, module, enum, constant_prefix, "2025.1"
- )
-
-
-@pytest.mark.parametrize(
- ("enum", "constant_postfix"),
- [
- (climate.HVACAction.OFF, "OFF"),
- (climate.HVACAction.HEATING, "HEAT"),
- (climate.HVACAction.COOLING, "COOL"),
- (climate.HVACAction.DRYING, "DRY"),
- (climate.HVACAction.IDLE, "IDLE"),
- (climate.HVACAction.FAN, "FAN"),
- ],
-)
-def test_deprecated_current_constants(
- caplog: pytest.LogCaptureFixture,
- enum: climate.HVACAction,
- constant_postfix: str,
-) -> None:
- """Test deprecated current constants."""
- import_and_test_deprecated_constant(
- caplog,
- climate.const,
- "CURRENT_HVAC_" + constant_postfix,
- f"{enum.__class__.__name__}.{enum.name}",
- enum,
- "2025.1",
- )
-
-
async def test_temperature_features_is_valid(
hass: HomeAssistant,
register_test_integration: MockConfigEntry,
@@ -339,6 +292,7 @@ async def test_mode_validation(
assert state.attributes.get(ATTR_PRESET_MODE) == "home"
assert state.attributes.get(ATTR_FAN_MODE) == "auto"
assert state.attributes.get(ATTR_SWING_MODE) == "auto"
+ assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "on"
await hass.services.async_call(
DOMAIN,
@@ -358,6 +312,15 @@ async def test_mode_validation(
},
blocking=True,
)
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
+ {
+ "entity_id": "climate.test",
+ "swing_horizontal_mode": "off",
+ },
+ blocking=True,
+ )
await hass.services.async_call(
DOMAIN,
SERVICE_SET_FAN_MODE,
@@ -371,6 +334,7 @@ async def test_mode_validation(
assert state.attributes.get(ATTR_PRESET_MODE) == "away"
assert state.attributes.get(ATTR_FAN_MODE) == "off"
assert state.attributes.get(ATTR_SWING_MODE) == "off"
+ assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "off"
await hass.services.async_call(
DOMAIN,
@@ -427,6 +391,25 @@ async def test_mode_validation(
)
assert exc.value.translation_key == "not_valid_swing_mode"
+ with pytest.raises(
+ ServiceValidationError,
+ match="Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off",
+ ) as exc:
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
+ {
+ "entity_id": "climate.test",
+ "swing_horizontal_mode": "invalid",
+ },
+ blocking=True,
+ )
+ assert (
+ str(exc.value)
+ == "Horizontal swing mode invalid is not valid. Valid horizontal swing modes are: on, off"
+ )
+ assert exc.value.translation_key == "not_valid_horizontal_swing_mode"
+
with pytest.raises(
ServiceValidationError,
match="Fan mode invalid is not valid. Valid fan modes are: auto, off",
diff --git a/tests/components/climate/test_reproduce_state.py b/tests/components/climate/test_reproduce_state.py
index 0632ebcc9e4..3bc91467f14 100644
--- a/tests/components/climate/test_reproduce_state.py
+++ b/tests/components/climate/test_reproduce_state.py
@@ -6,6 +6,7 @@ from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HUMIDITY,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -14,6 +15,7 @@ from homeassistant.components.climate import (
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
+ SERVICE_SET_SWING_HORIZONTAL_MODE,
SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
HVACMode,
@@ -96,6 +98,7 @@ async def test_state_with_context(hass: HomeAssistant) -> None:
[
(SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE),
(SERVICE_SET_SWING_MODE, ATTR_SWING_MODE),
+ (SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE),
(SERVICE_SET_FAN_MODE, ATTR_FAN_MODE),
(SERVICE_SET_HUMIDITY, ATTR_HUMIDITY),
(SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE),
@@ -122,6 +125,7 @@ async def test_attribute(hass: HomeAssistant, service, attribute) -> None:
[
(SERVICE_SET_PRESET_MODE, ATTR_PRESET_MODE),
(SERVICE_SET_SWING_MODE, ATTR_SWING_MODE),
+ (SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE),
(SERVICE_SET_FAN_MODE, ATTR_FAN_MODE),
],
)
diff --git a/tests/components/climate/test_significant_change.py b/tests/components/climate/test_significant_change.py
index f060344722a..7d709090357 100644
--- a/tests/components/climate/test_significant_change.py
+++ b/tests/components/climate/test_significant_change.py
@@ -10,6 +10,7 @@ from homeassistant.components.climate import (
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
ATTR_PRESET_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
ATTR_SWING_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
@@ -66,6 +67,18 @@ async def test_significant_state_change(hass: HomeAssistant) -> None:
),
(METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "old_value"}, False),
(METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "new_value"}, True),
+ (
+ METRIC,
+ {ATTR_SWING_HORIZONTAL_MODE: "old_value"},
+ {ATTR_SWING_HORIZONTAL_MODE: "old_value"},
+ False,
+ ),
+ (
+ METRIC,
+ {ATTR_SWING_HORIZONTAL_MODE: "old_value"},
+ {ATTR_SWING_HORIZONTAL_MODE: "new_value"},
+ True,
+ ),
# multiple attributes
(
METRIC,
diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py
index 499981c643d..bf9fd7302ae 100644
--- a/tests/components/cloud/test_tts.py
+++ b/tests/components/cloud/test_tts.py
@@ -227,25 +227,21 @@ async def test_get_tts_audio(
await on_start_callback()
client = await hass_client()
- url = "/api/tts_get_url"
- data |= {"message": "There is someone at the door."}
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data |= {"message": "There is someone at the door."}
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -280,25 +276,21 @@ async def test_get_tts_audio_logged_out(
await hass.async_block_till_done()
client = await hass_client()
- url = "/api/tts_get_url"
- data |= {"message": "There is someone at the door."}
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data |= {"message": "There is someone at the door."}
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -342,28 +334,24 @@ async def test_tts_entity(
assert state
assert state.state == STATE_UNKNOWN
- url = "/api/tts_get_url"
- data = {
- "engine_id": entity_id,
- "message": "There is someone at the door.",
- }
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data = {
+ "engine_id": entity_id,
+ "message": "There is someone at the door.",
+ }
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{entity_id}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_en-us_6e8b81ac47_{entity_id}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -482,29 +470,25 @@ async def test_deprecated_voice(
client = await hass_client()
# Test with non deprecated voice.
- url = "/api/tts_get_url"
- data |= {
- "message": "There is someone at the door.",
- "language": language,
- "options": {"voice": replacement_voice},
- }
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data |= {
+ "message": "There is someone at the door.",
+ "language": language,
+ "options": {"voice": replacement_voice},
+ }
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_87567e3e29_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_87567e3e29_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -522,22 +506,18 @@ async def test_deprecated_voice(
# Test with deprecated voice.
data["options"] = {"voice": deprecated_voice}
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_13646b7d32_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_13646b7d32_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
issue_id = f"deprecated_voice_{deprecated_voice}"
@@ -631,28 +611,24 @@ async def test_deprecated_gender(
client = await hass_client()
# Test without deprecated gender option.
- url = "/api/tts_get_url"
- data |= {
- "message": "There is someone at the door.",
- "language": language,
- }
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ url = "/api/tts_get_url"
+ data |= {
+ "message": "There is someone at the door.",
+ "language": language,
+ }
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_6e8b81ac47_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
assert mock_process_tts.call_count == 1
assert mock_process_tts.call_args is not None
@@ -667,22 +643,18 @@ async def test_deprecated_gender(
# Test with deprecated gender option.
data["options"] = {"gender": gender_option}
- req = await client.post(url, json=data)
- assert req.status == HTTPStatus.OK
- response = await req.json()
+ with patch(
+ "homeassistant.components.tts.secrets.token_urlsafe", return_value="test_token"
+ ):
+ req = await client.post(url, json=data)
+ assert req.status == HTTPStatus.OK
+ response = await req.json()
- assert response == {
- "url": (
- "http://example.local:8123/api/tts_proxy/"
- "42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_dd0e95eb04_{expected_url_suffix}.mp3"
- ),
- "path": (
- "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491"
- f"_{language.lower()}_dd0e95eb04_{expected_url_suffix}.mp3"
- ),
- }
- await hass.async_block_till_done()
+ assert response == {
+ "url": ("http://example.local:8123/api/tts_proxy/test_token.mp3"),
+ "path": ("/api/tts_proxy/test_token.mp3"),
+ }
+ await hass.async_block_till_done()
issue_id = "deprecated_gender"
diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py
index 7b603420bdf..23ba5e7808c 100644
--- a/tests/components/color_extractor/test_service.py
+++ b/tests/components/color_extractor/test_service.py
@@ -78,7 +78,7 @@ async def setup_light(hass: HomeAssistant):
# Validate starting values
assert state.state == STATE_ON
assert state.attributes.get(ATTR_BRIGHTNESS) == 180
- assert state.attributes.get(ATTR_RGB_COLOR) == (255, 63, 111)
+ assert state.attributes.get(ATTR_RGB_COLOR) == (255, 64, 112)
await hass.services.async_call(
LIGHT_DOMAIN,
diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py
index 5d1cd845e27..aa49410aacb 100644
--- a/tests/components/command_line/test_binary_sensor.py
+++ b/tests/components/command_line/test_binary_sensor.py
@@ -87,7 +87,7 @@ async def test_setup_platform_yaml(hass: HomeAssistant) -> None:
"payload_off": "0",
"value_template": "{{ value | multiply(0.1) }}",
"icon": (
- '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}'
+ '{% if this.attributes.icon=="mdi:icon2" %} mdi:icon1 {% else %} mdi:icon2 {% endif %}'
),
}
}
@@ -101,7 +101,15 @@ async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> Non
entity_state = hass.states.get("binary_sensor.test")
assert entity_state
assert entity_state.state == STATE_ON
- assert entity_state.attributes.get("icon") == "mdi:on"
+ assert entity_state.attributes.get("icon") == "mdi:icon2"
+
+ async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=30))
+ await hass.async_block_till_done(wait_background_tasks=True)
+
+ entity_state = hass.states.get("binary_sensor.test")
+ assert entity_state
+ assert entity_state.state == STATE_ON
+ assert entity_state.attributes.get("icon") == "mdi:icon1"
@pytest.mark.parametrize(
diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py
index da9d86ba8a5..426968eccc5 100644
--- a/tests/components/command_line/test_cover.py
+++ b/tests/components/command_line/test_cover.py
@@ -422,13 +422,19 @@ async def test_icon_template(hass: HomeAssistant) -> None:
"command_close": f"echo 0 > {path}",
"command_stop": f"echo 0 > {path}",
"name": "Test",
- "icon": "{% if this.state=='open' %} mdi:open {% else %} mdi:closed {% endif %}",
+ "icon": '{% if this.attributes.icon=="mdi:icon2" %} mdi:icon1 {% else %} mdi:icon2 {% endif %}',
}
}
]
},
)
await hass.async_block_till_done()
+ await hass.services.async_call(
+ COVER_DOMAIN,
+ SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: "cover.test"},
+ blocking=True,
+ )
await hass.services.async_call(
COVER_DOMAIN,
@@ -438,7 +444,7 @@ async def test_icon_template(hass: HomeAssistant) -> None:
)
entity_state = hass.states.get("cover.test")
assert entity_state
- assert entity_state.attributes.get("icon") == "mdi:closed"
+ assert entity_state.attributes.get("icon") == "mdi:icon1"
await hass.services.async_call(
COVER_DOMAIN,
@@ -448,4 +454,4 @@ async def test_icon_template(hass: HomeAssistant) -> None:
)
entity_state = hass.states.get("cover.test")
assert entity_state
- assert entity_state.attributes.get("icon") == "mdi:open"
+ assert entity_state.attributes.get("icon") == "mdi:icon2"
diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py
index 549e729892c..d62410fa792 100644
--- a/tests/components/command_line/test_switch.py
+++ b/tests/components/command_line/test_switch.py
@@ -552,7 +552,7 @@ async def test_templating(hass: HomeAssistant) -> None:
"command_off": f"echo 0 > {path}",
"value_template": '{{ value=="1" }}',
"icon": (
- '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}'
+ '{% if this.attributes.icon=="mdi:icon2" %} mdi:icon1 {% else %} mdi:icon2 {% endif %}'
),
"name": "Test",
}
@@ -564,7 +564,7 @@ async def test_templating(hass: HomeAssistant) -> None:
"command_off": f"echo 0 > {path}",
"value_template": '{{ value=="1" }}',
"icon": (
- '{% if states("switch.test2")=="on" %} mdi:on {% else %} mdi:off {% endif %}'
+ '{% if states("switch.test")=="off" %} mdi:off {% else %} mdi:on {% endif %}'
),
"name": "Test2",
},
@@ -595,7 +595,7 @@ async def test_templating(hass: HomeAssistant) -> None:
entity_state = hass.states.get("switch.test")
entity_state2 = hass.states.get("switch.test2")
assert entity_state.state == STATE_ON
- assert entity_state.attributes.get("icon") == "mdi:on"
+ assert entity_state.attributes.get("icon") == "mdi:icon2"
assert entity_state2.state == STATE_ON
assert entity_state2.attributes.get("icon") == "mdi:on"
diff --git a/tests/components/conftest.py b/tests/components/conftest.py
index 08bd16d1f7b..97b1d337e82 100644
--- a/tests/components/conftest.py
+++ b/tests/components/conftest.py
@@ -27,13 +27,14 @@ from homeassistant.config_entries import (
OptionsFlowManager,
)
from homeassistant.const import STATE_OFF, STATE_ON
-from homeassistant.core import HomeAssistant
+from homeassistant.core import Context, HomeAssistant, ServiceRegistry, ServiceResponse
from homeassistant.data_entry_flow import (
FlowContext,
FlowHandler,
FlowManager,
FlowResultType,
)
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.translation import async_get_translations
@@ -515,6 +516,7 @@ def supervisor_client() -> Generator[AsyncMock]:
supervisor_client.addons = AsyncMock()
supervisor_client.discovery = AsyncMock()
supervisor_client.homeassistant = AsyncMock()
+ supervisor_client.host = AsyncMock()
supervisor_client.os = AsyncMock()
supervisor_client.resolution = AsyncMock()
supervisor_client.supervisor = AsyncMock()
@@ -713,6 +715,23 @@ async def _check_create_issue_translations(
)
+async def _check_exception_translation(
+ hass: HomeAssistant,
+ exception: HomeAssistantError,
+ translation_errors: dict[str, str],
+) -> None:
+ if exception.translation_key is None:
+ return
+ await _validate_translation(
+ hass,
+ translation_errors,
+ "exceptions",
+ exception.translation_domain,
+ f"{exception.translation_key}.message",
+ exception.translation_placeholders,
+ )
+
+
@pytest.fixture(autouse=True)
async def check_translations(
ignore_translations: str | list[str],
@@ -733,6 +752,7 @@ async def check_translations(
# Keep reference to original functions
_original_flow_manager_async_handle_step = FlowManager._async_handle_step
_original_issue_registry_async_create_issue = ir.IssueRegistry.async_get_or_create
+ _original_service_registry_async_call = ServiceRegistry.async_call
# Prepare override functions
async def _flow_manager_async_handle_step(
@@ -755,6 +775,33 @@ async def check_translations(
)
return result
+ async def _service_registry_async_call(
+ self: ServiceRegistry,
+ domain: str,
+ service: str,
+ service_data: dict[str, Any] | None = None,
+ blocking: bool = False,
+ context: Context | None = None,
+ target: dict[str, Any] | None = None,
+ return_response: bool = False,
+ ) -> ServiceResponse:
+ try:
+ return await _original_service_registry_async_call(
+ self,
+ domain,
+ service,
+ service_data,
+ blocking,
+ context,
+ target,
+ return_response,
+ )
+ except HomeAssistantError as err:
+ translation_coros.add(
+ _check_exception_translation(self._hass, err, translation_errors)
+ )
+ raise
+
# Use override functions
with (
patch(
@@ -765,6 +812,10 @@ async def check_translations(
"homeassistant.helpers.issue_registry.IssueRegistry.async_get_or_create",
_issue_registry_async_create_issue,
),
+ patch(
+ "homeassistant.core.ServiceRegistry.async_call",
+ _service_registry_async_call,
+ ),
):
yield
diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr
index b1f2ea0db75..f1e220b10b2 100644
--- a/tests/components/conversation/snapshots/test_default_agent.ambr
+++ b/tests/components/conversation/snapshots/test_default_agent.ambr
@@ -308,7 +308,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
- 'speech': 'Sorry, I am not aware of any device called late added light',
+ 'speech': 'Sorry, I am not aware of any area called late added',
}),
}),
}),
@@ -378,7 +378,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
- 'speech': 'Sorry, I am not aware of any device called kitchen light',
+ 'speech': 'Sorry, I am not aware of any area called kitchen',
}),
}),
}),
@@ -428,7 +428,7 @@
'speech': dict({
'plain': dict({
'extra_data': None,
- 'speech': 'Sorry, I am not aware of any device called renamed light',
+ 'speech': 'Sorry, I am not aware of any area called renamed',
}),
}),
}),
diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr
index d9d859113f8..a3edd4fa51c 100644
--- a/tests/components/conversation/snapshots/test_http.ambr
+++ b/tests/components/conversation/snapshots/test_http.ambr
@@ -6,7 +6,6 @@
'id': 'conversation.home_assistant',
'name': 'Home Assistant',
'supported_languages': list([
- 'af',
'ar',
'bg',
'bn',
@@ -24,22 +23,18 @@
'fi',
'fr',
'gl',
- 'gu',
'he',
- 'hi',
'hr',
'hu',
'id',
'is',
'it',
'ka',
- 'kn',
'ko',
'lb',
'lt',
'lv',
'ml',
- 'mn',
'ms',
'nb',
'nl',
@@ -52,7 +47,6 @@
'sl',
'sr',
'sv',
- 'sw',
'te',
'th',
'tr',
@@ -541,7 +535,7 @@
'name': 'HassTurnOn',
}),
'match': True,
- 'sentence_template': ' on [all] in ',
+ 'sentence_template': ' on [] ',
'slots': dict({
'area': 'kitchen',
'domain': 'light',
@@ -612,7 +606,7 @@
'name': 'OrderBeer',
}),
'match': True,
- 'sentence_template': "I'd like to order a {beer_style} [please]",
+ 'sentence_template': "[I'd like to ]order a {beer_style} [please]",
'slots': dict({
'beer_style': 'lager',
}),
diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py
index 3c6b463670a..dab1e61ab81 100644
--- a/tests/components/conversation/test_default_agent.py
+++ b/tests/components/conversation/test_default_agent.py
@@ -397,7 +397,7 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
callback.reset_mock()
result = await conversation.async_converse(hass, sentence, None, Context())
assert callback.call_count == 1
- assert callback.call_args[0][0] == sentence
+ assert callback.call_args[0][0].text == sentence
assert (
result.response.response_type == intent.IntentResponseType.ACTION_DONE
), sentence
@@ -1735,7 +1735,7 @@ async def test_empty_aliases(
return_value=None,
) as mock_recognize_all:
await conversation.async_converse(
- hass, "turn on lights in the kitchen", None, Context(), None
+ hass, "turn on kitchen light", None, Context(), None
)
assert mock_recognize_all.call_count > 0
@@ -2833,3 +2833,183 @@ async def test_query_same_name_different_areas(
assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(result.response.matched_states) == 1
assert result.response.matched_states[0].entity_id == kitchen_light.entity_id
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_intent_cache_exposed(hass: HomeAssistant) -> None:
+ """Test that intent recognition results are cached for exposed entities."""
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+ assert isinstance(agent, default_agent.DefaultAgent)
+
+ entity_id = "light.test_light"
+ hass.states.async_set(entity_id, "off")
+ expose_entity(hass, entity_id, True)
+ await hass.async_block_till_done()
+
+ user_input = ConversationInput(
+ text="turn on test light",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert result.entities["name"].text == "test light"
+
+ # Mark this result so we know it is from cache next time
+ mark = "_from_cache"
+ setattr(result, mark, True)
+
+ # Should be from cache this time
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is True
+
+ # Unexposing clears the cache
+ expose_entity(hass, entity_id, False)
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is None
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_intent_cache_all_entities(hass: HomeAssistant) -> None:
+ """Test that intent recognition results are cached for all entities."""
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+ assert isinstance(agent, default_agent.DefaultAgent)
+
+ entity_id = "light.test_light"
+ hass.states.async_set(entity_id, "off")
+ expose_entity(hass, entity_id, False) # not exposed
+ await hass.async_block_till_done()
+
+ user_input = ConversationInput(
+ text="turn on test light",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert result.entities["name"].text == "test light"
+
+ # Mark this result so we know it is from cache next time
+ mark = "_from_cache"
+ setattr(result, mark, True)
+
+ # Should be from cache this time
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is True
+
+ # Adding a new entity clears the cache
+ hass.states.async_set("light.new_light", "off")
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is None
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_intent_cache_fuzzy(hass: HomeAssistant) -> None:
+ """Test that intent recognition results are cached for fuzzy matches."""
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+ assert isinstance(agent, default_agent.DefaultAgent)
+
+ # There is no entity named test light
+ user_input = ConversationInput(
+ text="turn on test light",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert result.unmatched_entities["area"].text == "test "
+
+ # Mark this result so we know it is from cache next time
+ mark = "_from_cache"
+ setattr(result, mark, True)
+
+ # Should be from cache this time
+ result = await agent.async_recognize_intent(user_input)
+ assert result is not None
+ assert getattr(result, mark, None) is True
+
+
+@pytest.mark.usefixtures("init_components")
+async def test_entities_filtered_by_input(hass: HomeAssistant) -> None:
+ """Test that entities are filtered by the input text before intent matching."""
+ agent = hass.data[DATA_DEFAULT_ENTITY]
+ assert isinstance(agent, default_agent.DefaultAgent)
+
+ # Only the switch is exposed
+ hass.states.async_set("light.test_light", "off")
+ hass.states.async_set(
+ "light.test_light_2", "off", attributes={ATTR_FRIENDLY_NAME: "test light"}
+ )
+ hass.states.async_set("cover.garage_door", "closed")
+ hass.states.async_set("switch.test_switch", "off")
+ expose_entity(hass, "light.test_light", False)
+ expose_entity(hass, "light.test_light_2", False)
+ expose_entity(hass, "cover.garage_door", False)
+ expose_entity(hass, "switch.test_switch", True)
+ await hass.async_block_till_done()
+
+ # test switch is exposed
+ user_input = ConversationInput(
+ text="turn on test switch",
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+
+ with patch(
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=None,
+ ) as recognize_best:
+ await agent.async_recognize_intent(user_input)
+
+ # (1) exposed, (2) all entities
+ assert len(recognize_best.call_args_list) == 2
+
+ # Only the test light should have been considered because its name shows
+ # up in the input text.
+ slot_lists = recognize_best.call_args_list[0].kwargs["slot_lists"]
+ name_list = slot_lists["name"]
+ assert len(name_list.values) == 1
+ assert name_list.values[0].text_in.text == "test switch"
+
+ # test light is not exposed
+ user_input = ConversationInput(
+ text="turn on Test Light", # different casing for name
+ context=Context(),
+ conversation_id=None,
+ device_id=None,
+ language=hass.config.language,
+ agent_id=None,
+ )
+
+ with patch(
+ "homeassistant.components.conversation.default_agent.recognize_best",
+ return_value=None,
+ ) as recognize_best:
+ await agent.async_recognize_intent(user_input)
+
+ # (1) exposed, (2) all entities
+ assert len(recognize_best.call_args_list) == 2
+
+ # Both test lights should have been considered because their name shows
+ # up in the input text.
+ slot_lists = recognize_best.call_args_list[1].kwargs["slot_lists"]
+ name_list = slot_lists["name"]
+ assert len(name_list.values) == 2
+ assert name_list.values[0].text_in.text == "test light"
+ assert name_list.values[1].text_in.text == "test light"
diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py
index 7bae9c43f70..244fa6bda7b 100644
--- a/tests/components/conversation/test_default_agent_intents.py
+++ b/tests/components/conversation/test_default_agent_intents.py
@@ -36,6 +36,7 @@ from homeassistant.helpers import (
intent,
)
from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as dt_util
from tests.common import async_mock_service
@@ -445,12 +446,22 @@ async def test_todo_add_item_fr(
assert intent_obj.slots.get("item", {}).get("value", "").strip() == "farine"
-@freeze_time(datetime(year=2013, month=9, day=17, hour=1, minute=2))
+@freeze_time(
+ datetime(
+ year=2013,
+ month=9,
+ day=17,
+ hour=1,
+ minute=2,
+ tzinfo=dt_util.UTC,
+ )
+)
async def test_date_time(
hass: HomeAssistant,
init_components,
) -> None:
"""Test the date and time intents."""
+ await hass.config.async_set_time_zone("UTC")
result = await conversation.async_converse(
hass, "what is the date", None, Context(), None
)
diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py
index 0100e62cf81..6900ba2d419 100644
--- a/tests/components/conversation/test_init.py
+++ b/tests/components/conversation/test_init.py
@@ -236,12 +236,17 @@ async def test_prepare_agent(
assert len(mock_prepare.mock_calls) == 1
-async def test_async_handle_sentence_triggers(hass: HomeAssistant) -> None:
+@pytest.mark.parametrize(
+ ("response_template", "expected_response"),
+ [("response {{ trigger.device_id }}", "response 1234"), ("", "")],
+)
+async def test_async_handle_sentence_triggers(
+ hass: HomeAssistant, response_template: str, expected_response: str
+) -> None:
"""Test handling sentence triggers with async_handle_sentence_triggers."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "conversation", {})
- response_template = "response {{ trigger.device_id }}"
assert await async_setup_component(
hass,
"automation",
@@ -260,7 +265,6 @@ async def test_async_handle_sentence_triggers(hass: HomeAssistant) -> None:
# Device id will be available in response template
device_id = "1234"
- expected_response = f"response {device_id}"
actual_response = await async_handle_sentence_triggers(
hass,
ConversationInput(
diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py
index 903bc405cf0..50fac51c87a 100644
--- a/tests/components/conversation/test_trigger.py
+++ b/tests/components/conversation/test_trigger.py
@@ -40,18 +40,31 @@ async def test_if_fires_on_event(
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
}
},
)
-
+ context = Context()
service_response = await hass.services.async_call(
"conversation",
"process",
{"text": "Ha ha ha"},
blocking=True,
return_response=True,
+ context=context,
)
assert service_response["response"]["speech"]["plain"]["speech"] == "Done"
@@ -61,13 +74,21 @@ async def test_if_fires_on_event(
assert service_calls[1].service == "automation"
assert service_calls[1].data["data"] == {
"alias": None,
- "id": "0",
- "idx": "0",
+ "id": 0,
+ "idx": 0,
"platform": "conversation",
"sentence": "Ha ha ha",
"slots": {},
"details": {},
"device_id": None,
+ "user_input": {
+ "agent_id": None,
+ "context": context.as_dict(),
+ "conversation_id": None,
+ "device_id": None,
+ "language": "en",
+ "text": "Ha ha ha",
+ },
}
@@ -152,7 +173,19 @@ async def test_response_same_sentence(
{"delay": "0:0:0.100"},
{
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
{"set_conversation_response": "response 2"},
],
@@ -168,13 +201,14 @@ async def test_response_same_sentence(
]
},
)
-
+ context = Context()
service_response = await hass.services.async_call(
"conversation",
"process",
{"text": "test sentence"},
blocking=True,
return_response=True,
+ context=context,
)
await hass.async_block_till_done()
@@ -188,12 +222,20 @@ async def test_response_same_sentence(
assert service_calls[1].data["data"] == {
"alias": None,
"id": "trigger1",
- "idx": "0",
+ "idx": 0,
"platform": "conversation",
"sentence": "test sentence",
"slots": {},
"details": {},
"device_id": None,
+ "user_input": {
+ "agent_id": None,
+ "context": context.as_dict(),
+ "conversation_id": None,
+ "device_id": None,
+ "language": "en",
+ "text": "test sentence",
+ },
}
@@ -231,13 +273,14 @@ async def test_response_same_sentence_with_error(
]
},
)
-
+ context = Context()
service_response = await hass.services.async_call(
"conversation",
"process",
{"text": "test sentence"},
blocking=True,
return_response=True,
+ context=context,
)
await hass.async_block_till_done()
@@ -320,12 +363,24 @@ async def test_same_trigger_multiple_sentences(
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
}
},
)
-
+ context = Context()
await hass.services.async_call(
"conversation",
"process",
@@ -333,6 +388,7 @@ async def test_same_trigger_multiple_sentences(
"text": "hello",
},
blocking=True,
+ context=context,
)
# Only triggers once
@@ -342,13 +398,21 @@ async def test_same_trigger_multiple_sentences(
assert service_calls[1].service == "automation"
assert service_calls[1].data["data"] == {
"alias": None,
- "id": "0",
- "idx": "0",
+ "id": 0,
+ "idx": 0,
"platform": "conversation",
"sentence": "hello",
"slots": {},
"details": {},
"device_id": None,
+ "user_input": {
+ "agent_id": None,
+ "context": context.as_dict(),
+ "conversation_id": None,
+ "device_id": None,
+ "language": "en",
+ "text": "hello",
+ },
}
@@ -371,7 +435,19 @@ async def test_same_sentence_multiple_triggers(
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
},
{
@@ -384,7 +460,19 @@ async def test_same_sentence_multiple_triggers(
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
},
],
@@ -488,12 +576,25 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
},
"action": {
"service": "test.automation",
- "data_template": {"data": "{{ trigger }}"},
+ "data_template": {
+ "data": {
+ "alias": "{{ trigger.alias }}",
+ "id": "{{ trigger.id }}",
+ "idx": "{{ trigger.idx }}",
+ "platform": "{{ trigger.platform }}",
+ "sentence": "{{ trigger.sentence }}",
+ "slots": "{{ trigger.slots }}",
+ "details": "{{ trigger.details }}",
+ "device_id": "{{ trigger.device_id }}",
+ "user_input": "{{ trigger.user_input }}",
+ }
+ },
},
}
},
)
+ context = Context()
await hass.services.async_call(
"conversation",
"process",
@@ -501,6 +602,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
"text": "play the white album by the beatles",
},
blocking=True,
+ context=context,
)
await hass.async_block_till_done()
@@ -509,8 +611,8 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
assert service_calls[1].service == "automation"
assert service_calls[1].data["data"] == {
"alias": None,
- "id": "0",
- "idx": "0",
+ "id": 0,
+ "idx": 0,
"platform": "conversation",
"sentence": "play the white album by the beatles",
"slots": {
@@ -530,6 +632,14 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall])
},
},
"device_id": None,
+ "user_input": {
+ "agent_id": None,
+ "context": context.as_dict(),
+ "conversation_id": None,
+ "device_id": None,
+ "language": "en",
+ "text": "play the white album by the beatles",
+ },
}
diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py
index 6b80dd1ab9a..646c44e4ac2 100644
--- a/tests/components/cover/test_init.py
+++ b/tests/components/cover/test_init.py
@@ -13,11 +13,7 @@ from homeassistant.setup import async_setup_component
from .common import MockCover
-from tests.common import (
- help_test_all,
- import_and_test_deprecated_constant_enum,
- setup_test_component_platform,
-)
+from tests.common import help_test_all, setup_test_component_platform
async def test_services(
@@ -161,22 +157,6 @@ def test_all() -> None:
help_test_all(cover)
-@pytest.mark.parametrize(
- ("enum", "constant_prefix"),
- _create_tuples(cover.CoverEntityFeature, "SUPPORT_")
- + _create_tuples(cover.CoverDeviceClass, "DEVICE_CLASS_"),
-)
-def test_deprecated_constants(
- caplog: pytest.LogCaptureFixture,
- enum: Enum,
- constant_prefix: str,
-) -> None:
- """Test deprecated constants."""
- import_and_test_deprecated_constant_enum(
- caplog, cover, enum, constant_prefix, "2025.1"
- )
-
-
def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None:
"""Test deprecated supported features ints."""
diff --git a/tests/components/cpuspeed/test_config_flow.py b/tests/components/cpuspeed/test_config_flow.py
index 0ebb8aede49..1a68d6f9396 100644
--- a/tests/components/cpuspeed/test_config_flow.py
+++ b/tests/components/cpuspeed/test_config_flow.py
@@ -50,7 +50,7 @@ async def test_already_configured(
)
assert result.get("type") is FlowResultType.ABORT
- assert result.get("reason") == "already_configured"
+ assert result.get("reason") == "single_instance_allowed"
assert len(mock_setup_entry.mock_calls) == 0
assert len(mock_cpuinfo_config_flow.mock_calls) == 0
diff --git a/tests/components/deconz/snapshots/test_light.ambr b/tests/components/deconz/snapshots/test_light.ambr
index a3ec7caac60..b73bbcca216 100644
--- a/tests/components/deconz/snapshots/test_light.ambr
+++ b/tests/components/deconz/snapshots/test_light.ambr
@@ -125,7 +125,7 @@
'min_mireds': 153,
'rgb_color': tuple(
255,
- 67,
+ 68,
0,
),
'supported_color_modes': list([
@@ -134,7 +134,7 @@
]),
'supported_features': ,
'xy_color': tuple(
- 0.674,
+ 0.673,
0.322,
),
}),
@@ -283,7 +283,7 @@
'min_mireds': 155,
'rgb_color': tuple(
255,
- 67,
+ 68,
0,
),
'supported_color_modes': list([
@@ -291,7 +291,7 @@
]),
'supported_features': ,
'xy_color': tuple(
- 0.674,
+ 0.673,
0.322,
),
}),
@@ -429,7 +429,7 @@
'min_mireds': 153,
'rgb_color': tuple(
255,
- 67,
+ 68,
0,
),
'supported_color_modes': list([
@@ -438,7 +438,7 @@
]),
'supported_features': ,
'xy_color': tuple(
- 0.674,
+ 0.673,
0.322,
),
}),
@@ -587,7 +587,7 @@
'min_mireds': 155,
'rgb_color': tuple(
255,
- 67,
+ 68,
0,
),
'supported_color_modes': list([
@@ -595,7 +595,7 @@
]),
'supported_features': ,
'xy_color': tuple(
- 0.674,
+ 0.673,
0.322,
),
}),
@@ -891,7 +891,7 @@
'min_mireds': 155,
'rgb_color': tuple(
255,
- 67,
+ 68,
0,
),
'supported_color_modes': list([
@@ -899,7 +899,7 @@
]),
'supported_features': ,
'xy_color': tuple(
- 0.674,
+ 0.673,
0.322,
),
}),
@@ -981,7 +981,7 @@
'rgb_color': tuple(
255,
165,
- 84,
+ 85,
),
'supported_color_modes': list([
,
@@ -990,8 +990,8 @@
]),
'supported_features': ,
'xy_color': tuple(
- 0.53,
- 0.388,
+ 0.529,
+ 0.387,
),
}),
'context': ,
@@ -1180,7 +1180,7 @@
'is_deconz_group': False,
'rgb_color': tuple(
243,
- 113,
+ 114,
255,
),
'supported_color_modes': list([
@@ -1189,7 +1189,7 @@
'supported_features': ,
'xy_color': tuple(
0.357,
- 0.188,
+ 0.189,
),
}),
'context': ,
diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py
index 8ce83d87b69..15135a333ce 100644
--- a/tests/components/deconz/test_light.py
+++ b/tests/components/deconz/test_light.py
@@ -830,7 +830,7 @@ async def test_groups(
},
{
"on": True,
- "xy": (0.235, 0.164),
+ "xy": (0.236, 0.166),
},
),
( # Turn on group with short color loop
@@ -845,7 +845,7 @@ async def test_groups(
},
{
"on": True,
- "xy": (0.235, 0.164),
+ "xy": (0.236, 0.166),
},
),
],
diff --git a/tests/components/demo/test_light.py b/tests/components/demo/test_light.py
index e3b1efc7eec..8fcdb8a9c2e 100644
--- a/tests/components/demo/test_light.py
+++ b/tests/components/demo/test_light.py
@@ -73,8 +73,8 @@ async def test_state_attributes(hass: HomeAssistant) -> None:
)
state = hass.states.get(ENTITY_LIGHT)
- assert state.attributes.get(ATTR_RGB_COLOR) == (250, 252, 255)
- assert state.attributes.get(ATTR_XY_COLOR) == (0.319, 0.326)
+ assert state.attributes.get(ATTR_RGB_COLOR) == (251, 253, 255)
+ assert state.attributes.get(ATTR_XY_COLOR) == (0.319, 0.327)
await hass.services.async_call(
LIGHT_DOMAIN,
diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py
index 362258b035a..e73c18919c5 100644
--- a/tests/components/device_tracker/test_init.py
+++ b/tests/components/device_tracker/test_init.py
@@ -5,7 +5,6 @@ from datetime import datetime, timedelta
import json
import logging
import os
-from types import ModuleType
from unittest.mock import call, patch
import pytest
@@ -37,8 +36,6 @@ from .common import MockScanner, mock_legacy_device_tracker_setup
from tests.common import (
assert_setup_component,
async_fire_time_changed,
- help_test_all,
- import_and_test_deprecated_constant_enum,
mock_registry,
mock_restore_cache,
patch_yaml_files,
@@ -739,28 +736,3 @@ def test_see_schema_allowing_ios_calls() -> None:
"hostname": "beer",
}
)
-
-
-@pytest.mark.parametrize(
- "module",
- [device_tracker, device_tracker.const],
-)
-def test_all(module: ModuleType) -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(module)
-
-
-@pytest.mark.parametrize(("enum"), list(SourceType))
-@pytest.mark.parametrize(
- "module",
- [device_tracker, device_tracker.const],
-)
-def test_deprecated_constants(
- caplog: pytest.LogCaptureFixture,
- enum: SourceType,
- module: ModuleType,
-) -> None:
- """Test deprecated constants."""
- import_and_test_deprecated_constant_enum(
- caplog, module, enum, "SOURCE_TYPE_", "2025.1"
- )
diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py
index 470ef65fccd..23c4a0f7cee 100644
--- a/tests/components/discovergy/test_config_flow.py
+++ b/tests/components/discovergy/test_config_flow.py
@@ -20,7 +20,7 @@ async def test_form(hass: HomeAssistant, discovergy: AsyncMock) -> None:
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
- assert result["errors"] is None
+ assert result["errors"] == {}
with patch(
"homeassistant.components.discovergy.async_setup_entry",
@@ -51,7 +51,7 @@ async def test_reauth(
config_entry.add_to_hass(hass)
init_result = await config_entry.start_reauth_flow(hass)
assert init_result["type"] is FlowResultType.FORM
- assert init_result["step_id"] == "reauth_confirm"
+ assert init_result["step_id"] == "user"
with patch(
"homeassistant.components.discovergy.async_setup_entry",
@@ -60,7 +60,7 @@ async def test_reauth(
configure_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
{
- CONF_EMAIL: "test@example.com",
+ CONF_EMAIL: "user@example.org",
CONF_PASSWORD: "test-password",
},
)
@@ -111,3 +111,30 @@ async def test_form_fail(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test@example.com"
assert "errors" not in result
+
+
+async def test_reauth_unique_id_mismatch(
+ hass: HomeAssistant, config_entry: MockConfigEntry, discovergy: AsyncMock
+) -> None:
+ """Test reauth flow with unique id mismatch."""
+ config_entry.add_to_hass(hass)
+
+ result = await config_entry.start_reauth_flow(hass)
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ with patch(
+ "homeassistant.components.discovergy.async_setup_entry",
+ return_value=True,
+ ):
+ configure_result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_EMAIL: "user2@example.org",
+ CONF_PASSWORD: "test-password",
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert configure_result["type"] is FlowResultType.ABORT
+ assert configure_result["reason"] == "account_mismatch"
diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py
index 640b6b3e24f..2d48d7e7b4f 100644
--- a/tests/components/dynalite/common.py
+++ b/tests/components/dynalite/common.py
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, call, patch
from dynalite_devices_lib.dynalitebase import DynaliteBaseDevice
from homeassistant.components import dynalite
-from homeassistant.const import ATTR_SERVICE
+from homeassistant.const import ATTR_SERVICE, CONF_HOST
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -34,7 +34,7 @@ async def get_entry_id_from_hass(hass: HomeAssistant) -> str:
async def create_entity_from_device(hass: HomeAssistant, device: DynaliteBaseDevice):
"""Set up the component and platform and create a light based on the device provided."""
host = "1.2.3.4"
- entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host})
entry.add_to_hass(hass)
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices"
diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py
index b0517b89031..ed9296ae685 100644
--- a/tests/components/dynalite/test_bridge.py
+++ b/tests/components/dynalite/test_bridge.py
@@ -17,6 +17,7 @@ from homeassistant.components.dynalite.const import (
ATTR_PACKET,
ATTR_PRESET,
)
+from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -26,7 +27,7 @@ from tests.common import MockConfigEntry
async def test_update_device(hass: HomeAssistant) -> None:
"""Test that update works."""
host = "1.2.3.4"
- entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host})
entry.add_to_hass(hass)
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices"
@@ -56,7 +57,7 @@ async def test_update_device(hass: HomeAssistant) -> None:
async def test_add_devices_then_register(hass: HomeAssistant) -> None:
"""Test that add_devices work."""
host = "1.2.3.4"
- entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host})
entry.add_to_hass(hass)
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices"
@@ -91,7 +92,7 @@ async def test_add_devices_then_register(hass: HomeAssistant) -> None:
async def test_register_then_add_devices(hass: HomeAssistant) -> None:
"""Test that add_devices work after register_add_entities."""
host = "1.2.3.4"
- entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host})
entry.add_to_hass(hass)
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices"
@@ -120,7 +121,7 @@ async def test_register_then_add_devices(hass: HomeAssistant) -> None:
async def test_notifications(hass: HomeAssistant) -> None:
"""Test that update works."""
host = "1.2.3.4"
- entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host})
entry.add_to_hass(hass)
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices"
diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py
index 8bb47fd67e3..20ee42d33b5 100644
--- a/tests/components/dynalite/test_config_flow.py
+++ b/tests/components/dynalite/test_config_flow.py
@@ -7,11 +7,9 @@ import pytest
from homeassistant import config_entries
from homeassistant.components import dynalite
from homeassistant.config_entries import ConfigEntryState
-from homeassistant.const import CONF_PORT
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers import issue_registry as ir
-from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@@ -31,11 +29,8 @@ async def test_flow(
exp_type,
exp_result,
exp_reason,
- issue_registry: ir.IssueRegistry,
) -> None:
"""Run a flow with or without errors and return result."""
- issue = issue_registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml")
- assert issue is None
host = "1.2.3.4"
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
@@ -43,8 +38,8 @@ async def test_flow(
):
result = await hass.config_entries.flow.async_init(
dynalite.DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={dynalite.CONF_HOST: host},
+ context={"source": config_entries.SOURCE_USER},
+ data={CONF_HOST: host},
)
await hass.async_block_till_done()
assert result["type"] == exp_type
@@ -52,51 +47,33 @@ async def test_flow(
assert result["result"].state == exp_result
if exp_reason:
assert result["reason"] == exp_reason
- issue = issue_registry.async_get_issue(
- HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{dynalite.DOMAIN}"
- )
- assert issue is not None
- assert issue.issue_domain == dynalite.DOMAIN
- assert issue.severity == ir.IssueSeverity.WARNING
-
-
-async def test_deprecated(
- hass: HomeAssistant, caplog: pytest.LogCaptureFixture
-) -> None:
- """Check that deprecation warning appears in caplog."""
- await async_setup_component(
- hass, dynalite.DOMAIN, {dynalite.DOMAIN: {dynalite.CONF_HOST: "aaa"}}
- )
- assert "The 'dynalite' option is deprecated" in caplog.text
async def test_existing(hass: HomeAssistant) -> None:
"""Test when the entry exists with the same config."""
host = "1.2.3.4"
- MockConfigEntry(
- domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}
- ).add_to_hass(hass)
+ MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}).add_to_hass(hass)
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
return_value=True,
):
result = await hass.config_entries.flow.async_init(
dynalite.DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={dynalite.CONF_HOST: host},
+ context={"source": config_entries.SOURCE_USER},
+ data={CONF_HOST: host},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
-async def test_existing_update(hass: HomeAssistant) -> None:
+async def test_existing_abort_update(hass: HomeAssistant) -> None:
"""Test when the entry exists with a different config."""
host = "1.2.3.4"
port1 = 7777
port2 = 8888
entry = MockConfigEntry(
domain=dynalite.DOMAIN,
- data={dynalite.CONF_HOST: host, CONF_PORT: port1},
+ data={CONF_HOST: host, CONF_PORT: port1},
)
entry.add_to_hass(hass)
with patch(
@@ -109,12 +86,12 @@ async def test_existing_update(hass: HomeAssistant) -> None:
assert mock_dyn_dev().configure.mock_calls[0][1][0]["port"] == port1
result = await hass.config_entries.flow.async_init(
dynalite.DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={dynalite.CONF_HOST: host, CONF_PORT: port2},
+ context={"source": config_entries.SOURCE_USER},
+ data={CONF_HOST: host, CONF_PORT: port2},
)
await hass.async_block_till_done()
- assert mock_dyn_dev().configure.call_count == 2
- assert mock_dyn_dev().configure.mock_calls[1][1][0]["port"] == port2
+ assert mock_dyn_dev().configure.call_count == 1
+ assert mock_dyn_dev().configure.mock_calls[0][1][0]["port"] == port1
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@@ -123,17 +100,15 @@ async def test_two_entries(hass: HomeAssistant) -> None:
"""Test when two different entries exist with different hosts."""
host1 = "1.2.3.4"
host2 = "5.6.7.8"
- MockConfigEntry(
- domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host1}
- ).add_to_hass(hass)
+ MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host1}).add_to_hass(hass)
with patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
return_value=True,
):
result = await hass.config_entries.flow.async_init(
dynalite.DOMAIN,
- context={"source": config_entries.SOURCE_IMPORT},
- data={dynalite.CONF_HOST: host2},
+ context={"source": config_entries.SOURCE_USER},
+ data={CONF_HOST: host2},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].state is ConfigEntryState.LOADED
@@ -172,9 +147,7 @@ async def test_setup_user(hass: HomeAssistant) -> None:
async def test_setup_user_existing_host(hass: HomeAssistant) -> None:
"""Test that when we setup a host that is defined, we get an error."""
host = "3.4.5.6"
- MockConfigEntry(
- domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}
- ).add_to_hass(hass)
+ MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER}
)
diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py
index 2c15c41e40b..4bf4eb53ad6 100644
--- a/tests/components/dynalite/test_init.py
+++ b/tests/components/dynalite/test_init.py
@@ -6,7 +6,7 @@ import pytest
from voluptuous import MultipleInvalid
import homeassistant.components.dynalite.const as dynalite
-from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM
+from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -20,71 +20,18 @@ async def test_empty_config(hass: HomeAssistant) -> None:
assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 0
-async def test_async_setup(hass: HomeAssistant) -> None:
- """Test a successful setup with all of the different options."""
- with patch(
- "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
- return_value=True,
- ):
- assert await async_setup_component(
- hass,
- dynalite.DOMAIN,
- {
- dynalite.DOMAIN: {
- dynalite.CONF_BRIDGES: [
- {
- CONF_HOST: "1.2.3.4",
- CONF_PORT: 1234,
- dynalite.CONF_AUTO_DISCOVER: True,
- dynalite.CONF_POLL_TIMER: 5.5,
- dynalite.CONF_AREA: {
- "1": {
- CONF_NAME: "Name1",
- dynalite.CONF_CHANNEL: {"4": {}},
- dynalite.CONF_PRESET: {"7": {}},
- dynalite.CONF_NO_DEFAULT: True,
- },
- "2": {CONF_NAME: "Name2"},
- "3": {
- CONF_NAME: "Name3",
- dynalite.CONF_TEMPLATE: CONF_ROOM,
- },
- "4": {
- CONF_NAME: "Name4",
- dynalite.CONF_TEMPLATE: dynalite.CONF_TIME_COVER,
- },
- },
- CONF_DEFAULT: {dynalite.CONF_FADE: 2.3},
- dynalite.CONF_ACTIVE: dynalite.ACTIVE_INIT,
- dynalite.CONF_PRESET: {
- "5": {CONF_NAME: "pres5", dynalite.CONF_FADE: 4.5}
- },
- dynalite.CONF_TEMPLATE: {
- CONF_ROOM: {
- dynalite.CONF_ROOM_ON: 6,
- dynalite.CONF_ROOM_OFF: 7,
- },
- dynalite.CONF_TIME_COVER: {
- dynalite.CONF_OPEN_PRESET: 8,
- dynalite.CONF_CLOSE_PRESET: 9,
- dynalite.CONF_STOP_PRESET: 10,
- dynalite.CONF_CHANNEL_COVER: 3,
- dynalite.CONF_DURATION: 2.2,
- dynalite.CONF_TILT_TIME: 3.3,
- dynalite.CONF_DEVICE_CLASS: "awning",
- },
- },
- }
- ]
- }
- },
- )
- await hass.async_block_till_done()
- assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 1
-
-
async def test_service_request_area_preset(hass: HomeAssistant) -> None:
"""Test requesting and area preset via service call."""
+ entry = MockConfigEntry(
+ domain=dynalite.DOMAIN,
+ data={CONF_HOST: "1.2.3.4"},
+ )
+ entry2 = MockConfigEntry(
+ domain=dynalite.DOMAIN,
+ data={CONF_HOST: "5.6.7.8"},
+ )
+ entry.add_to_hass(hass)
+ entry2.add_to_hass(hass)
with (
patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
@@ -95,20 +42,8 @@ async def test_service_request_area_preset(hass: HomeAssistant) -> None:
return_value=True,
) as mock_req_area_pres,
):
- assert await async_setup_component(
- hass,
- dynalite.DOMAIN,
- {
- dynalite.DOMAIN: {
- dynalite.CONF_BRIDGES: [
- {CONF_HOST: "1.2.3.4"},
- {CONF_HOST: "5.6.7.8"},
- ]
- }
- },
- )
+ assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
- assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 2
await hass.services.async_call(
dynalite.DOMAIN,
"request_area_preset",
@@ -160,6 +95,16 @@ async def test_service_request_area_preset(hass: HomeAssistant) -> None:
async def test_service_request_channel_level(hass: HomeAssistant) -> None:
"""Test requesting the level of a channel via service call."""
+ entry = MockConfigEntry(
+ domain=dynalite.DOMAIN,
+ data={CONF_HOST: "1.2.3.4"},
+ )
+ entry2 = MockConfigEntry(
+ domain=dynalite.DOMAIN,
+ data={CONF_HOST: "5.6.7.8"},
+ )
+ entry.add_to_hass(hass)
+ entry2.add_to_hass(hass)
with (
patch(
"homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
@@ -170,21 +115,7 @@ async def test_service_request_channel_level(hass: HomeAssistant) -> None:
return_value=True,
) as mock_req_chan_lvl,
):
- assert await async_setup_component(
- hass,
- dynalite.DOMAIN,
- {
- dynalite.DOMAIN: {
- dynalite.CONF_BRIDGES: [
- {
- CONF_HOST: "1.2.3.4",
- dynalite.CONF_AREA: {"7": {CONF_NAME: "test"}},
- },
- {CONF_HOST: "5.6.7.8"},
- ]
- }
- },
- )
+ assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 2
await hass.services.async_call(
@@ -212,60 +143,6 @@ async def test_service_request_channel_level(hass: HomeAssistant) -> None:
assert mock_req_chan_lvl.mock_calls == [call(4, 5), call(4, 5)]
-async def test_async_setup_bad_config1(hass: HomeAssistant) -> None:
- """Test a successful with bad config on templates."""
- with patch(
- "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
- return_value=True,
- ):
- assert not await async_setup_component(
- hass,
- dynalite.DOMAIN,
- {
- dynalite.DOMAIN: {
- dynalite.CONF_BRIDGES: [
- {
- CONF_HOST: "1.2.3.4",
- dynalite.CONF_AREA: {
- "1": {
- dynalite.CONF_TEMPLATE: dynalite.CONF_TIME_COVER,
- CONF_NAME: "Name",
- dynalite.CONF_ROOM_ON: 7,
- }
- },
- }
- ]
- }
- },
- )
- await hass.async_block_till_done()
-
-
-async def test_async_setup_bad_config2(hass: HomeAssistant) -> None:
- """Test a successful with bad config on numbers."""
- host = "1.2.3.4"
- with patch(
- "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
- return_value=True,
- ):
- assert not await async_setup_component(
- hass,
- dynalite.DOMAIN,
- {
- dynalite.DOMAIN: {
- dynalite.CONF_BRIDGES: [
- {
- CONF_HOST: host,
- dynalite.CONF_AREA: {"WRONG": {CONF_NAME: "Name"}},
- }
- ]
- }
- },
- )
- await hass.async_block_till_done()
- assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 0
-
-
async def test_unload_entry(hass: HomeAssistant) -> None:
"""Test being able to unload an entry."""
host = "1.2.3.4"
diff --git a/tests/components/dynalite/test_panel.py b/tests/components/dynalite/test_panel.py
index 97752142f0c..a13b27e7567 100644
--- a/tests/components/dynalite/test_panel.py
+++ b/tests/components/dynalite/test_panel.py
@@ -4,7 +4,7 @@ from unittest.mock import patch
from homeassistant.components import dynalite
from homeassistant.components.cover import DEVICE_CLASSES
-from homeassistant.const import CONF_PORT
+from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -20,7 +20,7 @@ async def test_get_config(
entry = MockConfigEntry(
domain=dynalite.DOMAIN,
- data={dynalite.CONF_HOST: host, CONF_PORT: port},
+ data={CONF_HOST: host, CONF_PORT: port},
)
entry.add_to_hass(hass)
with patch(
@@ -44,7 +44,7 @@ async def test_get_config(
result = msg["result"]
entry_id = entry.entry_id
assert result == {
- "config": {entry_id: {dynalite.CONF_HOST: host, CONF_PORT: port}},
+ "config": {entry_id: {CONF_HOST: host, CONF_PORT: port}},
"default": {
"DEFAULT_NAME": dynalite.const.DEFAULT_NAME,
"DEFAULT_PORT": dynalite.const.DEFAULT_PORT,
@@ -66,7 +66,7 @@ async def test_save_config(
entry1 = MockConfigEntry(
domain=dynalite.DOMAIN,
- data={dynalite.CONF_HOST: host1, CONF_PORT: port1},
+ data={CONF_HOST: host1, CONF_PORT: port1},
)
entry1.add_to_hass(hass)
with patch(
@@ -77,7 +77,7 @@ async def test_save_config(
await hass.async_block_till_done()
entry2 = MockConfigEntry(
domain=dynalite.DOMAIN,
- data={dynalite.CONF_HOST: host2, CONF_PORT: port2},
+ data={CONF_HOST: host2, CONF_PORT: port2},
)
entry2.add_to_hass(hass)
with patch(
@@ -94,7 +94,7 @@ async def test_save_config(
"id": 24,
"type": "dynalite/save-config",
"entry_id": entry2.entry_id,
- "config": {dynalite.CONF_HOST: host3, CONF_PORT: port3},
+ "config": {CONF_HOST: host3, CONF_PORT: port3},
}
)
@@ -103,9 +103,9 @@ async def test_save_config(
assert msg["result"] == {}
existing_entry = hass.config_entries.async_get_entry(entry1.entry_id)
- assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1}
+ assert existing_entry.data == {CONF_HOST: host1, CONF_PORT: port1}
modified_entry = hass.config_entries.async_get_entry(entry2.entry_id)
- assert modified_entry.data[dynalite.CONF_HOST] == host3
+ assert modified_entry.data[CONF_HOST] == host3
assert modified_entry.data[CONF_PORT] == port3
@@ -120,7 +120,7 @@ async def test_save_config_invalid_entry(
entry = MockConfigEntry(
domain=dynalite.DOMAIN,
- data={dynalite.CONF_HOST: host1, CONF_PORT: port1},
+ data={CONF_HOST: host1, CONF_PORT: port1},
)
entry.add_to_hass(hass)
with patch(
@@ -136,7 +136,7 @@ async def test_save_config_invalid_entry(
"id": 24,
"type": "dynalite/save-config",
"entry_id": "junk",
- "config": {dynalite.CONF_HOST: host2, CONF_PORT: port2},
+ "config": {CONF_HOST: host2, CONF_PORT: port2},
}
)
@@ -145,4 +145,4 @@ async def test_save_config_invalid_entry(
assert msg["result"] == {"error": True}
existing_entry = hass.config_entries.async_get_entry(entry.entry_id)
- assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1}
+ assert existing_entry.data == {CONF_HOST: host1, CONF_PORT: port1}
diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr
index 659edfde2cf..9c76c00b5b7 100644
--- a/tests/components/ecovacs/snapshots/test_sensor.ambr
+++ b/tests/components/ecovacs/snapshots/test_sensor.ambr
@@ -177,14 +177,14 @@
'supported_features': 0,
'translation_key': 'stats_area',
'unique_id': '8516fbb1-17f1-4194-0000000_stats_area',
- 'unit_of_measurement': 'm²',
+ 'unit_of_measurement': ,
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Goat G1 Area cleaned',
- 'unit_of_measurement': 'm²',
+ 'unit_of_measurement': ,
}),
'context': ,
'entity_id': 'sensor.goat_g1_area_cleaned',
@@ -512,7 +512,7 @@
'supported_features': 0,
'translation_key': 'total_stats_area',
'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_area',
- 'unit_of_measurement': 'm²',
+ 'unit_of_measurement': ,
})
# ---
# name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:state]
@@ -520,7 +520,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'Goat G1 Total area cleaned',
'state_class': ,
- 'unit_of_measurement': 'm²',
+ 'unit_of_measurement': ,
}),
'context': ,
'entity_id': 'sensor.goat_g1_total_area_cleaned',
@@ -755,14 +755,14 @@
'supported_features': 0,
'translation_key': 'stats_area',
'unique_id': 'E1234567890000000001_stats_area',
- 'unit_of_measurement': 'm²',
+ 'unit_of_measurement': ,
})
# ---
# name: test_sensors[yna5x1][sensor.ozmo_950_area_cleaned:state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Ozmo 950 Area cleaned',
- 'unit_of_measurement': 'm²',
+ 'unit_of_measurement': ,
}),
'context': ,
'entity_id': 'sensor.ozmo_950_area_cleaned',
@@ -1137,7 +1137,7 @@
'supported_features': 0,
'translation_key': 'total_stats_area',
'unique_id': 'E1234567890000000001_total_stats_area',
- 'unit_of_measurement': 'm²',
+ 'unit_of_measurement': ,
})
# ---
# name: test_sensors[yna5x1][sensor.ozmo_950_total_area_cleaned:state]
@@ -1145,7 +1145,7 @@
'attributes': ReadOnlyDict({
'friendly_name': 'Ozmo 950 Total area cleaned',
'state_class': ,
- 'unit_of_measurement': 'm²',
+ 'unit_of_measurement': ,
}),
'context': ,
'entity_id': 'sensor.ozmo_950_total_area_cleaned',
diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr
index c3ab076ded2..009feefc145 100644
--- a/tests/components/elgato/snapshots/test_light.ambr
+++ b/tests/components/elgato/snapshots/test_light.ambr
@@ -17,7 +17,7 @@
'min_mireds': 143,
'rgb_color': tuple(
255,
- 188,
+ 189,
133,
),
'supported_color_modes': list([
@@ -25,8 +25,8 @@
]),
'supported_features': ,
'xy_color': tuple(
- 0.465,
- 0.376,
+ 0.464,
+ 0.377,
),
}),
'context': ,
@@ -132,7 +132,7 @@
'min_mireds': 153,
'rgb_color': tuple(
255,
- 188,
+ 189,
133,
),
'supported_color_modes': list([
@@ -141,8 +141,8 @@
]),
'supported_features': ,
'xy_color': tuple(
- 0.465,
- 0.376,
+ 0.464,
+ 0.377,
),
}),
'context': ,
@@ -249,7 +249,7 @@
'min_mireds': 153,
'rgb_color': tuple(
255,
- 239,
+ 240,
240,
),
'supported_color_modes': list([
@@ -258,8 +258,8 @@
]),
'supported_features': ,
'xy_color': tuple(
- 0.34,
- 0.327,
+ 0.339,
+ 0.328,
),
}),
'context': ,
diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr
index f6a2745fb1a..210196ce414 100644
--- a/tests/components/emoncms/snapshots/test_sensor.ambr
+++ b/tests/components/emoncms/snapshots/test_sensor.ambr
@@ -1,5 +1,5 @@
# serializer version: 1
-# name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-entry]
+# name: test_coordinator_update[sensor.temperature_tag_parameter_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -13,8 +13,8 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
- 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1',
- 'has_entity_name': False,
+ 'entity_id': 'sensor.temperature_tag_parameter_1',
+ 'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': ,
@@ -25,16 +25,16 @@
}),
'original_device_class': ,
'original_icon': None,
- 'original_name': 'emoncms@1.1.1.1 parameter 1',
+ 'original_name': 'Temperature tag parameter 1',
'platform': 'emoncms',
'previous_unique_id': None,
'supported_features': 0,
- 'translation_key': None,
+ 'translation_key': 'temperature',
'unique_id': '123-53535292-1',
'unit_of_measurement': ,
})
# ---
-# name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-state]
+# name: test_coordinator_update[sensor.temperature_tag_parameter_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'FeedId': '1',
@@ -45,12 +45,12 @@
'Tag': 'tag',
'UserId': '1',
'device_class': 'temperature',
- 'friendly_name': 'emoncms@1.1.1.1 parameter 1',
+ 'friendly_name': 'Temperature tag parameter 1',
'state_class': ,
'unit_of_measurement': ,
}),
'context': ,
- 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1',
+ 'entity_id': 'sensor.temperature_tag_parameter_1',
'last_changed': ,
'last_reported': ,
'last_updated': ,
diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py
index e8344e50161..5ca333df1e2 100644
--- a/tests/components/esphome/test_assist_satellite.py
+++ b/tests/components/esphome/test_assist_satellite.py
@@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import replace
import io
import socket
-from unittest.mock import ANY, Mock, patch
+from unittest.mock import ANY, AsyncMock, Mock, patch
import wave
from aioesphomeapi import (
@@ -42,6 +42,10 @@ from homeassistant.components.esphome.assist_satellite import (
VoiceAssistantUDPServer,
)
from homeassistant.components.media_source import PlayMedia
+from homeassistant.components.select import (
+ DOMAIN as SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+)
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, intent as intent_helper
@@ -1473,3 +1477,194 @@ async def test_get_set_configuration(
# Device should have been updated
assert satellite.async_get_configuration() == updated_config
+
+
+async def test_wake_word_select(
+ hass: HomeAssistant,
+ mock_client: APIClient,
+ mock_esphome_device: Callable[
+ [APIClient, list[EntityInfo], list[UserService], list[EntityState]],
+ Awaitable[MockESPHomeDevice],
+ ],
+) -> None:
+ """Test wake word select."""
+ device_config = AssistSatelliteConfiguration(
+ available_wake_words=[
+ AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]),
+ AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]),
+ AssistSatelliteWakeWord("hey_mycroft", "Hey Mycroft", ["en"]),
+ ],
+ active_wake_words=["hey_jarvis"],
+ max_active_wake_words=1,
+ )
+ mock_client.get_voice_assistant_configuration.return_value = device_config
+
+ # Wrap mock so we can tell when it's done
+ configuration_set = asyncio.Event()
+
+ async def wrapper(*args, **kwargs):
+ # Update device config because entity will request it after update
+ device_config.active_wake_words = kwargs["active_wake_words"]
+ configuration_set.set()
+
+ mock_client.set_voice_assistant_configuration = AsyncMock(side_effect=wrapper)
+
+ mock_device: MockESPHomeDevice = await mock_esphome_device(
+ mock_client=mock_client,
+ entity_info=[],
+ user_service=[],
+ states=[],
+ device_info={
+ "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT
+ | VoiceAssistantFeature.ANNOUNCE
+ },
+ )
+ await hass.async_block_till_done()
+
+ satellite = get_satellite_entity(hass, mock_device.device_info.mac_address)
+ assert satellite is not None
+ assert satellite.async_get_configuration().active_wake_words == ["hey_jarvis"]
+
+ # Active wake word should be selected
+ state = hass.states.get("select.test_wake_word")
+ assert state is not None
+ assert state.state == "Hey Jarvis"
+
+ # Changing the select should set the active wake word
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {"entity_id": "select.test_wake_word", "option": "Okay Nabu"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("select.test_wake_word")
+ assert state is not None
+ assert state.state == "Okay Nabu"
+
+ # Wait for device config to be updated
+ async with asyncio.timeout(1):
+ await configuration_set.wait()
+
+ # Satellite config should have been updated
+ assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"]
+
+
+async def test_wake_word_select_no_wake_words(
+ hass: HomeAssistant,
+ mock_client: APIClient,
+ mock_esphome_device: Callable[
+ [APIClient, list[EntityInfo], list[UserService], list[EntityState]],
+ Awaitable[MockESPHomeDevice],
+ ],
+) -> None:
+ """Test wake word select is unavailable when there are no available wake word."""
+ device_config = AssistSatelliteConfiguration(
+ available_wake_words=[],
+ active_wake_words=[],
+ max_active_wake_words=1,
+ )
+ mock_client.get_voice_assistant_configuration.return_value = device_config
+
+ mock_device: MockESPHomeDevice = await mock_esphome_device(
+ mock_client=mock_client,
+ entity_info=[],
+ user_service=[],
+ states=[],
+ device_info={
+ "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT
+ | VoiceAssistantFeature.ANNOUNCE
+ },
+ )
+ await hass.async_block_till_done()
+
+ satellite = get_satellite_entity(hass, mock_device.device_info.mac_address)
+ assert satellite is not None
+ assert not satellite.async_get_configuration().available_wake_words
+
+ # Select should be unavailable
+ state = hass.states.get("select.test_wake_word")
+ assert state is not None
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_wake_word_select_zero_max_wake_words(
+ hass: HomeAssistant,
+ mock_client: APIClient,
+ mock_esphome_device: Callable[
+ [APIClient, list[EntityInfo], list[UserService], list[EntityState]],
+ Awaitable[MockESPHomeDevice],
+ ],
+) -> None:
+ """Test wake word select is unavailable max wake words is zero."""
+ device_config = AssistSatelliteConfiguration(
+ available_wake_words=[
+ AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]),
+ ],
+ active_wake_words=[],
+ max_active_wake_words=0,
+ )
+ mock_client.get_voice_assistant_configuration.return_value = device_config
+
+ mock_device: MockESPHomeDevice = await mock_esphome_device(
+ mock_client=mock_client,
+ entity_info=[],
+ user_service=[],
+ states=[],
+ device_info={
+ "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT
+ | VoiceAssistantFeature.ANNOUNCE
+ },
+ )
+ await hass.async_block_till_done()
+
+ satellite = get_satellite_entity(hass, mock_device.device_info.mac_address)
+ assert satellite is not None
+ assert satellite.async_get_configuration().max_active_wake_words == 0
+
+ # Select should be unavailable
+ state = hass.states.get("select.test_wake_word")
+ assert state is not None
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_wake_word_select_no_active_wake_words(
+ hass: HomeAssistant,
+ mock_client: APIClient,
+ mock_esphome_device: Callable[
+ [APIClient, list[EntityInfo], list[UserService], list[EntityState]],
+ Awaitable[MockESPHomeDevice],
+ ],
+) -> None:
+ """Test wake word select uses first available wake word if none are active."""
+ device_config = AssistSatelliteConfiguration(
+ available_wake_words=[
+ AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]),
+ AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]),
+ ],
+ active_wake_words=[],
+ max_active_wake_words=1,
+ )
+ mock_client.get_voice_assistant_configuration.return_value = device_config
+
+ mock_device: MockESPHomeDevice = await mock_esphome_device(
+ mock_client=mock_client,
+ entity_info=[],
+ user_service=[],
+ states=[],
+ device_info={
+ "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT
+ | VoiceAssistantFeature.ANNOUNCE
+ },
+ )
+ await hass.async_block_till_done()
+
+ satellite = get_satellite_entity(hass, mock_device.device_info.mac_address)
+ assert satellite is not None
+ assert not satellite.async_get_configuration().active_wake_words
+
+ # First available wake word should be selected
+ state = hass.states.get("select.test_wake_word")
+ assert state is not None
+ assert state.state == "Okay Nabu"
diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py
index 2324c73b16f..7f275fff4f2 100644
--- a/tests/components/esphome/test_light.py
+++ b/tests/components/esphome/test_light.py
@@ -676,7 +676,7 @@ async def test_light_rgb(
color_mode=LightColorCapability.RGB
| LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS,
- rgb=(pytest.approx(0.32941176470588235), 1.0, 0.0),
+ rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0),
brightness=pytest.approx(0.4980392156862745),
)
]
@@ -814,7 +814,7 @@ async def test_light_rgbw(
| LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS,
white=0,
- rgb=(pytest.approx(0.32941176470588235), 1.0, 0.0),
+ rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0),
brightness=pytest.approx(0.4980392156862745),
)
]
@@ -993,7 +993,7 @@ async def test_light_rgbww_with_cold_warm_white_support(
| LightColorCapability.BRIGHTNESS,
cold_white=0,
warm_white=0,
- rgb=(pytest.approx(0.32941176470588235), 1.0, 0.0),
+ rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0),
brightness=pytest.approx(0.4980392156862745),
)
]
@@ -1226,7 +1226,7 @@ async def test_light_rgbww_without_cold_warm_white_support(
| LightColorCapability.ON_OFF
| LightColorCapability.BRIGHTNESS,
white=0,
- rgb=(pytest.approx(0.32941176470588235), 1.0, 0.0),
+ rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0),
brightness=pytest.approx(0.4980392156862745),
)
]
diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py
index 799666fc66e..42b7e72a06e 100644
--- a/tests/components/esphome/test_media_player.py
+++ b/tests/components/esphome/test_media_player.py
@@ -22,6 +22,7 @@ from homeassistant.components.media_player import (
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_EXTRA,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN as MEDIA_PLAYER_DOMAIN,
@@ -414,3 +415,22 @@ async def test_media_player_proxy(
media_args = mock_client.media_player_command.call_args.kwargs
assert media_args["announcement"]
+
+ # test with bypass_proxy flag
+ mock_async_create_proxy_url.reset_mock()
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN,
+ SERVICE_PLAY_MEDIA,
+ {
+ ATTR_ENTITY_ID: "media_player.test_mymedia_player",
+ ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
+ ATTR_MEDIA_CONTENT_ID: media_url,
+ ATTR_MEDIA_EXTRA: {
+ "bypass_proxy": True,
+ },
+ },
+ blocking=True,
+ )
+ mock_async_create_proxy_url.assert_not_called()
+ media_args = mock_client.media_player_command.call_args.kwargs
+ assert media_args["media_url"] == media_url
diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py
index fbe30afd042..6ae1260a89d 100644
--- a/tests/components/esphome/test_select.py
+++ b/tests/components/esphome/test_select.py
@@ -9,7 +9,7 @@ from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
-from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
@@ -38,6 +38,16 @@ async def test_vad_sensitivity_select(
assert state.state == "default"
+async def test_wake_word_select(
+ hass: HomeAssistant,
+ mock_voice_assistant_v1_entry,
+) -> None:
+ """Test that wake word select is unavailable initially."""
+ state = hass.states.get("select.test_wake_word")
+ assert state is not None
+ assert state.state == STATE_UNAVAILABLE
+
+
async def test_select_generic_entity(
hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry
) -> None:
diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py
index a7dc544a97a..fbb09ab879c 100644
--- a/tests/components/fan/test_init.py
+++ b/tests/components/fan/test_init.py
@@ -4,7 +4,6 @@ from unittest.mock import patch
import pytest
-from homeassistant.components import fan
from homeassistant.components.fan import (
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
@@ -27,8 +26,6 @@ from tests.common import (
MockConfigEntry,
MockModule,
MockPlatform,
- help_test_all,
- import_and_test_deprecated_constant_enum,
mock_integration,
mock_platform,
setup_test_component_platform,
@@ -166,23 +163,6 @@ async def test_preset_mode_validation(
assert exc.value.translation_key == "not_valid_preset_mode"
-def test_all() -> None:
- """Test module.__all__ is correctly set."""
- help_test_all(fan)
-
-
-@pytest.mark.parametrize(("enum"), list(fan.FanEntityFeature))
-def test_deprecated_constants(
- caplog: pytest.LogCaptureFixture,
- enum: fan.FanEntityFeature,
-) -> None:
- """Test deprecated constants."""
- if not FanEntityFeature.TURN_OFF and not FanEntityFeature.TURN_ON:
- import_and_test_deprecated_constant_enum(
- caplog, fan, enum, "SUPPORT_", "2025.1"
- )
-
-
def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None:
"""Test deprecated supported features ints."""
diff --git a/tests/components/feedreader/conftest.py b/tests/components/feedreader/conftest.py
index 8eeb89e00cd..1e7d50c3835 100644
--- a/tests/components/feedreader/conftest.py
+++ b/tests/components/feedreader/conftest.py
@@ -64,6 +64,18 @@ def fixture_feed_only_summary(hass: HomeAssistant) -> bytes:
return load_fixture_bytes("feedreader8.xml")
+@pytest.fixture(name="feed_htmlentities")
+def fixture_feed_htmlentities(hass: HomeAssistant) -> bytes:
+ """Load test feed data with HTML Entities."""
+ return load_fixture_bytes("feedreader9.xml")
+
+
+@pytest.fixture(name="feed_atom_htmlentities")
+def fixture_feed_atom_htmlentities(hass: HomeAssistant) -> bytes:
+ """Load test ATOM feed data with HTML Entities."""
+ return load_fixture_bytes("feedreader10.xml")
+
+
@pytest.fixture(name="events")
async def fixture_events(hass: HomeAssistant) -> list[Event]:
"""Fixture that catches alexa events."""
diff --git a/tests/components/feedreader/fixtures/feedreader10.xml b/tests/components/feedreader/fixtures/feedreader10.xml
new file mode 100644
index 00000000000..17ec8069ae1
--- /dev/null
+++ b/tests/components/feedreader/fixtures/feedreader10.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ 2024-11-18T14:00:00Z
+
+
+
+ urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6
+
+
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a
+ 2024-11-18T14:00:00Z
+
+
+
+
diff --git a/tests/components/feedreader/fixtures/feedreader9.xml b/tests/components/feedreader/fixtures/feedreader9.xml
new file mode 100644
index 00000000000..580a42cbd3f
--- /dev/null
+++ b/tests/components/feedreader/fixtures/feedreader9.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+ http://www.example.com/main.html
+ Mon, 18 Nov 2024 15:00:00 +1000
+ Mon, 18 Nov 2024 15:00:00 +1000
+ 1800
+
+ -
+
+
+ http://www.example.com/link/1
+ GUID 1
+ Mon, 18 Nov 2024 15:00:00 +1000
+
+
+
+
+
diff --git a/tests/components/feedreader/snapshots/test_event.ambr b/tests/components/feedreader/snapshots/test_event.ambr
new file mode 100644
index 00000000000..9cce035ea87
--- /dev/null
+++ b/tests/components/feedreader/snapshots/test_event.ambr
@@ -0,0 +1,27 @@
+# serializer version: 1
+# name: test_event_htmlentities[feed_atom_htmlentities]
+ ReadOnlyDict({
+ 'content': 'Contenido en español',
+ 'description': 'Resumen en español',
+ 'event_type': 'feedreader',
+ 'event_types': list([
+ 'feedreader',
+ ]),
+ 'friendly_name': 'Mock Title',
+ 'link': 'http://example.org/2003/12/13/atom03',
+ 'title': 'Título',
+ })
+# ---
+# name: test_event_htmlentities[feed_htmlentities]
+ ReadOnlyDict({
+ 'content': 'Contenido 1 en español',
+ 'description': 'Descripción 1',
+ 'event_type': 'feedreader',
+ 'event_types': list([
+ 'feedreader',
+ ]),
+ 'friendly_name': 'Mock Title',
+ 'link': 'http://www.example.com/link/1',
+ 'title': 'Título 1',
+ })
+# ---
diff --git a/tests/components/feedreader/test_config_flow.py b/tests/components/feedreader/test_config_flow.py
index 2a434306c0f..e801227293c 100644
--- a/tests/components/feedreader/test_config_flow.py
+++ b/tests/components/feedreader/test_config_flow.py
@@ -246,3 +246,38 @@ async def test_options_flow(hass: HomeAssistant) -> None:
assert result["data"] == {
CONF_MAX_ENTRIES: 10,
}
+
+
+@pytest.mark.parametrize(
+ ("fixture_name", "expected_title"),
+ [
+ ("feed_htmlentities", "RSS en español"),
+ ("feed_atom_htmlentities", "ATOM RSS en español"),
+ ],
+)
+async def test_feed_htmlentities(
+ hass: HomeAssistant,
+ feedparser,
+ setup_entry,
+ fixture_name,
+ expected_title,
+ request: pytest.FixtureRequest,
+) -> None:
+ """Test starting a flow by user from a feed with HTML Entities in the title."""
+ with patch(
+ "homeassistant.components.feedreader.config_flow.feedparser.http.get",
+ side_effect=[request.getfixturevalue(fixture_name)],
+ ):
+ # init user flow
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ # success
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_URL: URL}
+ )
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["title"] == expected_title
diff --git a/tests/components/feedreader/test_event.py b/tests/components/feedreader/test_event.py
index 491c7e38d02..32f8ecb8080 100644
--- a/tests/components/feedreader/test_event.py
+++ b/tests/components/feedreader/test_event.py
@@ -3,6 +3,9 @@
from datetime import timedelta
from unittest.mock import patch
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
from homeassistant.components.feedreader.event import (
ATTR_CONTENT,
ATTR_DESCRIPTION,
@@ -59,3 +62,31 @@ async def test_event_entity(
assert state.attributes[ATTR_LINK] == "http://www.example.com/link/1"
assert state.attributes[ATTR_CONTENT] == "This is a summary"
assert state.attributes[ATTR_DESCRIPTION] == "Description 1"
+
+
+@pytest.mark.parametrize(
+ ("fixture_name"),
+ [
+ ("feed_htmlentities"),
+ ("feed_atom_htmlentities"),
+ ],
+)
+async def test_event_htmlentities(
+ hass: HomeAssistant,
+ snapshot: SnapshotAssertion,
+ fixture_name,
+ request: pytest.FixtureRequest,
+) -> None:
+ """Test feed event entity with HTML Entities."""
+ entry = create_mock_entry(VALID_CONFIG_DEFAULT)
+ entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.components.feedreader.coordinator.feedparser.http.get",
+ side_effect=[request.getfixturevalue(fixture_name)],
+ ):
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("event.mock_title")
+ assert state
+ assert state.attributes == snapshot
diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py
index d7700d79e3b..bc7a66dc86e 100644
--- a/tests/components/feedreader/test_init.py
+++ b/tests/components/feedreader/test_init.py
@@ -12,6 +12,7 @@ import pytest
from homeassistant.components.feedreader.const import DOMAIN
from homeassistant.core import Event, HomeAssistant
+from homeassistant.helpers import device_registry as dr
import homeassistant.util.dt as dt_util
from . import async_setup_config_entry, create_mock_entry
@@ -357,3 +358,23 @@ async def test_feed_errors(
freezer.tick(timedelta(hours=1, seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
+
+
+async def test_feed_atom_htmlentities(
+ hass: HomeAssistant, feed_atom_htmlentities, device_registry: dr.DeviceRegistry
+) -> None:
+ """Test ATOM feed author with HTML Entities."""
+
+ entry = create_mock_entry(VALID_CONFIG_DEFAULT)
+ entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.components.feedreader.coordinator.feedparser.http.get",
+ side_effect=[feed_atom_htmlentities],
+ ):
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ device_entry = device_registry.async_get_device(
+ identifiers={(DOMAIN, entry.entry_id)}
+ )
+ assert device_entry.manufacturer == "Juan Pérez"
diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py
index ac10d4fc79d..1976a8f310b 100644
--- a/tests/components/fibaro/conftest.py
+++ b/tests/components/fibaro/conftest.py
@@ -106,6 +106,29 @@ def mock_cover() -> Mock:
return cover
+@pytest.fixture
+def mock_light() -> Mock:
+ """Fixture for a dimmmable light."""
+ light = Mock()
+ light.fibaro_id = 3
+ light.parent_fibaro_id = 0
+ light.name = "Test light"
+ light.room_id = 1
+ light.dead = False
+ light.visible = True
+ light.enabled = True
+ light.type = "com.fibaro.FGD212"
+ light.base_type = "com.fibaro.device"
+ light.properties = {"manufacturer": ""}
+ light.actions = {"setValue": 1, "on": 0, "off": 0}
+ light.supported_features = {}
+ value_mock = Mock()
+ value_mock.has_value = True
+ value_mock.int_value.return_value = 20
+ light.value = value_mock
+ return light
+
+
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Return the default mocked config entry."""
diff --git a/tests/components/fibaro/test_light.py b/tests/components/fibaro/test_light.py
new file mode 100644
index 00000000000..d0a24e009b7
--- /dev/null
+++ b/tests/components/fibaro/test_light.py
@@ -0,0 +1,57 @@
+"""Test the Fibaro light platform."""
+
+from unittest.mock import Mock, patch
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from .conftest import init_integration
+
+from tests.common import MockConfigEntry
+
+
+async def test_light_setup(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ mock_fibaro_client: Mock,
+ mock_config_entry: MockConfigEntry,
+ mock_light: Mock,
+ mock_room: Mock,
+) -> None:
+ """Test that the light creates an entity."""
+
+ # Arrange
+ mock_fibaro_client.read_rooms.return_value = [mock_room]
+ mock_fibaro_client.read_devices.return_value = [mock_light]
+
+ with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]):
+ # Act
+ await init_integration(hass, mock_config_entry)
+ # Assert
+ entry = entity_registry.async_get("light.room_1_test_light_3")
+ assert entry
+ assert entry.unique_id == "hc2_111111.3"
+ assert entry.original_name == "Room 1 Test light"
+
+
+async def test_light_brightness(
+ hass: HomeAssistant,
+ mock_fibaro_client: Mock,
+ mock_config_entry: MockConfigEntry,
+ mock_light: Mock,
+ mock_room: Mock,
+) -> None:
+ """Test that the light brightness value is translated."""
+
+ # Arrange
+ mock_fibaro_client.read_rooms.return_value = [mock_room]
+ mock_fibaro_client.read_devices.return_value = [mock_light]
+
+ with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]):
+ # Act
+ await init_integration(hass, mock_config_entry)
+ # Assert
+ state = hass.states.get("light.room_1_test_light_3")
+ assert state.attributes["brightness"] == 51
+ assert state.state == "on"
diff --git a/tests/components/filesize/conftest.py b/tests/components/filesize/conftest.py
index ac66af0d22f..09acf7a58cc 100644
--- a/tests/components/filesize/conftest.py
+++ b/tests/components/filesize/conftest.py
@@ -8,21 +8,30 @@ from unittest.mock import patch
import pytest
-from homeassistant.components.filesize.const import DOMAIN
-from homeassistant.const import CONF_FILE_PATH
+from homeassistant.components.filesize.const import DOMAIN, PLATFORMS
+from homeassistant.const import CONF_FILE_PATH, Platform
from . import TEST_FILE_NAME
from tests.common import MockConfigEntry
+@pytest.fixture(name="load_platforms")
+async def patch_platform_constant() -> list[Platform]:
+ """Return list of platforms to load."""
+ return PLATFORMS
+
+
@pytest.fixture
-def mock_config_entry(tmp_path: Path) -> MockConfigEntry:
+def mock_config_entry(
+ tmp_path: Path, load_platforms: list[Platform]
+) -> MockConfigEntry:
"""Return the default mocked config entry."""
test_file = str(tmp_path.joinpath(TEST_FILE_NAME))
return MockConfigEntry(
title=TEST_FILE_NAME,
domain=DOMAIN,
+ entry_id="01JD5CTQMH9FKEFQKZJ8MMBQ3X",
data={CONF_FILE_PATH: test_file},
unique_id=test_file,
)
diff --git a/tests/components/filesize/snapshots/test_sensor.ambr b/tests/components/filesize/snapshots/test_sensor.ambr
new file mode 100644
index 00000000000..339d64acf91
--- /dev/null
+++ b/tests/components/filesize/snapshots/test_sensor.ambr
@@ -0,0 +1,197 @@
+# serializer version: 1
+# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_created-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.mock_file_test_filesize_txt_created',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Created',
+ 'platform': 'filesize',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'created',
+ 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X-created',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_created-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'timestamp',
+ 'friendly_name': 'mock_file_test_filesize.txt Created',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.mock_file_test_filesize_txt_created',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '2024-11-20T18:19:04+00:00',
+ })
+# ---
+# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_last_updated-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.mock_file_test_filesize_txt_last_updated',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Last updated',
+ 'platform': 'filesize',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'last_updated',
+ 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X-last_updated',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_last_updated-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'timestamp',
+ 'friendly_name': 'mock_file_test_filesize.txt Last updated',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.mock_file_test_filesize_txt_last_updated',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '2024-11-20T18:19:24+00:00',
+ })
+# ---
+# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_size-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.mock_file_test_filesize_txt_size',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Size',
+ 'platform': 'filesize',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'size',
+ 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_size-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'data_size',
+ 'friendly_name': 'mock_file_test_filesize.txt Size',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.mock_file_test_filesize_txt_size',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.0',
+ })
+# ---
+# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_size_in_bytes-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.mock_file_test_filesize_txt_size_in_bytes',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Size in bytes',
+ 'platform': 'filesize',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'size_bytes',
+ 'unique_id': '01JD5CTQMH9FKEFQKZJ8MMBQ3X-bytes',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensors[load_platforms0][sensor.mock_file_test_filesize_txt_size_in_bytes-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'data_size',
+ 'friendly_name': 'mock_file_test_filesize.txt Size in bytes',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.mock_file_test_filesize_txt_size_in_bytes',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '4',
+ })
+# ---
diff --git a/tests/components/filesize/test_config_flow.py b/tests/components/filesize/test_config_flow.py
index 4b275e66d02..383b1f596f8 100644
--- a/tests/components/filesize/test_config_flow.py
+++ b/tests/components/filesize/test_config_flow.py
@@ -11,7 +11,7 @@ from homeassistant.const import CONF_FILE_PATH
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
-from . import TEST_FILE_NAME, async_create_file
+from . import TEST_FILE_NAME, TEST_FILE_NAME2, async_create_file
from tests.common import MockConfigEntry
@@ -108,3 +108,119 @@ async def test_flow_fails_on_validation(hass: HomeAssistant, tmp_path: Path) ->
assert result2["data"] == {
CONF_FILE_PATH: test_file,
}
+
+
+async def test_reconfigure_flow(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmp_path: Path
+) -> None:
+ """Test a reconfigure flow."""
+ test_file = str(tmp_path.joinpath(TEST_FILE_NAME2))
+ await async_create_file(hass, test_file)
+ hass.config.allowlist_external_dirs = {tmp_path}
+ mock_config_entry.add_to_hass(hass)
+
+ result = await mock_config_entry.start_reconfigure_flow(hass)
+ assert result["step_id"] == "reconfigure"
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_FILE_PATH: test_file},
+ )
+ await hass.async_block_till_done()
+
+ assert result2["type"] is FlowResultType.ABORT
+ assert result2["reason"] == "reconfigure_successful"
+ assert mock_config_entry.data == {CONF_FILE_PATH: str(test_file)}
+
+
+async def test_unique_id_already_exist_in_reconfigure_flow(
+ hass: HomeAssistant, tmp_path: Path
+) -> None:
+ """Test a reconfigure flow fails when unique id already exist."""
+ test_file = str(tmp_path.joinpath(TEST_FILE_NAME))
+ test_file2 = str(tmp_path.joinpath(TEST_FILE_NAME2))
+ await async_create_file(hass, test_file)
+ await async_create_file(hass, test_file2)
+ hass.config.allowlist_external_dirs = {tmp_path}
+ test_file = str(tmp_path.joinpath(TEST_FILE_NAME))
+ mock_config_entry = MockConfigEntry(
+ title=TEST_FILE_NAME,
+ domain=DOMAIN,
+ data={CONF_FILE_PATH: test_file},
+ unique_id=test_file,
+ )
+ mock_config_entry2 = MockConfigEntry(
+ title=TEST_FILE_NAME2,
+ domain=DOMAIN,
+ data={CONF_FILE_PATH: test_file2},
+ unique_id=test_file2,
+ )
+ mock_config_entry.add_to_hass(hass)
+ mock_config_entry2.add_to_hass(hass)
+
+ result = await mock_config_entry.start_reconfigure_flow(hass)
+ assert result["step_id"] == "reconfigure"
+ assert result["type"] is FlowResultType.FORM
+ assert result["errors"] == {}
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_FILE_PATH: test_file2},
+ )
+ await hass.async_block_till_done()
+
+ assert result2["type"] is FlowResultType.ABORT
+ assert result2["reason"] == "already_configured"
+
+
+async def test_reconfigure_flow_fails_on_validation(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmp_path: Path
+) -> None:
+ """Test config flow errors in reconfigure."""
+ test_file2 = str(tmp_path.joinpath(TEST_FILE_NAME2))
+ hass.config.allowlist_external_dirs = {}
+
+ mock_config_entry.add_to_hass(hass)
+ result = await mock_config_entry.start_reconfigure_flow(hass)
+
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "reconfigure"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_FILE_PATH: test_file2,
+ },
+ )
+
+ assert result["errors"] == {"base": "not_valid"}
+
+ await async_create_file(hass, test_file2)
+
+ with patch(
+ "homeassistant.components.filesize.config_flow.pathlib.Path",
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_FILE_PATH: test_file2,
+ },
+ )
+
+ assert result2["errors"] == {"base": "not_allowed"}
+
+ hass.config.allowlist_external_dirs = {tmp_path}
+ with patch(
+ "homeassistant.components.filesize.config_flow.pathlib.Path",
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_FILE_PATH: test_file2,
+ },
+ )
+
+ assert result2["type"] is FlowResultType.ABORT
+ assert result2["reason"] == "reconfigure_successful"
diff --git a/tests/components/filesize/test_sensor.py b/tests/components/filesize/test_sensor.py
index 880563f0ad8..8292800a861 100644
--- a/tests/components/filesize/test_sensor.py
+++ b/tests/components/filesize/test_sensor.py
@@ -2,14 +2,56 @@
import os
from pathlib import Path
+from unittest.mock import patch
-from homeassistant.const import CONF_FILE_PATH, STATE_UNAVAILABLE
+import pytest
+from syrupy.assertion import SnapshotAssertion
+
+from homeassistant.components.filesize.const import DOMAIN
+from homeassistant.const import CONF_FILE_PATH, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity_component import async_update_entity
from . import TEST_FILE_NAME, async_create_file
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+@pytest.mark.parametrize(
+ "load_platforms",
+ [[Platform.SENSOR]],
+)
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_sensors(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ tmp_path: Path,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test that an invalid path is caught."""
+ testfile = str(tmp_path.joinpath("file.txt"))
+ await async_create_file(hass, testfile)
+ hass.config.allowlist_external_dirs = {tmp_path}
+ mock_config_entry.add_to_hass(hass)
+ hass.config_entries.async_update_entry(
+ mock_config_entry, data={CONF_FILE_PATH: testfile}
+ )
+ with (
+ patch(
+ "os.stat_result.st_mtime",
+ 1732126764.780758,
+ ),
+ patch(
+ "os.stat_result.st_ctime",
+ 1732126744.780758,
+ ),
+ ):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_invalid_path(
@@ -27,7 +69,10 @@ async def test_invalid_path(
async def test_valid_path(
- hass: HomeAssistant, tmp_path: Path, mock_config_entry: MockConfigEntry
+ hass: HomeAssistant,
+ tmp_path: Path,
+ mock_config_entry: MockConfigEntry,
+ device_registry: dr.DeviceRegistry,
) -> None:
"""Test for a valid path."""
testfile = str(tmp_path.joinpath("file.txt"))
@@ -41,10 +86,15 @@ async def test_valid_path(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
- state = hass.states.get("sensor.file_txt_size")
+ state = hass.states.get("sensor.mock_file_test_filesize_txt_size")
assert state
assert state.state == "0.0"
+ device = device_registry.async_get_device(
+ identifiers={(DOMAIN, mock_config_entry.entry_id)}
+ )
+ assert device.name == mock_config_entry.title
+
await hass.async_add_executor_job(os.remove, testfile)
@@ -63,12 +113,12 @@ async def test_state_unavailable(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
- state = hass.states.get("sensor.file_txt_size")
+ state = hass.states.get("sensor.mock_file_test_filesize_txt_size")
assert state
assert state.state == "0.0"
await hass.async_add_executor_job(os.remove, testfile)
- await async_update_entity(hass, "sensor.file_txt_size")
+ await async_update_entity(hass, "sensor.mock_file_test_filesize_txt_size")
- state = hass.states.get("sensor.file_txt_size")
+ state = hass.states.get("sensor.mock_file_test_filesize_txt_size")
assert state.state == STATE_UNAVAILABLE
diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py
index f5a7b310202..c12776eb552 100644
--- a/tests/components/flux_led/test_light.py
+++ b/tests/components/flux_led/test_light.py
@@ -517,7 +517,7 @@ async def test_rgbw_light_auto_on(hass: HomeAssistant) -> None:
# enough resolution to determine which color to display
bulb.async_turn_on.assert_not_called()
bulb.async_set_brightness.assert_not_called()
- bulb.async_set_levels.assert_called_with(2, 0, 0, 0)
+ bulb.async_set_levels.assert_called_with(3, 0, 0, 0)
bulb.async_set_levels.reset_mock()
await hass.services.async_call(
@@ -534,7 +534,7 @@ async def test_rgbw_light_auto_on(hass: HomeAssistant) -> None:
# enough resolution to determine which color to display
bulb.async_turn_on.assert_not_called()
bulb.async_set_brightness.assert_not_called()
- bulb.async_set_levels.assert_called_with(2, 0, 0, 56)
+ bulb.async_set_levels.assert_called_with(3, 0, 0, 56)
bulb.async_set_levels.reset_mock()
bulb.brightness = 128
@@ -652,7 +652,7 @@ async def test_rgbww_light_auto_on(hass: HomeAssistant) -> None:
# which color to display
bulb.async_turn_on.assert_not_called()
bulb.async_set_brightness.assert_not_called()
- bulb.async_set_levels.assert_called_with(2, 0, 0, 0, 0)
+ bulb.async_set_levels.assert_called_with(3, 0, 0, 0, 0)
bulb.async_set_levels.reset_mock()
bulb.brightness = 128
diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py
index 77deb665f5e..7dec640b898 100644
--- a/tests/components/fritz/test_sensor.py
+++ b/tests/components/fritz/test_sensor.py
@@ -43,7 +43,7 @@ async def test_sensor_setup(
async def test_sensor_update_fail(
- hass: HomeAssistant, fc_class_mock, fh_class_mock
+ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fc_class_mock, fh_class_mock
) -> None:
"""Test failed update of Fritz!Tools sensors."""
@@ -53,10 +53,12 @@ async def test_sensor_update_fail(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
- fc_class_mock().call_action_side_effect(FritzConnectionException)
+ fc_class_mock().call_action_side_effect(FritzConnectionException("Boom"))
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300))
await hass.async_block_till_done(wait_background_tasks=True)
+ assert "Error while uptaing the data: Boom" in caplog.text
+
sensors = hass.states.async_all(SENSOR_DOMAIN)
for sensor in sensors:
assert sensor.state == STATE_UNAVAILABLE
diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py
index 5006adedd77..5a682277176 100644
--- a/tests/components/frontend/test_init.py
+++ b/tests/components/frontend/test_init.py
@@ -166,7 +166,7 @@ async def test_frontend_and_static(mock_http_client: TestClient) -> None:
text = await resp.text()
# Test we can retrieve frontend.js
- frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_-]{11}.js)", text)
+ frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_-]{16}.js)", text)
assert frontendjs is not None, text
resp = await mock_http_client.get(frontendjs.groups(0)[0])
@@ -689,7 +689,7 @@ async def test_auth_authorize(mock_http_client: TestClient) -> None:
# Test we can retrieve authorize.js
authorizejs = re.search(
- r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_-]{11}.js)", text
+ r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_-]{16}.js)", text
)
assert authorizejs is not None, text
diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json
index 72d129492bb..600fc46608c 100644
--- a/tests/components/fyta/fixtures/plant_status1.json
+++ b/tests/components/fyta/fixtures/plant_status1.json
@@ -1,13 +1,16 @@
{
"battery_level": 80,
- "battery_status": true,
+ "low_battery": true,
"last_updated": "2023-01-10 10:10:00",
"light": 2,
"light_status": 3,
"nickname": "Gummibaum",
+ "nutrients_status": 3,
"moisture": 61,
"moisture_status": 3,
"sensor_available": true,
+ "sensor_id": "FD:1D:B7:E3:D0:E2",
+ "sensor_update_available": false,
"sw_version": "1.0",
"status": 1,
"online": true,
@@ -15,6 +18,7 @@
"plant_id": 0,
"plant_origin_path": "",
"plant_thumb_path": "",
+ "is_productive_plant": false,
"salinity": 1,
"salinity_status": 4,
"scientific_name": "Ficus elastica",
diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json
index 8ed09532567..c39e2ac8685 100644
--- a/tests/components/fyta/fixtures/plant_status2.json
+++ b/tests/components/fyta/fixtures/plant_status2.json
@@ -1,13 +1,16 @@
{
"battery_level": 80,
- "battery_status": true,
+ "low_battery": true,
"last_updated": "2023-01-02 10:10:00",
"light": 2,
"light_status": 3,
"nickname": "Kakaobaum",
+ "nutrients_status": 3,
"moisture": 61,
"moisture_status": 3,
"sensor_available": true,
+ "sensor_id": "FD:1D:B7:E3:D0:E3",
+ "sensor_update_available": false,
"sw_version": "1.0",
"status": 1,
"online": true,
@@ -15,6 +18,7 @@
"plant_id": 0,
"plant_origin_path": "",
"plant_thumb_path": "",
+ "is_productive_plant": false,
"salinity": 1,
"salinity_status": 4,
"scientific_name": "Theobroma cacao",
diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json
index 6e32ba601ed..58e3e1b86a0 100644
--- a/tests/components/fyta/fixtures/plant_status3.json
+++ b/tests/components/fyta/fixtures/plant_status3.json
@@ -1,13 +1,16 @@
{
"battery_level": 80,
- "battery_status": true,
+ "low_battery": true,
"last_updated": "2023-01-02 10:10:00",
"light": 2,
"light_status": 3,
"nickname": "Tomatenpflanze",
+ "nutrients_status": 0,
"moisture": 61,
"moisture_status": 3,
"sensor_available": true,
+ "sensor_id": "FD:1D:B7:E3:D0:E3",
+ "sensor_update_available": false,
"sw_version": "1.0",
"status": 1,
"online": true,
@@ -15,6 +18,7 @@
"plant_id": 0,
"plant_origin_path": "",
"plant_thumb_path": "",
+ "is_productive_plant": true,
"salinity": 1,
"salinity_status": 4,
"scientific_name": "Solanum lycopersicum",
diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr
index 2af616c6412..eb19797e5b1 100644
--- a/tests/components/fyta/snapshots/test_diagnostics.ambr
+++ b/tests/components/fyta/snapshots/test_diagnostics.ambr
@@ -26,22 +26,34 @@
'plant_data': dict({
'0': dict({
'battery_level': 80.0,
- 'battery_status': True,
+ 'fertilise_last': None,
+ 'fertilise_next': None,
'last_updated': '2023-01-10T10:10:00',
'light': 2.0,
'light_status': 3,
+ 'low_battery': True,
'moisture': 61.0,
'moisture_status': 3,
'name': 'Gummibaum',
+ 'notification_light': False,
+ 'notification_nutrition': False,
+ 'notification_temperature': False,
+ 'notification_water': False,
+ 'nutrients_status': 3,
'online': True,
'ph': None,
'plant_id': 0,
'plant_origin_path': '',
'plant_thumb_path': '',
+ 'productive_plant': False,
+ 'repotted': False,
'salinity': 1.0,
'salinity_status': 4,
'scientific_name': 'Ficus elastica',
'sensor_available': True,
+ 'sensor_id': 'FD:1D:B7:E3:D0:E2',
+ 'sensor_status': 0,
+ 'sensor_update_available': False,
'status': 1,
'sw_version': '1.0',
'temperature': 25.2,
@@ -49,22 +61,34 @@
}),
'1': dict({
'battery_level': 80.0,
- 'battery_status': True,
+ 'fertilise_last': None,
+ 'fertilise_next': None,
'last_updated': '2023-01-02T10:10:00',
'light': 2.0,
'light_status': 3,
+ 'low_battery': True,
'moisture': 61.0,
'moisture_status': 3,
'name': 'Kakaobaum',
+ 'notification_light': False,
+ 'notification_nutrition': False,
+ 'notification_temperature': False,
+ 'notification_water': False,
+ 'nutrients_status': 3,
'online': True,
'ph': 7.0,
'plant_id': 0,
'plant_origin_path': '',
'plant_thumb_path': '',
+ 'productive_plant': False,
+ 'repotted': False,
'salinity': 1.0,
'salinity_status': 4,
'scientific_name': 'Theobroma cacao',
'sensor_available': True,
+ 'sensor_id': 'FD:1D:B7:E3:D0:E3',
+ 'sensor_status': 0,
+ 'sensor_update_available': False,
'status': 1,
'sw_version': '1.0',
'temperature': 25.2,
diff --git a/tests/components/garages_amsterdam/__init__.py b/tests/components/garages_amsterdam/__init__.py
index ff430c0e7b2..f721506b9b0 100644
--- a/tests/components/garages_amsterdam/__init__.py
+++ b/tests/components/garages_amsterdam/__init__.py
@@ -1 +1,12 @@
"""Tests for the Garages Amsterdam integration."""
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+
+async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
+ """Fixture for setting up the integration."""
+ config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(config_entry.entry_id)
diff --git a/tests/components/garages_amsterdam/conftest.py b/tests/components/garages_amsterdam/conftest.py
index fb59ba26569..93190d1d1ee 100644
--- a/tests/components/garages_amsterdam/conftest.py
+++ b/tests/components/garages_amsterdam/conftest.py
@@ -1,32 +1,85 @@
-"""Test helpers."""
+"""Fixtures for Garages Amsterdam integration tests."""
-from unittest.mock import Mock, patch
+from collections.abc import Generator
+from datetime import UTC, datetime
+from unittest.mock import AsyncMock, patch
+from odp_amsterdam import Garage, GarageCategory, VehicleType
import pytest
+from homeassistant.components.garages_amsterdam.const import DOMAIN
-@pytest.fixture(autouse=True)
-def mock_cases():
- """Mock garages_amsterdam garages."""
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+ """Override setup entry."""
with patch(
- "odp_amsterdam.ODPAmsterdam.all_garages",
- return_value=[
- Mock(
+ "homeassistant.components.garages_amsterdam.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ yield mock_setup_entry
+
+
+@pytest.fixture
+def mock_garages_amsterdam() -> Generator[AsyncMock]:
+ """Mock garages_amsterdam garages."""
+ with (
+ patch(
+ "homeassistant.components.garages_amsterdam.ODPAmsterdam",
+ autospec=True,
+ ) as mock_client,
+ patch(
+ "homeassistant.components.garages_amsterdam.config_flow.ODPAmsterdam",
+ new=mock_client,
+ ),
+ ):
+ client = mock_client.return_value
+ client.all_garages.return_value = [
+ Garage(
+ garage_id="test-id-1",
garage_name="IJDok",
+ vehicle=VehicleType.CAR,
+ category=GarageCategory.GARAGE,
+ state="ok",
free_space_short=100,
free_space_long=10,
short_capacity=120,
long_capacity=60,
- state="ok",
+ availability_pct=50.5,
+ longitude=1.111111,
+ latitude=2.222222,
+ updated_at=datetime(2023, 2, 23, 13, 44, 48, tzinfo=UTC),
),
- Mock(
+ Garage(
+ garage_id="test-id-2",
garage_name="Arena",
- free_space_short=200,
- free_space_long=20,
- short_capacity=240,
- long_capacity=80,
+ vehicle=VehicleType.CAR,
+ category=GarageCategory.GARAGE,
state="error",
+ free_space_short=200,
+ free_space_long=None,
+ short_capacity=240,
+ long_capacity=None,
+ availability_pct=83.3,
+ longitude=3.333333,
+ latitude=4.444444,
+ updated_at=datetime(2023, 2, 23, 13, 44, 48, tzinfo=UTC),
),
- ],
- ) as mock_get_garages:
- yield mock_get_garages
+ ]
+ yield client
+
+
+@pytest.fixture
+def mock_config_entry() -> MockConfigEntry:
+ """Return the default mocked config entry."""
+ return MockConfigEntry(
+ title="monitor",
+ domain=DOMAIN,
+ data={
+ "garage_name": "IJDok",
+ },
+ unique_id="unique_thingy",
+ version=1,
+ )
diff --git a/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr
new file mode 100644
index 00000000000..5f6511090ee
--- /dev/null
+++ b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr
@@ -0,0 +1,49 @@
+# serializer version: 1
+# name: test_all_binary_sensors[binary_sensor.ijdok_state-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'binary_sensor',
+ 'entity_category': None,
+ 'entity_id': 'binary_sensor.ijdok_state',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'State',
+ 'platform': 'garages_amsterdam',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'state',
+ 'unique_id': 'IJDok-state',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_all_binary_sensors[binary_sensor.ijdok_state-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by municipality of Amsterdam',
+ 'device_class': 'problem',
+ 'friendly_name': 'IJDok State',
+ }),
+ 'context': ,
+ 'entity_id': 'binary_sensor.ijdok_state',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
diff --git a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr
new file mode 100644
index 00000000000..2c579631bae
--- /dev/null
+++ b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr
@@ -0,0 +1,199 @@
+# serializer version: 1
+# name: test_all_sensors[sensor.ijdok_long_parking_capacity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.ijdok_long_parking_capacity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Long parking capacity',
+ 'platform': 'garages_amsterdam',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'long_capacity',
+ 'unique_id': 'IJDok-long_capacity',
+ 'unit_of_measurement': 'cars',
+ })
+# ---
+# name: test_all_sensors[sensor.ijdok_long_parking_capacity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by municipality of Amsterdam',
+ 'friendly_name': 'IJDok Long parking capacity',
+ 'unit_of_measurement': 'cars',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.ijdok_long_parking_capacity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '60',
+ })
+# ---
+# name: test_all_sensors[sensor.ijdok_long_parking_free_space-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.ijdok_long_parking_free_space',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Long parking free space',
+ 'platform': 'garages_amsterdam',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'free_space_long',
+ 'unique_id': 'IJDok-free_space_long',
+ 'unit_of_measurement': 'cars',
+ })
+# ---
+# name: test_all_sensors[sensor.ijdok_long_parking_free_space-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by municipality of Amsterdam',
+ 'friendly_name': 'IJDok Long parking free space',
+ 'state_class': ,
+ 'unit_of_measurement': 'cars',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.ijdok_long_parking_free_space',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '10',
+ })
+# ---
+# name: test_all_sensors[sensor.ijdok_short_parking_capacity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.ijdok_short_parking_capacity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Short parking capacity',
+ 'platform': 'garages_amsterdam',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'short_capacity',
+ 'unique_id': 'IJDok-short_capacity',
+ 'unit_of_measurement': 'cars',
+ })
+# ---
+# name: test_all_sensors[sensor.ijdok_short_parking_capacity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by municipality of Amsterdam',
+ 'friendly_name': 'IJDok Short parking capacity',
+ 'unit_of_measurement': 'cars',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.ijdok_short_parking_capacity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '120',
+ })
+# ---
+# name: test_all_sensors[sensor.ijdok_short_parking_free_space-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.ijdok_short_parking_free_space',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Short parking free space',
+ 'platform': 'garages_amsterdam',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'free_space_short',
+ 'unique_id': 'IJDok-free_space_short',
+ 'unit_of_measurement': 'cars',
+ })
+# ---
+# name: test_all_sensors[sensor.ijdok_short_parking_free_space-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'attribution': 'Data provided by municipality of Amsterdam',
+ 'friendly_name': 'IJDok Short parking free space',
+ 'state_class': ,
+ 'unit_of_measurement': 'cars',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.ijdok_short_parking_free_space',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '100',
+ })
+# ---
diff --git a/tests/components/garages_amsterdam/test_binary_sensor.py b/tests/components/garages_amsterdam/test_binary_sensor.py
new file mode 100644
index 00000000000..b7d0333f7e3
--- /dev/null
+++ b/tests/components/garages_amsterdam/test_binary_sensor.py
@@ -0,0 +1,31 @@
+"""Tests the binary sensors provided by the Garages Amsterdam integration."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import snapshot_platform
+
+
+async def test_all_binary_sensors(
+ hass: HomeAssistant,
+ mock_garages_amsterdam: AsyncMock,
+ mock_config_entry: AsyncMock,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test all binary sensors."""
+ with patch(
+ "homeassistant.components.garages_amsterdam.PLATFORMS", [Platform.BINARY_SENSOR]
+ ):
+ await setup_integration(hass, mock_config_entry)
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
diff --git a/tests/components/garages_amsterdam/test_config_flow.py b/tests/components/garages_amsterdam/test_config_flow.py
index 729d31e413c..68950c96cf0 100644
--- a/tests/components/garages_amsterdam/test_config_flow.py
+++ b/tests/components/garages_amsterdam/test_config_flow.py
@@ -1,39 +1,40 @@
"""Test the Garages Amsterdam config flow."""
from http import HTTPStatus
-from unittest.mock import patch
+from unittest.mock import AsyncMock, patch
from aiohttp import ClientResponseError
import pytest
-from homeassistant import config_entries
from homeassistant.components.garages_amsterdam.const import DOMAIN
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
-async def test_full_flow(hass: HomeAssistant) -> None:
- """Test we get the form."""
+async def test_full_user_flow(
+ hass: HomeAssistant,
+ mock_garages_amsterdam: AsyncMock,
+ mock_setup_entry: AsyncMock,
+) -> None:
+ """Test the full user configuration flow."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}
+ DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") is FlowResultType.FORM
+ assert result.get("step_id") == "user"
+ assert not result.get("errors")
- with patch(
- "homeassistant.components.garages_amsterdam.async_setup_entry",
- return_value=True,
- ) as mock_setup_entry:
- result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- {"garage_name": "IJDok"},
- )
- await hass.async_block_till_done()
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={"garage_name": "IJDok"},
+ )
- assert result2.get("type") is FlowResultType.CREATE_ENTRY
- assert result2.get("title") == "IJDok"
- assert "result" in result2
- assert result2["result"].unique_id == "IJDok"
+ assert result.get("type") is FlowResultType.CREATE_ENTRY
+ assert result.get("title") == "IJDok"
+ assert result.get("data") == {"garage_name": "IJDok"}
+ assert len(mock_garages_amsterdam.all_garages.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
@@ -50,14 +51,14 @@ async def test_full_flow(hass: HomeAssistant) -> None:
async def test_error_handling(
side_effect: Exception, reason: str, hass: HomeAssistant
) -> None:
- """Test we get the form."""
+ """Test error handling in the config flow."""
with patch(
"homeassistant.components.garages_amsterdam.config_flow.ODPAmsterdam.all_garages",
side_effect=side_effect,
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}
+ DOMAIN, context={"source": SOURCE_USER}
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == reason
diff --git a/tests/components/garages_amsterdam/test_init.py b/tests/components/garages_amsterdam/test_init.py
new file mode 100644
index 00000000000..ed5469e5ff9
--- /dev/null
+++ b/tests/components/garages_amsterdam/test_init.py
@@ -0,0 +1,26 @@
+"""Tests for the Garages Amsterdam integration."""
+
+from unittest.mock import AsyncMock
+
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+
+async def test_load_unload_config_entry(
+ hass: HomeAssistant,
+ mock_config_entry: MockConfigEntry,
+ mock_garages_amsterdam: AsyncMock,
+) -> None:
+ """Test the Garages Amsterdam integration loads and unloads correctly."""
+ mock_config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_config_entry.state is ConfigEntryState.LOADED
+
+ await hass.config_entries.async_unload(mock_config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
diff --git a/tests/components/garages_amsterdam/test_sensor.py b/tests/components/garages_amsterdam/test_sensor.py
new file mode 100644
index 00000000000..bc36401ea47
--- /dev/null
+++ b/tests/components/garages_amsterdam/test_sensor.py
@@ -0,0 +1,31 @@
+"""Tests the sensors provided by the Garages Amsterdam integration."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import snapshot_platform
+
+
+async def test_all_sensors(
+ hass: HomeAssistant,
+ mock_garages_amsterdam: AsyncMock,
+ mock_config_entry: AsyncMock,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test all sensors."""
+ with patch(
+ "homeassistant.components.garages_amsterdam.PLATFORMS", [Platform.SENSOR]
+ ):
+ await setup_integration(hass, mock_config_entry)
+
+ await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py
index ae8c2e1d51e..b8d376d652f 100644
--- a/tests/components/glances/test_config_flow.py
+++ b/tests/components/glances/test_config_flow.py
@@ -1,6 +1,6 @@
"""Tests for Glances config flow."""
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
from glances_api.exceptions import (
GlancesApiAuthorizationError,
@@ -10,14 +10,14 @@ from glances_api.exceptions import (
import pytest
from homeassistant import config_entries
-from homeassistant.components import glances
+from homeassistant.components.glances.const import DOMAIN
from homeassistant.const import CONF_NAME, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import HA_SENSOR_DATA, MOCK_USER_INPUT
-from tests.common import MockConfigEntry, patch
+from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
@@ -31,7 +31,7 @@ async def test_form(hass: HomeAssistant) -> None:
"""Test config entry configured successfully."""
result = await hass.config_entries.flow.async_init(
- glances.DOMAIN, context={"source": config_entries.SOURCE_USER}
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
@@ -60,7 +60,7 @@ async def test_form_fails(
mock_api.return_value.get_ha_sensor_data.side_effect = error
result = await hass.config_entries.flow.async_init(
- glances.DOMAIN, context={"source": config_entries.SOURCE_USER}
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_USER_INPUT
@@ -72,11 +72,11 @@ async def test_form_fails(
async def test_form_already_configured(hass: HomeAssistant) -> None:
"""Test host is already configured."""
- entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT)
+ entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
- glances.DOMAIN, context={"source": config_entries.SOURCE_USER}
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_USER_INPUT
@@ -87,7 +87,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None:
async def test_reauth_success(hass: HomeAssistant) -> None:
"""Test we can reauth."""
- entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT)
+ entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
@@ -120,7 +120,7 @@ async def test_reauth_fails(
hass: HomeAssistant, error: Exception, message: str, mock_api: MagicMock
) -> None:
"""Test we can reauth."""
- entry = MockConfigEntry(domain=glances.DOMAIN, data=MOCK_USER_INPUT)
+ entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT)
entry.add_to_hass(hass)
mock_api.return_value.get_ha_sensor_data.side_effect = [error, HA_SENSOR_DATA]
diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py
index 553bd6f2089..16d4d9d371b 100644
--- a/tests/components/glances/test_init.py
+++ b/tests/components/glances/test_init.py
@@ -1,6 +1,6 @@
"""Tests for Glances integration."""
-from unittest.mock import AsyncMock, MagicMock
+from unittest.mock import MagicMock
from glances_api.exceptions import (
GlancesApiAuthorizationError,
@@ -12,9 +12,8 @@ import pytest
from homeassistant.components.glances.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import issue_registry as ir
-from . import HA_SENSOR_DATA, MOCK_USER_INPUT
+from . import MOCK_USER_INPUT
from tests.common import MockConfigEntry
@@ -30,29 +29,6 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None:
assert entry.state is ConfigEntryState.LOADED
-async def test_entry_deprecated_version(
- hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_api: AsyncMock
-) -> None:
- """Test creating an issue if glances server is version 2."""
- entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT)
- entry.add_to_hass(hass)
-
- mock_api.return_value.get_ha_sensor_data.side_effect = [
- GlancesApiNoDataAvailable("endpoint: 'all' is not valid"), # fail v4
- GlancesApiNoDataAvailable("endpoint: 'all' is not valid"), # fail v3
- HA_SENSOR_DATA, # success v2
- HA_SENSOR_DATA,
- ]
-
- await hass.config_entries.async_setup(entry.entry_id)
-
- assert entry.state is ConfigEntryState.LOADED
-
- issue = issue_registry.async_get_issue(DOMAIN, "deprecated_version")
- assert issue is not None
- assert issue.severity == ir.IssueSeverity.WARNING
-
-
@pytest.mark.parametrize(
("error", "entry_state"),
[
diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py
index 536a1440958..ad43e341968 100644
--- a/tests/components/google/test_init.py
+++ b/tests/components/google/test_init.py
@@ -20,7 +20,8 @@ from homeassistant.components.google.const import CONF_CALENDAR_ACCESS
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF
from homeassistant.core import HomeAssistant, State
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported
+from homeassistant.setup import async_setup_component
from homeassistant.util.dt import UTC, utcnow
from .conftest import (
@@ -593,7 +594,7 @@ async def test_unsupported_create_event(
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test create event service call is unsupported for virtual calendars."""
-
+ await async_setup_component(hass, "homeassistant", {})
mock_calendars_list({"items": [test_api_calendar]})
mock_events_list({})
assert await component_setup()
@@ -601,8 +602,12 @@ async def test_unsupported_create_event(
start_datetime = datetime.datetime.now(tz=zoneinfo.ZoneInfo("America/Regina"))
delta = datetime.timedelta(days=3, hours=3)
end_datetime = start_datetime + delta
+ entity_id = "calendar.backyard_light"
- with pytest.raises(HomeAssistantError, match="does not support this service"):
+ with pytest.raises(
+ ServiceNotSupported,
+ match=f"Entity {entity_id} does not support action google.create_event",
+ ):
await hass.services.async_call(
DOMAIN,
"create_event",
@@ -613,7 +618,7 @@ async def test_unsupported_create_event(
"summary": TEST_EVENT_SUMMARY,
"description": TEST_EVENT_DESCRIPTION,
},
- target={"entity_id": "calendar.backyard_light"},
+ target={"entity_id": entity_id},
blocking=True,
)
diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py
index bbf2d98b492..e3a01c05eca 100644
--- a/tests/components/group/test_notify.py
+++ b/tests/components/group/test_notify.py
@@ -161,7 +161,8 @@ async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> No
"data": {"hello": "world", "test": "message", "default": "default"},
},
),
- ]
+ ],
+ any_order=True,
)
send_message_mock.reset_mock()
diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py
index 8d729f4358f..f76987c5ce6 100644
--- a/tests/components/habitica/conftest.py
+++ b/tests/components/habitica/conftest.py
@@ -61,6 +61,15 @@ def mock_habitica(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker:
params={"language": "en"},
json=load_json_object_fixture("content.json", DOMAIN),
)
+ aioclient_mock.get(
+ f"{DEFAULT_URL}/api/v3/user/anonymized",
+ json={
+ "data": {
+ "user": load_json_object_fixture("user.json", DOMAIN)["data"],
+ "tasks": load_json_object_fixture("tasks.json", DOMAIN)["data"],
+ }
+ },
+ )
return aioclient_mock
diff --git a/tests/components/habitica/fixtures/common_buttons_unavailable.json b/tests/components/habitica/fixtures/common_buttons_unavailable.json
index efee5364e02..bcc65ee3f91 100644
--- a/tests/components/habitica/fixtures/common_buttons_unavailable.json
+++ b/tests/components/habitica/fixtures/common_buttons_unavailable.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -45,8 +46,8 @@
"shield": "shield_warrior_5",
"back": "heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/healer_fixture.json b/tests/components/habitica/fixtures/healer_fixture.json
index 85f719f4ca7..d76ae612662 100644
--- a/tests/components/habitica/fixtures/healer_fixture.json
+++ b/tests/components/habitica/fixtures/healer_fixture.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -48,10 +49,10 @@
"armor": "armor_healer_5",
"head": "head_healer_5",
"shield": "shield_healer_5",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/healer_skills_unavailable.json b/tests/components/habitica/fixtures/healer_skills_unavailable.json
index a6bff246b2a..e3cead40f7d 100644
--- a/tests/components/habitica/fixtures/healer_skills_unavailable.json
+++ b/tests/components/habitica/fixtures/healer_skills_unavailable.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -47,10 +48,10 @@
"armor": "armor_healer_5",
"head": "head_healer_5",
"shield": "shield_healer_5",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/quest_invitation_off.json b/tests/components/habitica/fixtures/quest_invitation_off.json
index b5eccd99e10..0f191696476 100644
--- a/tests/components/habitica/fixtures/quest_invitation_off.json
+++ b/tests/components/habitica/fixtures/quest_invitation_off.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
diff --git a/tests/components/habitica/fixtures/rogue_fixture.json b/tests/components/habitica/fixtures/rogue_fixture.json
index 1e5e996c034..b6fcd9f1427 100644
--- a/tests/components/habitica/fixtures/rogue_fixture.json
+++ b/tests/components/habitica/fixtures/rogue_fixture.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -48,10 +49,10 @@
"armor": "armor_rogue_5",
"head": "head_rogue_5",
"shield": "shield_rogue_5",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/rogue_skills_unavailable.json b/tests/components/habitica/fixtures/rogue_skills_unavailable.json
index c7c5ff32245..b3bada649fa 100644
--- a/tests/components/habitica/fixtures/rogue_skills_unavailable.json
+++ b/tests/components/habitica/fixtures/rogue_skills_unavailable.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -47,10 +48,10 @@
"armor": "armor_rogue_5",
"head": "head_rogue_5",
"shield": "shield_rogue_5",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/rogue_stealth_unavailable.json b/tests/components/habitica/fixtures/rogue_stealth_unavailable.json
index 9fd7adcca42..9478feb91fa 100644
--- a/tests/components/habitica/fixtures/rogue_stealth_unavailable.json
+++ b/tests/components/habitica/fixtures/rogue_stealth_unavailable.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -47,10 +48,10 @@
"armor": "armor_rogue_5",
"head": "head_rogue_5",
"shield": "shield_rogue_5",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json
index 2e8305283d0..7784b9c7f49 100644
--- a/tests/components/habitica/fixtures/tasks.json
+++ b/tests/components/habitica/fixtures/tasks.json
@@ -345,7 +345,12 @@
"daysOfMonth": [],
"weeksOfMonth": [],
"checklist": [],
- "reminders": [],
+ "reminders": [
+ {
+ "id": "1491d640-6b21-4d0c-8940-0b7aa61c8836",
+ "time": "2024-09-22T20:00:00.0000Z"
+ }
+ ],
"createdAt": "2024-07-07T17:51:53.266Z",
"updatedAt": "2024-09-21T22:51:41.756Z",
"userId": "5f359083-ef78-4af0-985a-0b2c6d05797c",
diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json
index e1b77cd31f2..a498de910ef 100644
--- a/tests/components/habitica/fixtures/user.json
+++ b/tests/components/habitica/fixtures/user.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -62,7 +63,8 @@
"quest": {
"RSVPNeeded": true,
"key": "dustbunnies"
- }
+ },
+ "_id": "94cd398c-2240-4320-956e-6d345cf2c0de"
},
"needsCron": true,
"lastCron": "2024-09-21T22:01:55.586Z",
@@ -74,10 +76,10 @@
"armor": "armor_warrior_5",
"head": "head_warrior_5",
"shield": "shield_warrior_5",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/warrior_fixture.json b/tests/components/habitica/fixtures/warrior_fixture.json
index 3517e8a908a..97ad9e5b060 100644
--- a/tests/components/habitica/fixtures/warrior_fixture.json
+++ b/tests/components/habitica/fixtures/warrior_fixture.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -48,10 +49,10 @@
"armor": "armor_warrior_5",
"head": "head_warrior_5",
"shield": "shield_warrior_5",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/warrior_skills_unavailable.json b/tests/components/habitica/fixtures/warrior_skills_unavailable.json
index b3d33c85d5c..f25ca484cba 100644
--- a/tests/components/habitica/fixtures/warrior_skills_unavailable.json
+++ b/tests/components/habitica/fixtures/warrior_skills_unavailable.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -47,10 +48,10 @@
"armor": "armor_warrior_5",
"head": "head_warrior_5",
"shield": "shield_warrior_5",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/wizard_fixture.json b/tests/components/habitica/fixtures/wizard_fixture.json
index de596e231de..655c0ad1f0d 100644
--- a/tests/components/habitica/fixtures/wizard_fixture.json
+++ b/tests/components/habitica/fixtures/wizard_fixture.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -48,10 +49,10 @@
"armor": "armor_wizard_5",
"head": "head_wizard_5",
"shield": "shield_base_0",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/wizard_frost_unavailable.json b/tests/components/habitica/fixtures/wizard_frost_unavailable.json
index 31d10fde4b9..d5634633a0d 100644
--- a/tests/components/habitica/fixtures/wizard_frost_unavailable.json
+++ b/tests/components/habitica/fixtures/wizard_frost_unavailable.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -47,10 +48,10 @@
"armor": "armor_wizard_5",
"head": "head_wizard_5",
"shield": "shield_base_0",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/fixtures/wizard_skills_unavailable.json b/tests/components/habitica/fixtures/wizard_skills_unavailable.json
index f3bdee9dd74..eaf5f6f55b8 100644
--- a/tests/components/habitica/fixtures/wizard_skills_unavailable.json
+++ b/tests/components/habitica/fixtures/wizard_skills_unavailable.json
@@ -1,4 +1,5 @@
{
+ "success": true,
"data": {
"api_user": "test-api-user",
"profile": { "name": "test-user" },
@@ -47,10 +48,10 @@
"armor": "armor_wizard_5",
"head": "head_wizard_5",
"shield": "shield_base_0",
- "back": "heroicAureole",
+ "back": "back_special_heroicAureole",
"headAccessory": "headAccessory_armoire_gogglesOfBookbinding",
- "eyewear": "plagueDoctorMask",
- "body": "aetherAmulet"
+ "eyewear": "eyewear_armoire_plagueDoctorMask",
+ "body": "body_special_aetherAmulet"
}
}
}
diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr
index 7325e125470..c2f9c8e83c9 100644
--- a/tests/components/habitica/snapshots/test_calendar.ambr
+++ b/tests/components/habitica/snapshots/test_calendar.ambr
@@ -577,6 +577,266 @@
}),
])
# ---
+# name: test_api_events[calendar.test_user_daily_reminders]
+ list([
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-21T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-21T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-22T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-22T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-23T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-23T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-24T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-24T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-25T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-25T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-26T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-26T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-27T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-27T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-28T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-28T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-29T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-29T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-09-30T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-30T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-10-01T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-10-01T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-10-02T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-10-02T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-10-03T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-10-03T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-10-04T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-10-04T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-10-05T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-10-05T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-10-06T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-10-06T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ dict({
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end': dict({
+ 'dateTime': '2024-10-07T21:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-10-07T20:00:00+02:00',
+ }),
+ 'summary': '5 Minuten ruhig durchatmen',
+ 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ }),
+ ])
+# ---
+# name: test_api_events[calendar.test_user_to_do_reminders]
+ list([
+ dict({
+ 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.',
+ 'end': dict({
+ 'dateTime': '2024-09-22T03:00:00+02:00',
+ }),
+ 'location': None,
+ 'recurrence_id': None,
+ 'rrule': None,
+ 'start': dict({
+ 'dateTime': '2024-09-22T02:00:00+02:00',
+ }),
+ 'summary': 'Rechnungen bezahlen',
+ 'uid': '2f6fcabc-f670-4ec3-ba65-817e8deea490_91c09432-10ac-4a49-bd20-823081ec29ed',
+ }),
+ ])
+# ---
# name: test_api_events[calendar.test_user_to_do_s]
list([
dict({
@@ -676,6 +936,110 @@
'state': 'on',
})
# ---
+# name: test_calendar_platform[calendar.test_user_daily_reminders-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'calendar',
+ 'entity_category': None,
+ 'entity_id': 'calendar.test_user_daily_reminders',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Daily reminders',
+ 'platform': 'habitica',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': ,
+ 'unique_id': '00000000-0000-0000-0000-000000000000_daily_reminders',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_calendar_platform[calendar.test_user_daily_reminders-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'all_day': False,
+ 'description': 'Klicke um Deinen Terminplan festzulegen!',
+ 'end_time': '2024-09-21 21:00:00',
+ 'friendly_name': 'test-user Daily reminders',
+ 'location': '',
+ 'message': '5 Minuten ruhig durchatmen',
+ 'start_time': '2024-09-21 20:00:00',
+ }),
+ 'context': ,
+ 'entity_id': 'calendar.test_user_daily_reminders',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
+# name: test_calendar_platform[calendar.test_user_to_do_reminders-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'calendar',
+ 'entity_category': None,
+ 'entity_id': 'calendar.test_user_to_do_reminders',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'To-do reminders',
+ 'platform': 'habitica',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': ,
+ 'unique_id': '00000000-0000-0000-0000-000000000000_todo_reminders',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_calendar_platform[calendar.test_user_to_do_reminders-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'all_day': False,
+ 'description': 'Strom- und Internetrechnungen rechtzeitig überweisen.',
+ 'end_time': '2024-09-22 03:00:00',
+ 'friendly_name': 'test-user To-do reminders',
+ 'location': '',
+ 'message': 'Rechnungen bezahlen',
+ 'start_time': '2024-09-22 02:00:00',
+ }),
+ 'context': ,
+ 'entity_id': 'calendar.test_user_to_do_reminders',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'off',
+ })
+# ---
# name: test_calendar_platform[calendar.test_user_to_do_s-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr
new file mode 100644
index 00000000000..bb9371a4c68
--- /dev/null
+++ b/tests/components/habitica/snapshots/test_diagnostics.ambr
@@ -0,0 +1,715 @@
+# serializer version: 1
+# name: test_diagnostics
+ dict({
+ 'config_entry_data': dict({
+ 'api_user': 'test-api-user',
+ 'url': 'https://habitica.com',
+ }),
+ 'habitica_data': dict({
+ 'tasks': list([
+ dict({
+ '_id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'counterDown': 0,
+ 'counterUp': 0,
+ 'createdAt': '2024-07-07T17:51:53.268Z',
+ 'down': True,
+ 'frequency': 'daily',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'history': list([
+ ]),
+ 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a',
+ 'notes': '',
+ 'priority': 1,
+ 'reminders': list([
+ ]),
+ 'tags': list([
+ ]),
+ 'text': 'Gesundes Essen/Junkfood',
+ 'type': 'habit',
+ 'up': True,
+ 'updatedAt': '2024-07-07T17:51:53.268Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': 0,
+ }),
+ dict({
+ '_id': '1d147de6-5c02-4740-8e2f-71d3015a37f4',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'counterDown': 0,
+ 'counterUp': 0,
+ 'createdAt': '2024-07-07T17:51:53.266Z',
+ 'down': False,
+ 'frequency': 'daily',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'history': list([
+ dict({
+ 'date': 1720376763324,
+ 'scoredDown': 0,
+ 'scoredUp': 1,
+ 'value': 1,
+ }),
+ ]),
+ 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4',
+ 'notes': '',
+ 'priority': 1,
+ 'reminders': list([
+ ]),
+ 'tags': list([
+ ]),
+ 'text': 'Eine kurze Pause machen',
+ 'type': 'habit',
+ 'up': True,
+ 'updatedAt': '2024-07-12T09:58:45.438Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': 0,
+ }),
+ dict({
+ '_id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'counterDown': 0,
+ 'counterUp': 0,
+ 'createdAt': '2024-07-07T17:51:53.265Z',
+ 'down': True,
+ 'frequency': 'daily',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'history': list([
+ ]),
+ 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4',
+ 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht',
+ 'priority': 1,
+ 'reminders': list([
+ ]),
+ 'tags': list([
+ ]),
+ 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest',
+ 'type': 'habit',
+ 'up': False,
+ 'updatedAt': '2024-07-07T17:51:53.265Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': 0,
+ }),
+ dict({
+ '_id': 'e97659e0-2c42-4599-a7bb-00282adc410d',
+ 'alias': 'create_a_task',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'counterDown': 0,
+ 'counterUp': 0,
+ 'createdAt': '2024-07-07T17:51:53.264Z',
+ 'down': False,
+ 'frequency': 'daily',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'history': list([
+ dict({
+ 'date': 1720376763140,
+ 'scoredDown': 0,
+ 'scoredUp': 1,
+ 'value': 1,
+ }),
+ ]),
+ 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d',
+ 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do',
+ 'priority': 1,
+ 'reminders': list([
+ ]),
+ 'tags': list([
+ ]),
+ 'text': 'Füge eine Aufgabe zu Habitica hinzu',
+ 'type': 'habit',
+ 'up': True,
+ 'updatedAt': '2024-07-12T09:58:45.438Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': 0,
+ }),
+ dict({
+ '_id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'checklist': list([
+ ]),
+ 'collapseChecklist': False,
+ 'completed': True,
+ 'createdAt': '2024-07-07T17:51:53.268Z',
+ 'daysOfMonth': list([
+ ]),
+ 'everyX': 1,
+ 'frequency': 'weekly',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'history': list([
+ dict({
+ 'completed': True,
+ 'date': 1720376766749,
+ 'isDue': True,
+ 'value': 1,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1720545311292,
+ 'isDue': True,
+ 'value': 0.02529999999999999,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1720564306719,
+ 'isDue': True,
+ 'value': -0.9740518837628547,
+ }),
+ dict({
+ 'completed': True,
+ 'date': 1720691096907,
+ 'isDue': True,
+ 'value': 0.051222853419153,
+ }),
+ dict({
+ 'completed': True,
+ 'date': 1720778325243,
+ 'isDue': True,
+ 'value': 1.0499115128458676,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1724185196447,
+ 'isDue': True,
+ 'value': 0.07645736684721605,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1724255707692,
+ 'isDue': True,
+ 'value': -0.921585289356988,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1726846163640,
+ 'isDue': True,
+ 'value': -1.9454824860630637,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1726953787542,
+ 'isDue': True,
+ 'value': -2.9966001649571803,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1726956115608,
+ 'isDue': True,
+ 'value': -4.07641493832036,
+ }),
+ dict({
+ 'completed': True,
+ 'date': 1726957460150,
+ 'isDue': True,
+ 'value': -2.9663035443712333,
+ }),
+ ]),
+ 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa',
+ 'isDue': True,
+ 'nextDue': list([
+ 'Mon Sep 23 2024 00:00:00 GMT+0200',
+ 'Tue Sep 24 2024 00:00:00 GMT+0200',
+ 'Wed Sep 25 2024 00:00:00 GMT+0200',
+ 'Thu Sep 26 2024 00:00:00 GMT+0200',
+ 'Fri Sep 27 2024 00:00:00 GMT+0200',
+ 'Sat Sep 28 2024 00:00:00 GMT+0200',
+ ]),
+ 'notes': 'Klicke um Änderungen zu machen!',
+ 'priority': 1,
+ 'reminders': list([
+ ]),
+ 'repeat': dict({
+ 'f': True,
+ 'm': True,
+ 's': True,
+ 'su': True,
+ 't': True,
+ 'th': True,
+ 'w': True,
+ }),
+ 'startDate': '2024-07-06T22:00:00.000Z',
+ 'streak': 1,
+ 'tags': list([
+ ]),
+ 'text': 'Zahnseide benutzen',
+ 'type': 'daily',
+ 'updatedAt': '2024-09-21T22:24:20.154Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': -2.9663035443712333,
+ 'weeksOfMonth': list([
+ ]),
+ 'yesterDaily': True,
+ }),
+ dict({
+ '_id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'checklist': list([
+ ]),
+ 'collapseChecklist': False,
+ 'completed': False,
+ 'createdAt': '2024-07-07T17:51:53.266Z',
+ 'daysOfMonth': list([
+ ]),
+ 'everyX': 1,
+ 'frequency': 'weekly',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'history': list([
+ dict({
+ 'completed': True,
+ 'date': 1720374903074,
+ 'isDue': True,
+ 'value': 1,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1720545311291,
+ 'isDue': True,
+ 'value': 0.02529999999999999,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1720564306717,
+ 'isDue': True,
+ 'value': -0.9740518837628547,
+ }),
+ dict({
+ 'completed': True,
+ 'date': 1720682459722,
+ 'isDue': True,
+ 'value': 0.051222853419153,
+ }),
+ dict({
+ 'completed': True,
+ 'date': 1720778325246,
+ 'isDue': True,
+ 'value': 1.0499115128458676,
+ }),
+ dict({
+ 'completed': True,
+ 'date': 1720778492219,
+ 'isDue': True,
+ 'value': 2.023365658844519,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1724255707691,
+ 'isDue': True,
+ 'value': 1.0738942424964806,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1726846163638,
+ 'isDue': True,
+ 'value': 0.10103816898038132,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1726953787540,
+ 'isDue': True,
+ 'value': -0.8963760215867302,
+ }),
+ dict({
+ 'completed': False,
+ 'date': 1726956115607,
+ 'isDue': True,
+ 'value': -1.919611992979862,
+ }),
+ ]),
+ 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f',
+ 'isDue': True,
+ 'nextDue': list([
+ '2024-09-22T22:00:00.000Z',
+ '2024-09-23T22:00:00.000Z',
+ '2024-09-24T22:00:00.000Z',
+ '2024-09-25T22:00:00.000Z',
+ '2024-09-26T22:00:00.000Z',
+ '2024-09-27T22:00:00.000Z',
+ ]),
+ 'notes': 'Klicke um Deinen Terminplan festzulegen!',
+ 'priority': 1,
+ 'reminders': list([
+ dict({
+ 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836',
+ 'time': '2024-09-22T20:00:00.0000Z',
+ }),
+ ]),
+ 'repeat': dict({
+ 'f': True,
+ 'm': True,
+ 's': True,
+ 'su': True,
+ 't': True,
+ 'th': True,
+ 'w': True,
+ }),
+ 'startDate': '2024-07-06T22:00:00.000Z',
+ 'streak': 0,
+ 'tags': list([
+ ]),
+ 'text': '5 Minuten ruhig durchatmen',
+ 'type': 'daily',
+ 'updatedAt': '2024-09-21T22:51:41.756Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': -1.919611992979862,
+ 'weeksOfMonth': list([
+ ]),
+ 'yesterDaily': True,
+ }),
+ dict({
+ '_id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'checklist': list([
+ ]),
+ 'collapseChecklist': False,
+ 'completed': False,
+ 'createdAt': '2024-09-22T11:44:43.774Z',
+ 'daysOfMonth': list([
+ ]),
+ 'everyX': 1,
+ 'frequency': 'weekly',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'history': list([
+ ]),
+ 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1',
+ 'isDue': True,
+ 'nextDue': list([
+ '2024-09-24T22:00:00.000Z',
+ '2024-09-27T22:00:00.000Z',
+ '2024-09-28T22:00:00.000Z',
+ '2024-10-01T22:00:00.000Z',
+ '2024-10-04T22:00:00.000Z',
+ '2024-10-08T22:00:00.000Z',
+ ]),
+ 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.',
+ 'priority': 2,
+ 'reminders': list([
+ ]),
+ 'repeat': dict({
+ 'f': False,
+ 'm': False,
+ 's': True,
+ 'su': True,
+ 't': False,
+ 'th': False,
+ 'w': True,
+ }),
+ 'startDate': '2024-09-21T22:00:00.000Z',
+ 'streak': 0,
+ 'tags': list([
+ '51076966-2970-4b40-b6ba-d58c6a756dd7',
+ ]),
+ 'text': 'Fitnessstudio besuchen',
+ 'type': 'daily',
+ 'updatedAt': '2024-09-22T11:44:43.774Z',
+ 'userId': '1343a9af-d891-4027-841a-956d105ca408',
+ 'value': 0,
+ 'weeksOfMonth': list([
+ ]),
+ 'yesterDaily': True,
+ }),
+ dict({
+ '_id': '88de7cd9-af2b-49ce-9afd-bf941d87336b',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'checklist': list([
+ ]),
+ 'collapseChecklist': False,
+ 'completed': False,
+ 'createdAt': '2024-09-21T22:17:57.816Z',
+ 'date': '2024-09-27T22:17:00.000Z',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b',
+ 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.',
+ 'priority': 1,
+ 'reminders': list([
+ ]),
+ 'tags': list([
+ ]),
+ 'text': 'Buch zu Ende lesen',
+ 'type': 'todo',
+ 'updatedAt': '2024-09-21T22:17:57.816Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': 0,
+ }),
+ dict({
+ '_id': '2f6fcabc-f670-4ec3-ba65-817e8deea490',
+ 'alias': 'pay_bills',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'checklist': list([
+ ]),
+ 'collapseChecklist': False,
+ 'completed': False,
+ 'createdAt': '2024-09-21T22:17:19.513Z',
+ 'date': '2024-08-31T22:16:00.000Z',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490',
+ 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.',
+ 'priority': 1,
+ 'reminders': list([
+ dict({
+ 'id': '91c09432-10ac-4a49-bd20-823081ec29ed',
+ 'time': '2024-09-22T02:00:00.0000Z',
+ }),
+ ]),
+ 'tags': list([
+ ]),
+ 'text': 'Rechnungen bezahlen',
+ 'type': 'todo',
+ 'updatedAt': '2024-09-21T22:19:35.576Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': 0,
+ }),
+ dict({
+ '_id': '1aa3137e-ef72-4d1f-91ee-41933602f438',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'checklist': list([
+ ]),
+ 'collapseChecklist': False,
+ 'completed': False,
+ 'createdAt': '2024-09-21T22:16:38.153Z',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438',
+ 'notes': 'Rasen mähen und die Pflanzen gießen.',
+ 'priority': 1,
+ 'reminders': list([
+ ]),
+ 'tags': list([
+ ]),
+ 'text': 'Garten pflegen',
+ 'type': 'todo',
+ 'updatedAt': '2024-09-21T22:16:38.153Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': 0,
+ }),
+ dict({
+ '_id': '86ea2475-d1b5-4020-bdcc-c188c7996afa',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'checklist': list([
+ ]),
+ 'collapseChecklist': False,
+ 'completed': False,
+ 'createdAt': '2024-09-21T22:16:16.756Z',
+ 'date': '2024-09-21T22:00:00.000Z',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa',
+ 'notes': 'Den Ausflug für das kommende Wochenende organisieren.',
+ 'priority': 1,
+ 'reminders': list([
+ ]),
+ 'tags': list([
+ '51076966-2970-4b40-b6ba-d58c6a756dd7',
+ ]),
+ 'text': 'Wochenendausflug planen',
+ 'type': 'todo',
+ 'updatedAt': '2024-09-21T22:16:16.756Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': 0,
+ }),
+ dict({
+ '_id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b',
+ 'attribute': 'str',
+ 'byHabitica': False,
+ 'challenge': dict({
+ }),
+ 'createdAt': '2024-07-07T17:51:53.266Z',
+ 'group': dict({
+ 'assignedUsers': list([
+ ]),
+ 'completedBy': dict({
+ }),
+ }),
+ 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b',
+ 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!',
+ 'priority': 1,
+ 'reminders': list([
+ ]),
+ 'tags': list([
+ ]),
+ 'text': 'Belohne Dich selbst',
+ 'type': 'reward',
+ 'updatedAt': '2024-07-07T17:51:53.266Z',
+ 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c',
+ 'value': 10,
+ }),
+ ]),
+ 'user': dict({
+ 'api_user': 'test-api-user',
+ 'auth': dict({
+ 'local': dict({
+ 'username': 'test-username',
+ }),
+ }),
+ 'flags': dict({
+ 'classSelected': True,
+ }),
+ 'id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303',
+ 'items': dict({
+ 'gear': dict({
+ 'equipped': dict({
+ 'armor': 'armor_warrior_5',
+ 'back': 'back_special_heroicAureole',
+ 'body': 'body_special_aetherAmulet',
+ 'eyewear': 'eyewear_armoire_plagueDoctorMask',
+ 'head': 'head_warrior_5',
+ 'headAccessory': 'headAccessory_armoire_gogglesOfBookbinding',
+ 'shield': 'shield_warrior_5',
+ 'weapon': 'weapon_warrior_5',
+ }),
+ }),
+ }),
+ 'lastCron': '2024-09-21T22:01:55.586Z',
+ 'needsCron': True,
+ 'party': dict({
+ '_id': '94cd398c-2240-4320-956e-6d345cf2c0de',
+ 'quest': dict({
+ 'RSVPNeeded': True,
+ 'key': 'dustbunnies',
+ }),
+ }),
+ 'preferences': dict({
+ 'automaticAllocation': True,
+ 'disableClasses': False,
+ 'language': 'en',
+ 'sleep': False,
+ }),
+ 'profile': dict({
+ 'name': 'test-user',
+ }),
+ 'stats': dict({
+ 'buffs': dict({
+ 'con': 26,
+ 'int': 26,
+ 'per': 26,
+ 'seafoam': False,
+ 'shinySeed': False,
+ 'snowball': False,
+ 'spookySparkles': False,
+ 'stealth': 0,
+ 'str': 26,
+ 'streaks': False,
+ }),
+ 'class': 'wizard',
+ 'con': 15,
+ 'exp': 737,
+ 'gp': 137.62587214609795,
+ 'hp': 0,
+ 'int': 15,
+ 'lvl': 38,
+ 'maxHealth': 50,
+ 'maxMP': 166,
+ 'mp': 50.89999999999998,
+ 'per': 15,
+ 'points': 5,
+ 'str': 15,
+ 'toNextLevel': 880,
+ }),
+ 'tasksOrder': dict({
+ 'dailys': list([
+ 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a',
+ 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4',
+ 'e97659e0-2c42-4599-a7bb-00282adc410d',
+ '564b9ac9-c53d-4638-9e7f-1cd96fe19baa',
+ 'f2c85972-1a19-4426-bc6d-ce3337b9d99f',
+ '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1',
+ ]),
+ 'habits': list([
+ '1d147de6-5c02-4740-8e2f-71d3015a37f4',
+ ]),
+ 'rewards': list([
+ '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b',
+ ]),
+ 'todos': list([
+ '88de7cd9-af2b-49ce-9afd-bf941d87336b',
+ '2f6fcabc-f670-4ec3-ba65-817e8deea490',
+ '1aa3137e-ef72-4d1f-91ee-41933602f438',
+ '86ea2475-d1b5-4020-bdcc-c188c7996afa',
+ ]),
+ }),
+ }),
+ }),
+ })
+# ---
diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr
index 07eddf496b2..28dd7eb8c43 100644
--- a/tests/components/habitica/snapshots/test_sensor.ambr
+++ b/tests/components/habitica/snapshots/test_sensor.ambr
@@ -101,7 +101,7 @@
'allocated': 15,
'buffs': 26,
'class': 0,
- 'equipment': 20,
+ 'equipment': 42,
'friendly_name': 'test-user Constitution',
'level': 19,
'unit_of_measurement': 'CON',
@@ -111,7 +111,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '80',
+ 'state': '102',
})
# ---
# name: test_sensors[sensor.test_user_dailies-entry]
@@ -665,7 +665,7 @@
'allocated': 15,
'buffs': 26,
'class': 0,
- 'equipment': 0,
+ 'equipment': 12,
'friendly_name': 'test-user Intelligence',
'level': 19,
'unit_of_measurement': 'INT',
@@ -675,7 +675,7 @@
'last_changed': ,
'last_reported': ,
'last_updated':