From 3101994179ba820ebc33616916c471abbb2a6288 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:51:05 +0200 Subject: [PATCH 1/9] Update pnpm to 12.3.2 (#181905) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- tests/e2e/package.json | 2 +- tests/e2e/pnpm-lock.yaml | 74 ++++++++++++++++++++-------------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 6dfd876f4eddf..a3c8e6b392935 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "End-to-end browser tests for Home Assistant Core", "private": true, - "packageManager": "pnpm@12.3.1", + "packageManager": "pnpm@12.3.2", "scripts": { "test": "playwright test" }, diff --git a/tests/e2e/pnpm-lock.yaml b/tests/e2e/pnpm-lock.yaml index 2b04b47df57d0..ed22669a4aa35 100644 --- a/tests/e2e/pnpm-lock.yaml +++ b/tests/e2e/pnpm-lock.yaml @@ -7,96 +7,96 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.3.1 - version: 12.3.1 + specifier: 12.3.2 + version: 12.3.2 packages: - '@pnpm/exe.darwin-arm64@12.3.1': - resolution: {integrity: sha512-Yer+aZnQtgE+OOLKwbRHaAEt74WBJdH8eXpLrSWf+5vj7DkkDvhSR45HVxN3a5d430fMUyF0lmQai02T650r4g==} + '@pnpm/exe.darwin-arm64@12.3.2': + resolution: {integrity: sha512-6/s7P0pq+ULV5FwspTAJcGznMrGCbLOsJdD5d9YpoJONb9jI73MmNTKxlDMtlMikNNK+no8HZDWPFh7Ga1avVA==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.3.1': - resolution: {integrity: sha512-XPZYf+XpxufwZS8tpQQdftsv6eUtPDA0uU5NlfXA1uMVPvXJesw5PLGWmmJZHcI4rIscn2H6FngkIcvkuZaaKQ==} + '@pnpm/exe.darwin-x64@12.3.2': + resolution: {integrity: sha512-PQympeXAJXS+ittZ5AHg+eZbcW89ajoCVlM23kFIcrv2eDDiciLJNk4+8AZgmMXMRAbEnjtqVjZbk3lFxHoFEw==} cpu: [x64] os: [darwin] - '@pnpm/exe.linux-arm64-musl@12.3.1': - resolution: {integrity: sha512-WjKcLeuierWGSQHjb0QeDl8avQTel8rN5jDMCI55tBJTrTBXNQsdlpgzP9uCD0GSzjPH+hjWKb+GjeMNvv5Ruw==} + '@pnpm/exe.linux-arm64-musl@12.3.2': + resolution: {integrity: sha512-uHgJwDuohgKwlik5zGem0JIhs5c3NNzdh8hB9OW7MmD2s1L2z3/sloMknvCEdx4tne2vS9NN/yb3Enhpj9ZxwQ==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.3.1': - resolution: {integrity: sha512-X0HxHlEYubFRuMPBOy+ytKsv6c11v4em/ovVVCy5KCdJVBtVGF7vF5ywlGqw7EjpGdqM39pdLTWFwH9Q77lUzQ==} + '@pnpm/exe.linux-arm64@12.3.2': + resolution: {integrity: sha512-SMj5eTAWvjojqCYCXGKQO4qSanTBCe1xhtXQ0qgfBZWJcLCMJkyea65HotYEZLWmAXEDWPh87GZczcJZXEzCIQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.3.1': - resolution: {integrity: sha512-ZvzQI2+Ek0v+V6m30XV3ShIx5sm+ZIZoM669Z0F/RvMSpHgklqv3CBdEwAsQCZ7XBNZLMQIE7RPR2yIwxt3mnw==} + '@pnpm/exe.linux-x64-musl@12.3.2': + resolution: {integrity: sha512-YYKfJubIb18iBG/pm9peDIP/STy5l5GrOQQkuFr9yOZdkc+hlYH5MBdTD8ffjTkemP9AfTYJ1R/QB/L+AZfRXA==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.3.1': - resolution: {integrity: sha512-ubvbQ2OSt7fR3kSnhtknx5xnm2H7uRi0kC32ADmzKJ1vZz337kmL30rTHoUzTG7ZcIyEhODg7R+zI1cW1ZdVDw==} + '@pnpm/exe.linux-x64@12.3.2': + resolution: {integrity: sha512-qc3jzpaGtMNABOG6Xl0Mee0IOXddAFiDD6sFoOqHpzcq+3YALy+6HMms47g7bYOFNSKWULqPv+SyFYJfZ8EMsA==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.3.1': - resolution: {integrity: sha512-e+5CjFBGWP7U7RfFlH/EQnlFaP9Uyo/Y0BDCEbitsC4if28WBur3ajAkc25rRwiEwmmEpipTSzqNsKIs9Mq72Q==} + '@pnpm/exe.win32-arm64@12.3.2': + resolution: {integrity: sha512-W1+M+HdebF/i3DjF2Tx9/+i7BoIJFX+AwNd3WXjWozylQQVfhBA024Ea0rrx7nwToPkS2M8sd8axpwIFLL5bbw==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.3.1': - resolution: {integrity: sha512-5pW8NQuVF3dkNdaUCm03PeoDiC5I9h4y9Fz717KLWi5jxDKmgARmD1zDdm60F5ohRZHPrRv/jAg94a/5+VXxow==} + '@pnpm/exe.win32-x64@12.3.2': + resolution: {integrity: sha512-LjekndG6dhq9g34qTNeEcynAAVypRUoYuga79lG96AA4JCKxH5dC3jx0mLRKeClQKwGVE1yXuQrCj+alVU2qQA==} cpu: [x64] os: [win32] - pnpm@12.3.1: - resolution: {integrity: sha512-PBTBVAjRSJ01D47X+TNh0mzHBwVPaEmk7qO7dAf/T+RWS0E6HEIadzoXarEWio3xx9VI8s0Nx9NE9YxOaApbGA==} + pnpm@12.3.2: + resolution: {integrity: sha512-gvkDUxRkbuqYGt7IXHEaIatfOwVlmz+c6dJwy7k+ig9hTaF5Irrgl5AuK4YO6OttXBp+iSmaFod8DCQ/NrjhWw==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.darwin-arm64@12.3.1': + '@pnpm/exe.darwin-arm64@12.3.2': optional: true - '@pnpm/exe.darwin-x64@12.3.1': + '@pnpm/exe.darwin-x64@12.3.2': optional: true - '@pnpm/exe.linux-arm64-musl@12.3.1': + '@pnpm/exe.linux-arm64-musl@12.3.2': optional: true - '@pnpm/exe.linux-arm64@12.3.1': + '@pnpm/exe.linux-arm64@12.3.2': optional: true - '@pnpm/exe.linux-x64-musl@12.3.1': + '@pnpm/exe.linux-x64-musl@12.3.2': optional: true - '@pnpm/exe.linux-x64@12.3.1': + '@pnpm/exe.linux-x64@12.3.2': optional: true - '@pnpm/exe.win32-arm64@12.3.1': + '@pnpm/exe.win32-arm64@12.3.2': optional: true - '@pnpm/exe.win32-x64@12.3.1': + '@pnpm/exe.win32-x64@12.3.2': optional: true - pnpm@12.3.1: + pnpm@12.3.2: optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.3.1 - '@pnpm/exe.darwin-x64': 12.3.1 - '@pnpm/exe.linux-arm64': 12.3.1 - '@pnpm/exe.linux-arm64-musl': 12.3.1 - '@pnpm/exe.linux-x64': 12.3.1 - '@pnpm/exe.linux-x64-musl': 12.3.1 - '@pnpm/exe.win32-arm64': 12.3.1 - '@pnpm/exe.win32-x64': 12.3.1 + '@pnpm/exe.darwin-arm64': 12.3.2 + '@pnpm/exe.darwin-x64': 12.3.2 + '@pnpm/exe.linux-arm64': 12.3.2 + '@pnpm/exe.linux-arm64-musl': 12.3.2 + '@pnpm/exe.linux-x64': 12.3.2 + '@pnpm/exe.linux-x64-musl': 12.3.2 + '@pnpm/exe.win32-arm64': 12.3.2 + '@pnpm/exe.win32-x64': 12.3.2 --- lockfileVersion: '9.0' From 2e74c63aaeb709693f0a75ed9fac9bd436545593 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:51:19 +0200 Subject: [PATCH 2/9] Update ruff (#181906) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- requirements_test_pre_commit.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b54d5e287cf24..7496fcaffcdfb 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.16.5 + rev: v0.16.6 hooks: - id: ruff-check args: diff --git a/pyproject.toml b/pyproject.toml index a6f2406cc4f23..8a2440f3a6f36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -638,7 +638,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.16.5" +required-version = ">=0.16.6" [tool.ruff.lint] select = [ diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 09f7f2ec9ed37..cbf553e69a09f 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.3 -ruff==0.16.5 +ruff==0.16.6 yamllint==1.38.0 zizmor==1.30.0 From 58b268badefdd49f7a229ecd1a67460389e6254e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 11 Sep 2026 07:40:37 +0200 Subject: [PATCH 3/9] Add missing Miele coffee system programs (#181171) --- homeassistant/components/miele/const.py | 3 +++ homeassistant/components/miele/strings.json | 3 +++ tests/components/miele/snapshots/test_sensor.ambr | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 131dafff7c704..3c943cb785583 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -1204,6 +1204,8 @@ class CoffeeSystemProgramId(MieleEnum, missing_to_none=True): espresso_macchiato = 24007, 24039, 24071, 24135 cafe_au_lait = 24008, 24040, 24072, 24104, 24136 caffe_latte = 24009, 24041, 24073, 24105, 24137 + caffe_americano = 24010 + long_black = 24011 flat_white = 24012, 24044, 24076, 24108, 24140 very_hot_water = 24013, 24045, 24077, 24109, 24141 hot_water = 24014, 24046, 24078, 24110, 24142 @@ -1215,6 +1217,7 @@ class CoffeeSystemProgramId(MieleEnum, missing_to_none=True): green_tea = 24020, 24052, 24084, 24116, 24148 white_tea = 24021, 24053, 24085, 24117, 24149 japanese_tea = 24022, 29054, 24086, 24118, 24150 + chai_latte = 24023 # special programs coffee_pot = 24400 barista_assistant = 24407 diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index e5b3fb58c6164..e113bbae0472c 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -355,6 +355,7 @@ "bunched_carrots_whole_small": "Bunched carrots (whole, small)", "butter_cake": "Butter cake", "cafe_au_lait": "Café au lait", + "caffe_americano": "Caffè Americano", "caffe_latte": "Caffè latte", "cappuccino": "Cappuccino", "cappuccino_italiano": "Cappuccino Italiano", @@ -379,6 +380,7 @@ "celery_pieces": "Celery (pieces)", "celery_sliced": "Celery (sliced)", "cep": "Cep", + "chai_latte": "Chai latte", "chanterelle": "Chanterelle", "char": "Char", "check_appliance": "Check appliance", @@ -577,6 +579,7 @@ "lemon_meringue_pie": "Lemon meringue pie", "linzer_augen_1_tray": "Linzer Augen (1 tray)", "linzer_augen_2_trays": "Linzer Augen (2 trays)", + "long_black": "Long black", "long_coffee": "Long coffee", "long_grain_rice_general_rapid_steam_cooking": "Long grain rice (general, rapid steam cooking)", "long_grain_rice_general_steam_cooking": "Long grain rice (general, steam cooking)", diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index a1b0ee9b6f661..4bd1359d9f121 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -268,9 +268,11 @@ 'black_tea', 'brewing_unit_degrease', 'cafe_au_lait', + 'caffe_americano', 'caffe_latte', 'cappuccino', 'cappuccino_italiano', + 'chai_latte', 'check_appliance', 'coffee', 'coffee_pot', @@ -286,6 +288,7 @@ 'intermediate_rinsing', 'japanese_tea', 'latte_macchiato', + 'long_black', 'long_coffee', 'milk_foam', 'milk_pipework_clean', @@ -339,9 +342,11 @@ 'black_tea', 'brewing_unit_degrease', 'cafe_au_lait', + 'caffe_americano', 'caffe_latte', 'cappuccino', 'cappuccino_italiano', + 'chai_latte', 'check_appliance', 'coffee', 'coffee_pot', @@ -357,6 +362,7 @@ 'intermediate_rinsing', 'japanese_tea', 'latte_macchiato', + 'long_black', 'long_coffee', 'milk_foam', 'milk_pipework_clean', From 498244c1f9189b250325f6a930fa1b74b0bc24a9 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Fri, 11 Sep 2026 07:47:31 +0200 Subject: [PATCH 4/9] Share Duco coordinator update test helper (#181781) --- tests/components/duco/__init__.py | 14 +++- tests/components/duco/test_binary_sensor.py | 33 ++++----- tests/components/duco/test_fan.py | 9 +-- tests/components/duco/test_init.py | 16 ++--- tests/components/duco/test_number.py | 21 ++---- tests/components/duco/test_sensor.py | 77 +++++++-------------- 6 files changed, 64 insertions(+), 106 deletions(-) diff --git a/tests/components/duco/__init__.py b/tests/components/duco/__init__.py index 71b8a2a1175c3..a7e3ccc5787b4 100644 --- a/tests/components/duco/__init__.py +++ b/tests/components/duco/__init__.py @@ -3,10 +3,22 @@ from collections.abc import Sequence from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.duco.const import SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def async_fire_coordinator_update( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Trigger a scheduled coordinator update.""" + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) async def setup_integration( diff --git a/tests/components/duco/test_binary_sensor.py b/tests/components/duco/test_binary_sensor.py index bc8bdfd18da88..bc3aa92b39eb6 100644 --- a/tests/components/duco/test_binary_sensor.py +++ b/tests/components/duco/test_binary_sensor.py @@ -14,7 +14,7 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.duco.const import BOX_NODE_ID, SCAN_INTERVAL +from homeassistant.components.duco.const import BOX_NODE_ID from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( STATE_OFF, @@ -26,9 +26,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_platform_integration +from . import async_fire_coordinator_update, setup_platform_integration -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry VENTILATION_PROBLEM_ENTITY_ID = "binary_sensor.living_ventilation" @@ -38,13 +38,6 @@ ] -async def _async_refresh(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: - """Trigger a coordinator refresh.""" - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - - async def test_diagnostic_binary_sensor_entity_registry_defaults( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -144,7 +137,7 @@ async def test_diagnostic_binary_sensors_added_after_initial_empty_response( diagnostic_subsystems=(DiagComponent(component="Ventilation", status="Error"),) ) - await _async_refresh(hass, freezer) + await async_fire_coordinator_update(hass, freezer) assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_ON) @@ -155,7 +148,7 @@ async def test_diagnostic_binary_sensors_added_after_initial_empty_response( ) ) - await _async_refresh(hass, freezer) + await async_fire_coordinator_update(hass, freezer) assert hass.states.is_state("binary_sensor.living_filter", STATE_OFF) @@ -178,7 +171,7 @@ async def test_diagnostic_binary_sensors_wait_for_box_node( assert hass.states.get(VENTILATION_PROBLEM_ENTITY_ID) is None mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes - await _async_refresh(hass, freezer) + await async_fire_coordinator_update(hass, freezer) assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF) @@ -209,7 +202,7 @@ async def test_diagnostic_binary_sensor_becomes_unknown_without_known_status( diagnostic_subsystems=diagnostic_subsystems ) - await _async_refresh(hass, freezer) + await async_fire_coordinator_update(hass, freezer) assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_UNKNOWN) @@ -233,13 +226,13 @@ async def test_diagnostics_refresh_failure_is_isolated_and_recovers( mock_duco_client.async_get_diagnostics_info.side_effect = exception_type("error") - await _async_refresh(hass, freezer) + await async_fire_coordinator_update(hass, freezer) assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_UNAVAILABLE) assert hass.states.is_state("sensor.office_co2_carbon_dioxide", "405") mock_duco_client.async_get_diagnostics_info.side_effect = None - await _async_refresh(hass, freezer) + await async_fire_coordinator_update(hass, freezer) assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF) @@ -266,7 +259,7 @@ async def test_initial_diagnostics_failure_is_isolated_and_recovers( assert hass.states.is_state("sensor.office_co2_carbon_dioxide", "405") mock_duco_client.async_get_diagnostics_info.side_effect = None - await _async_refresh(hass, freezer) + await async_fire_coordinator_update(hass, freezer) assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF) @@ -283,11 +276,11 @@ async def test_diagnostics_availability_transitions_logged( mock_duco_client.async_get_diagnostics_info.side_effect = DucoError("error") await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR]) - await _async_refresh(hass, freezer) + await async_fire_coordinator_update(hass, freezer) mock_duco_client.async_get_diagnostics_info.side_effect = None - await _async_refresh(hass, freezer) - await _async_refresh(hass, freezer) + await async_fire_coordinator_update(hass, freezer) + await async_fire_coordinator_update(hass, freezer) assert [ record.message diff --git a/tests/components/duco/test_fan.py b/tests/components/duco/test_fan.py index 2db0a6b49f6a1..8a6f3561eee9d 100644 --- a/tests/components/duco/test_fan.py +++ b/tests/components/duco/test_fan.py @@ -8,7 +8,6 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.duco.const import SCAN_INTERVAL from homeassistant.components.fan import ( ATTR_PERCENTAGE, ATTR_PRESET_MODE, @@ -21,9 +20,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import setup_platform_integration +from . import async_fire_coordinator_update, setup_platform_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform _FAN_ENTITY = "fan.living" @@ -145,9 +144,7 @@ async def test_coordinator_update_marks_unavailable( side_effect=DucoConnectionError("offline") ) - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get(_FAN_ENTITY) assert state is not None diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index aca738a00a83f..4ff7b1f659133 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -21,13 +21,13 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.duco.const import BOX_NODE_ID, DOMAIN, SCAN_INTERVAL +from homeassistant.components.duco.const import BOX_NODE_ID, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import setup_platform_integration +from . import async_fire_coordinator_update, setup_platform_integration from .conftest import ( TEST_HOST, TEST_MAC, @@ -223,9 +223,7 @@ async def test_setup_entry_recovers_from_optional_temperature_capability_failure assert mock_config_entry.state is ConfigEntryState.LOADED assert hass.states.get("sensor.living_outdoor_air_temperature") is None - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get("sensor.living_outdoor_air_temperature") assert state is not None @@ -289,9 +287,7 @@ async def test_empty_bypass_temperature_targets_are_retried( assert hass.states.get("number.living_bypass_target_2") is None mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) assert mock_duco_client.async_get_bypass_supply_temperature_targets.await_count == 2 assert hass.states.get("number.living_bypass_target_1") is not None @@ -321,9 +317,7 @@ async def test_missing_bypass_temperature_targets_are_retried( assert mock_config_entry.state is ConfigEntryState.LOADED assert hass.states.get("number.living_bypass_target_1") is None - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get("number.living_bypass_target_1") assert state is not None diff --git a/tests/components/duco/test_number.py b/tests/components/duco/test_number.py index 8f59182f4659b..91591c1062749 100644 --- a/tests/components/duco/test_number.py +++ b/tests/components/duco/test_number.py @@ -13,7 +13,6 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.duco.const import SCAN_INTERVAL from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant @@ -21,9 +20,9 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import setup_platform_integration +from . import async_fire_coordinator_update, setup_platform_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform _ZONE_1_ENTITY_ID = "number.living_bypass_target_1" _ZONE_2_ENTITY_ID = "number.living_bypass_target_2" @@ -298,18 +297,14 @@ async def test_bypass_supply_temperature_target_becomes_unavailable_when_missing assert state.state == "20.0" updated_target = replace(mock_bypass_supply_temperature_targets.pop(1), value=20.5) - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get(_ZONE_1_ENTITY_ID) assert state is not None assert state.state == STATE_UNAVAILABLE mock_bypass_supply_temperature_targets[1] = updated_target - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get(_ZONE_1_ENTITY_ID) assert state is not None @@ -334,17 +329,13 @@ async def test_bypass_supply_temperature_target_recovers_from_refresh_error( DucoError("Temporary bypass target failure"), mock_bypass_supply_temperature_targets.copy(), ] - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get(_ZONE_1_ENTITY_ID) assert state is not None assert state.state == STATE_UNAVAILABLE - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get(_ZONE_1_ENTITY_ID) assert state is not None diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index 2973478af573c..e6e9f7683067c 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -19,14 +19,14 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.duco.const import BOX_NODE_ID, DOMAIN, SCAN_INTERVAL +from homeassistant.components.duco.const import BOX_NODE_ID, DOMAIN from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import setup_platform_integration +from . import async_fire_coordinator_update, setup_platform_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform FILTER_REMAINING_ENTITY_ID = "sensor.living_filter_remaining" VENTILATION_TEMPERATURE_ENTITY_IDS = ( @@ -195,14 +195,15 @@ async def test_iaq_sensor_entities_disabled_by_default( @pytest.mark.usefixtures("init_integration") -async def test_rssi_sensor_disabled_by_default( +async def test_diagnostic_sensor_entities_disabled_by_default( hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: - """Test that the RSSI sensor is disabled by default.""" - entry = entity_registry.async_get("sensor.living_signal_strength") - assert entry is not None - assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION + """Test that diagnostic sensor entities are disabled by default.""" + for entity_id in ("sensor.living_signal_strength",): + entry = entity_registry.async_get(entity_id) + assert entry is not None + assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION @pytest.mark.usefixtures("init_integration") @@ -223,9 +224,7 @@ async def test_coordinator_update_failure_marks_unavailable( """Test sensor entities become unavailable when the coordinator update fails.""" mock_duco_client.async_get_nodes.side_effect = exception_type(exception_message) - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get("sensor.office_co2_carbon_dioxide") assert state is not None @@ -249,9 +248,7 @@ async def test_lan_info_failures_keep_node_entities_available( """Test node entities stay available when LAN info retrieval fails.""" mock_duco_client.async_get_lan_info = AsyncMock(side_effect=exception) - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get("sensor.office_co2_carbon_dioxide") assert state is not None @@ -280,9 +277,7 @@ async def test_time_filter_remaining_missing_is_retried( assert hass.states.get(FILTER_REMAINING_ENTITY_ID) is None - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) assert mock_duco_client.async_get_time_filter_remaining.await_count == 2 state = hass.states.get(FILTER_REMAINING_ENTITY_ID) @@ -307,9 +302,7 @@ async def test_empty_ventilation_temperatures_are_retried( for entity_id in VENTILATION_TEMPERATURE_ENTITY_IDS: assert hass.states.get(entity_id) is None - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) assert mock_duco_client.async_get_ventilation_temperature_info.await_count == 2 state = hass.states.get("sensor.living_outdoor_air_temperature") @@ -358,9 +351,7 @@ async def test_time_filter_remaining_transient_failure_recovers_sensor_creation( assert hass.states.get(FILTER_REMAINING_ENTITY_ID) is None - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get(FILTER_REMAINING_ENTITY_ID) assert state is not None @@ -414,9 +405,7 @@ async def test_new_node_added_dynamically( new_node = dynamic_sensor_nodes[node_id] mock_duco_client.async_get_nodes.return_value = [*mock_sensor_nodes, new_node] - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get(expected_entity_id) assert state is not None @@ -448,9 +437,7 @@ async def test_deregistered_node_removes_device( node for node in mock_sensor_nodes if node.node_id != 2 ] - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) # The device should be removed from the device registry. device = device_registry.async_get_device_by_identifier( @@ -485,9 +472,7 @@ async def test_box_node_not_removed_on_transient_incomplete_node_list( node for node in mock_sensor_nodes if node.node_id != BOX_NODE_ID ] - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) assert ( device_registry.async_get_device_by_identifier( @@ -502,9 +487,7 @@ async def test_box_node_not_removed_on_transient_incomplete_node_list( mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get("fan.living") assert state is not None @@ -538,9 +521,7 @@ async def test_unknown_node_type_logs_warning_and_creates_no_entities( ) mock_duco_client.async_get_nodes.return_value = [*mock_sensor_nodes, unknown_node] - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) assert "99" in caplog.text assert "unsupported" in caplog.text.lower() @@ -589,9 +570,7 @@ def _make_node(node_type: NodeType | str) -> Node: *mock_sensor_nodes, _make_node(NodeType.UNKNOWN), ] - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) assert hass.states.get("sensor.future_sensor_humidity") is None @@ -600,9 +579,7 @@ def _make_node(node_type: NodeType | str) -> Node: *mock_sensor_nodes, _make_node("BSRH"), ] - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get("sensor.future_sensor_humidity") assert state is not None @@ -642,16 +619,12 @@ async def test_unknown_node_logged_at_debug( mock_duco_client.async_get_nodes.return_value = [*mock_sensor_nodes, unknown_node] with caplog.at_level(logging.WARNING, logger="homeassistant.components.duco"): - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) assert "has an unsupported device type" not in caplog.text with caplog.at_level(logging.DEBUG, logger="homeassistant.components.duco"): - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) assert "has an unsupported device type" in caplog.text @@ -684,9 +657,7 @@ async def test_ventilation_state_unknown_returns_state_unknown( ] mock_duco_client.async_get_nodes.return_value = updated_nodes - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) + await async_fire_coordinator_update(hass, freezer) state = hass.states.get("sensor.living_ventilation_state") assert state is not None From 3bd85bae0975beb5599d90a1f72e7fec2bd31776 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 10 Sep 2026 23:01:33 -0700 Subject: [PATCH 5/9] Bump python-roborock to 7.4.2 (#181907) Co-authored-by: Home Assistant Developer --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 7de330908d073..75caa58ba36a5 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==7.4.1", + "python-roborock==7.4.2", "vacuum-map-parser-roborock==0.1.5" ] } diff --git a/requirements_all.txt b/requirements_all.txt index ea491cba66019..346388511b620 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2808,7 +2808,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==7.4.1 +python-roborock==7.4.2 # homeassistant.components.smarttub python-smarttub==0.0.47 From ce399d5ab26dac1eb0020804b5f33122a46db895 Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Fri, 11 Sep 2026 08:09:42 +0200 Subject: [PATCH 6/9] Use PKCE for Weheat OAuth2 authorization (#181881) Co-authored-by: Claude Opus 5 (1M context) --- .../weheat/application_credentials.py | 20 ++++++++++++++---- tests/components/weheat/test_config_flow.py | 21 ++++++++++++++----- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/weheat/application_credentials.py b/homeassistant/components/weheat/application_credentials.py index 3f85d4b055887..df9f1f5913a68 100644 --- a/homeassistant/components/weheat/application_credentials.py +++ b/homeassistant/components/weheat/application_credentials.py @@ -1,11 +1,23 @@ """application_credentials platform the Weheat integration.""" -from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.components.application_credentials import ClientCredential from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + LocalOAuth2ImplementationWithPkce, +) from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN -async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: - """Return authorization server.""" - return AuthorizationServer(authorize_url=OAUTH2_AUTHORIZE, token_url=OAUTH2_TOKEN) +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> LocalOAuth2ImplementationWithPkce: + """Return auth implementation with PKCE support.""" + return LocalOAuth2ImplementationWithPkce( + hass, + auth_domain, + credential.client_id, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + credential.client_secret, + ) diff --git a/tests/components/weheat/test_config_flow.py b/tests/components/weheat/test_config_flow.py index ff033cb44e885..7d95a9eed00f5 100644 --- a/tests/components/weheat/test_config_flow.py +++ b/tests/components/weheat/test_config_flow.py @@ -4,11 +4,13 @@ import pytest from weheat.exceptions import ApiException +from yarl import URL from homeassistant.components.weheat.const import ( DOMAIN, ENTRY_TITLE, OAUTH2_AUTHORIZE, + OAUTH2_SCOPES, OAUTH2_TOKEN, ) from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult @@ -58,6 +60,10 @@ async def test_full_flow( assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_weheat.mock_calls) == 1 + token_request_data = aioclient_mock.mock_calls[-1][2] + assert token_request_data["grant_type"] == "authorization_code" + assert token_request_data["code_verifier"] + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == USER_UUID_1 assert result["result"].title == ENTRY_TITLE @@ -189,12 +195,17 @@ async def handle_oauth( }, ) - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - "&scope=openid+offline_access" + result_url = URL(result["url"]) + assert f"{result_url.origin()}{result_url.path}" == OAUTH2_AUTHORIZE + assert result_url.query["response_type"] == "code" + assert result_url.query["client_id"] == CLIENT_ID + assert ( + result_url.query["redirect_uri"] == "https://example.com/auth/external/callback" ) + assert result_url.query["state"] == state + assert result_url.query["scope"] == " ".join(OAUTH2_SCOPES) + assert result_url.query["code_challenge"] + assert result_url.query["code_challenge_method"] == "S256" client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") From 99c76fc2117bc3da45f6bc17b895205883fcf4ba Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Fri, 11 Sep 2026 09:12:39 +0200 Subject: [PATCH 7/9] Consolidate Duco capability recovery tests (#181916) --- tests/components/duco/test_init.py | 65 +++++++++++++--------------- tests/components/duco/test_sensor.py | 44 ++++++------------- 2 files changed, 45 insertions(+), 64 deletions(-) diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index 4ff7b1f659133..20c9b68a84675 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -265,16 +265,41 @@ async def test_setup_entry_retries_on_bypass_temperature_failure( assert mock_config_entry.error_reason_translation_placeholders is None -async def test_empty_bypass_temperature_targets_are_retried( +@pytest.mark.parametrize( + ("initial_zone_ids", "initial_missing_entity_ids"), + [ + pytest.param( + frozenset(), + ( + "number.living_bypass_target_1", + "number.living_bypass_target_2", + ), + id="empty", + ), + pytest.param( + frozenset({2}), + ("number.living_bypass_target_1",), + id="zone_1_missing", + ), + ], +) +async def test_bypass_temperature_targets_are_retried( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], mock_config_entry: MockConfigEntry, mock_duco_client: AsyncMock, + initial_zone_ids: frozenset[int], + initial_missing_entity_ids: tuple[str, ...], ) -> None: - """Test empty bypass targets are retried and can later create entities.""" + """Test missing bypass targets are retried and create number entities.""" + initial_targets = { + zone_id: target + for zone_id, target in mock_bypass_supply_temperature_targets.items() + if zone_id in initial_zone_ids + } mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = [ - {}, + initial_targets, mock_bypass_supply_temperature_targets.copy(), ] mock_config_entry.add_to_hass(hass) @@ -283,45 +308,17 @@ async def test_empty_bypass_temperature_targets_are_retried( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - assert hass.states.get("number.living_bypass_target_1") is None - assert hass.states.get("number.living_bypass_target_2") is None + for entity_id in initial_missing_entity_ids: + assert hass.states.get(entity_id) is None mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() await async_fire_coordinator_update(hass, freezer) assert mock_duco_client.async_get_bypass_supply_temperature_targets.await_count == 2 - assert hass.states.get("number.living_bypass_target_1") is not None - assert hass.states.get("number.living_bypass_target_2") is not None - - -async def test_missing_bypass_temperature_targets_are_retried( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], - mock_config_entry: MockConfigEntry, - mock_duco_client: AsyncMock, -) -> None: - """Test missing bypass targets are retried and can later create entities.""" - targets_without_zone_1 = { - k: v for k, v in mock_bypass_supply_temperature_targets.items() if k != 1 - } - mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = [ - targets_without_zone_1, - mock_bypass_supply_temperature_targets.copy(), - ] - 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 - assert hass.states.get("number.living_bypass_target_1") is None - - await async_fire_coordinator_update(hass, freezer) - state = hass.states.get("number.living_bypass_target_1") assert state is not None assert state.state == "20.0" + assert hass.states.get("number.living_bypass_target_2") is not None async def test_setup_entry_ignores_node_name_config_failures( diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index e6e9f7683067c..b342e93a2ffdd 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -259,19 +259,27 @@ async def test_lan_info_failures_keep_node_entities_available( assert state.state == "-60" -async def test_time_filter_remaining_missing_is_retried( +@pytest.mark.parametrize( + "initial_time_filter_remain", + [ + pytest.param(None, id="missing"), + pytest.param(DucoError("heat recovery info error"), id="transient_failure"), + ], +) +async def test_time_filter_remaining_is_retried( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_duco_client: AsyncMock, mock_sensor_nodes: list[Node], freezer: FrozenDateTimeFactory, + initial_time_filter_remain: DucoError | None, ) -> None: - """Test a missing filter timer does not create the sensor but is retried.""" + """Test unavailable filter timer data is retried and can create the sensor.""" mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes - - mock_duco_client.async_get_time_filter_remaining = AsyncMock( - side_effect=[None, 180] - ) + mock_duco_client.async_get_time_filter_remaining.side_effect = [ + initial_time_filter_remain, + 180, + ] await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR]) @@ -334,30 +342,6 @@ async def test_partial_ventilation_temperatures_only_expose_available_sensor_val assert hass.states.get("sensor.living_exhaust_air_temperature") is None -async def test_time_filter_remaining_transient_failure_recovers_sensor_creation( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_duco_client: AsyncMock, - mock_sensor_nodes: list[Node], - freezer: FrozenDateTimeFactory, -) -> None: - """Test the filter timer sensor is added once a transient startup failure recovers.""" - mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes - mock_duco_client.async_get_time_filter_remaining = AsyncMock( - side_effect=[DucoError("heat recovery info error"), 180] - ) - - await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR]) - - assert hass.states.get(FILTER_REMAINING_ENTITY_ID) is None - - await async_fire_coordinator_update(hass, freezer) - - state = hass.states.get(FILTER_REMAINING_ENTITY_ID) - assert state is not None - assert state.state == "180" - - @pytest.mark.parametrize( ( "node_id", From 254a2b88d7b0dae4d9353184d46cfc9f4aa1d262 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:13:18 +0200 Subject: [PATCH 8/9] Move telegram_bot services to dedicated services module (#181917) --- .../components/telegram_bot/__init__.py | 825 +---------------- .../components/telegram_bot/services.py | 834 ++++++++++++++++++ .../telegram_bot/test_telegram_bot.py | 3 +- 3 files changed, 842 insertions(+), 820 deletions(-) create mode 100644 homeassistant/components/telegram_bot/services.py diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 844aed33075a8..f223bb7b2af77 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -5,45 +5,17 @@ import telegram from telegram import Bot -from telegram.constants import InputMediaType from telegram.error import InvalidToken, TelegramError -import voluptuous as vol -from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_DOMAIN, - ATTR_ENTITY_ID, - ATTR_LATITUDE, - ATTR_LONGITUDE, - ATTR_SERVICE, - CONF_PLATFORM, - Platform, -) -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) +from homeassistant.const import CONF_PLATFORM, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) -from homeassistant.helpers.target import ( - TargetSelection, - async_extract_referenced_entity_ids, -) -from homeassistant.helpers.typing import ConfigType, VolSchemaType -from homeassistant.util.json import JsonValueType +from homeassistant.helpers.typing import ConfigType from . import broadcast, polling, webhooks from .bot import ( @@ -53,415 +25,21 @@ initialize_bot, ) from .const import ( - ATTR_ALLOWS_MULTIPLE_ANSWERS, - ATTR_AUTHENTICATION, - ATTR_CALLBACK_QUERY_ID, - ATTR_CAPTION, - ATTR_CHAT_ACTION, - ATTR_CHAT_ID, - ATTR_DIRECTORY_PATH, - ATTR_DISABLE_NOTIF, - ATTR_DISABLE_WEB_PREV, - ATTR_DRAFT_ID, - ATTR_FILE, - ATTR_FILE_ID, - ATTR_FILE_NAME, - ATTR_IS_ANONYMOUS, - ATTR_IS_BIG, - ATTR_KEYBOARD, - ATTR_KEYBOARD_INLINE, - ATTR_MEDIA, - ATTR_MEDIA_TYPE, - ATTR_MESSAGE, - ATTR_MESSAGE_ID, - ATTR_MESSAGE_TAG, - ATTR_MESSAGE_THREAD_ID, - ATTR_ONE_TIME_KEYBOARD, - ATTR_OPEN_PERIOD, - ATTR_OPTIONS, ATTR_PARSER, - ATTR_PASSWORD, - ATTR_PROTECT_CONTENT, - ATTR_QUESTION, - ATTR_REACTION, - ATTR_REPLY_TO_MSGID, - ATTR_RESIZE_KEYBOARD, - ATTR_SHOW_ALERT, - ATTR_STICKER_ID, - ATTR_TARGET, - ATTR_TIMEOUT, - ATTR_TITLE, - ATTR_URL, - ATTR_USERNAME, - ATTR_VERIFY_SSL, - CHAT_ACTION_CHOOSE_STICKER, - CHAT_ACTION_FIND_LOCATION, - CHAT_ACTION_RECORD_VIDEO, - CHAT_ACTION_RECORD_VIDEO_NOTE, - CHAT_ACTION_RECORD_VOICE, - CHAT_ACTION_TYPING, - CHAT_ACTION_UPLOAD_DOCUMENT, - CHAT_ACTION_UPLOAD_PHOTO, - CHAT_ACTION_UPLOAD_VIDEO, - CHAT_ACTION_UPLOAD_VIDEO_NOTE, - CHAT_ACTION_UPLOAD_VOICE, CONF_API_ENDPOINT, CONF_CHAT_ID, - CONF_CONFIG_ENTRY_ID, DEFAULT_API_ENDPOINT, DOMAIN, - PARSER_HTML, - PARSER_MD, - PARSER_MD2, - PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, PLATFORM_POLLING, PLATFORM_WEBHOOKS, - SERVICE_ANSWER_CALLBACK_QUERY, - SERVICE_DELETE_MESSAGE, - SERVICE_DOWNLOAD_FILE, - SERVICE_EDIT_CAPTION, - SERVICE_EDIT_MESSAGE, - SERVICE_EDIT_MESSAGE_MEDIA, - SERVICE_EDIT_REPLYMARKUP, - SERVICE_LEAVE_CHAT, - SERVICE_SEND_ANIMATION, - SERVICE_SEND_CHAT_ACTION, - SERVICE_SEND_DOCUMENT, - SERVICE_SEND_LOCATION, - SERVICE_SEND_MEDIA_GROUP, - SERVICE_SEND_MESSAGE, - SERVICE_SEND_MESSAGE_DRAFT, - SERVICE_SEND_PHOTO, - SERVICE_SEND_POLL, - SERVICE_SEND_STICKER, - SERVICE_SEND_VIDEO, - SERVICE_SEND_VOICE, - SERVICE_SET_MESSAGE_REACTION, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -ATTR_PARSER_SCHEMA = vol.All( - cv.string, - vol.In([PARSER_HTML, PARSER_MD, PARSER_MD2, PARSER_PLAIN_TEXT]), -) - -BASE_SERVICE_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, - vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, - vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, - vol.Optional(ATTR_RESIZE_KEYBOARD): cv.boolean, - vol.Optional(ATTR_ONE_TIME_KEYBOARD): cv.boolean, - vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, - vol.Optional(ATTR_TIMEOUT): cv.positive_int, - vol.Optional(ATTR_MESSAGE_TAG): cv.string, - vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), - } -) - -SERVICE_SCHEMA_SEND_MESSAGE = vol.All( - cv.deprecated(ATTR_TIMEOUT), - BASE_SERVICE_SCHEMA.extend( - { - vol.Required(ATTR_MESSAGE): cv.string, - vol.Optional(ATTR_TITLE): cv.string, - vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), - } - ), -) - -SERVICE_SCHEMA_SEND_MESSAGE_DRAFT = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), - vol.Required(ATTR_DRAFT_ID): vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Required(ATTR_MESSAGE): cv.string, - vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, - } -) - -SERVICE_SCHEMA_SEND_CHAT_ACTION = vol.All( - cv.deprecated(ATTR_TIMEOUT), - vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Required(ATTR_CHAT_ACTION): vol.In( - ( - CHAT_ACTION_TYPING, - CHAT_ACTION_UPLOAD_PHOTO, - CHAT_ACTION_RECORD_VIDEO, - CHAT_ACTION_UPLOAD_VIDEO, - CHAT_ACTION_RECORD_VOICE, - CHAT_ACTION_UPLOAD_VOICE, - CHAT_ACTION_UPLOAD_DOCUMENT, - CHAT_ACTION_CHOOSE_STICKER, - CHAT_ACTION_FIND_LOCATION, - CHAT_ACTION_RECORD_VIDEO_NOTE, - CHAT_ACTION_UPLOAD_VIDEO_NOTE, - ) - ), - vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), - } - ), -) - -SERVICE_SCHEMA_BASE_SEND_FILE = BASE_SERVICE_SCHEMA.extend( - { - vol.Optional(ATTR_URL): cv.string, - vol.Optional(ATTR_FILE): cv.string, - vol.Optional(ATTR_CAPTION): cv.string, - vol.Optional(ATTR_USERNAME): cv.string, - vol.Optional(ATTR_PASSWORD): cv.string, - vol.Optional(ATTR_AUTHENTICATION): cv.string, - vol.Optional(ATTR_VERIFY_SSL): cv.boolean, - vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), - } -) - -SERVICE_SCHEMA_SEND_FILE = vol.All( - cv.deprecated(ATTR_TIMEOUT), - SERVICE_SCHEMA_BASE_SEND_FILE, -) - -SERVICE_SCHEMA_SEND_MEDIA_GROUP = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Required(ATTR_MEDIA): vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(ATTR_MEDIA_TYPE): vol.In( - ( - str(InputMediaType.AUDIO), - str(InputMediaType.VIDEO), - str(InputMediaType.DOCUMENT), - str(InputMediaType.PHOTO), - ) - ), - vol.Optional(ATTR_URL): cv.string, - vol.Optional(ATTR_FILE): cv.string, - vol.Optional(ATTR_CAPTION): cv.string, - vol.Optional(ATTR_USERNAME): cv.string, - vol.Optional(ATTR_PASSWORD): cv.string, - vol.Optional(ATTR_AUTHENTICATION): cv.string, - vol.Optional(ATTR_VERIFY_SSL, default=True): cv.boolean, - } - ) - ], - vol.Length(min=2, max=10), - ), - vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, - vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, - vol.Optional(ATTR_PROTECT_CONTENT): cv.boolean, - vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), - vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), - } -) - -SERVICE_SCHEMA_SEND_STICKER = vol.All( - cv.deprecated(ATTR_TIMEOUT), - SERVICE_SCHEMA_BASE_SEND_FILE.extend({vol.Optional(ATTR_STICKER_ID): cv.string}), -) - -SERVICE_SCHEMA_SEND_LOCATION = vol.All( - cv.deprecated(ATTR_TIMEOUT), - BASE_SERVICE_SCHEMA.extend( - { - vol.Required(ATTR_LONGITUDE): cv.string, - vol.Required(ATTR_LATITUDE): cv.string, - vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), - } - ), -) - -SERVICE_SCHEMA_SEND_POLL = vol.All( - cv.deprecated(ATTR_TIMEOUT), - vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), - vol.Required(ATTR_QUESTION): cv.string, - vol.Required(ATTR_OPTIONS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_OPEN_PERIOD): cv.positive_int, - vol.Optional(ATTR_IS_ANONYMOUS, default=True): cv.boolean, - vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, - vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, - vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), - vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), - } - ), -) - -SERVICE_SCHEMA_EDIT_MESSAGE = vol.All( - cv.deprecated(ATTR_TIMEOUT), - vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Optional(ATTR_TITLE): cv.string, - vol.Required(ATTR_MESSAGE): cv.string, - vol.Required(ATTR_MESSAGE_ID): vol.Any( - cv.positive_int, vol.All(cv.string, "last") - ), - vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), - vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, - vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, - vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, - } - ), -) - -SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA = vol.All( - cv.deprecated(ATTR_TIMEOUT), - vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGE_ID): vol.Any( - cv.positive_int, vol.All(cv.string, "last") - ), - vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), - vol.Optional(ATTR_CAPTION): cv.string, - vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, - vol.Required(ATTR_MEDIA_TYPE): vol.In( - ( - str(InputMediaType.ANIMATION), - str(InputMediaType.AUDIO), - str(InputMediaType.VIDEO), - str(InputMediaType.DOCUMENT), - str(InputMediaType.PHOTO), - ) - ), - vol.Optional(ATTR_URL): cv.string, - vol.Optional(ATTR_FILE): cv.string, - vol.Optional(ATTR_USERNAME): cv.string, - vol.Optional(ATTR_PASSWORD): cv.string, - vol.Optional(ATTR_AUTHENTICATION): cv.string, - vol.Optional(ATTR_VERIFY_SSL): cv.boolean, - vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, - } - ), -) - -SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGE_ID): vol.Any( - cv.positive_int, vol.All(cv.string, "last") - ), - vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), - vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, - vol.Required(ATTR_CAPTION): cv.string, - vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, - } -) - -SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGE_ID): vol.Any( - cv.positive_int, vol.All(cv.string, "last") - ), - vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), - vol.Required(ATTR_KEYBOARD_INLINE): cv.ensure_list, - } -) - -SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY = vol.Schema( - { - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGE): cv.string, - vol.Required(ATTR_CALLBACK_QUERY_ID): vol.Coerce(int), - vol.Optional(ATTR_SHOW_ALERT): cv.boolean, - } -) - -SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), - vol.Required(ATTR_MESSAGE_ID): vol.Any( - cv.positive_int, vol.All(cv.string, "last") - ), - } -) - -SERVICE_SCHEMA_LEAVE_CHAT = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), - } -) - -SERVICE_SCHEMA_SET_MESSAGE_REACTION = vol.Schema( - { - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_MESSAGE_ID): vol.Any( - cv.positive_int, vol.All(cv.string, "last") - ), - vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), - vol.Required(ATTR_REACTION): cv.string, - vol.Optional(ATTR_IS_BIG, default=False): cv.boolean, - } -) - -SERVICE_SCHEMA_DOWNLOAD_FILE = vol.Schema( - { - vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_FILE_ID): cv.string, - vol.Optional(ATTR_DIRECTORY_PATH): cv.string, - vol.Optional(ATTR_FILE_NAME): cv.string, - } -) - -SERVICE_MAP: dict[str, VolSchemaType] = { - SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, - SERVICE_SEND_MESSAGE_DRAFT: SERVICE_SCHEMA_SEND_MESSAGE_DRAFT, - SERVICE_SEND_CHAT_ACTION: SERVICE_SCHEMA_SEND_CHAT_ACTION, - SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, - SERVICE_SEND_MEDIA_GROUP: SERVICE_SCHEMA_SEND_MEDIA_GROUP, - SERVICE_SEND_STICKER: SERVICE_SCHEMA_SEND_STICKER, - SERVICE_SEND_ANIMATION: SERVICE_SCHEMA_SEND_FILE, - SERVICE_SEND_VIDEO: SERVICE_SCHEMA_SEND_FILE, - SERVICE_SEND_VOICE: SERVICE_SCHEMA_SEND_FILE, - SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE, - SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, - SERVICE_SEND_POLL: SERVICE_SCHEMA_SEND_POLL, - SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE, - SERVICE_EDIT_MESSAGE_MEDIA: SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA, - SERVICE_EDIT_CAPTION: SERVICE_SCHEMA_EDIT_CAPTION, - SERVICE_EDIT_REPLYMARKUP: SERVICE_SCHEMA_EDIT_REPLYMARKUP, - SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY, - SERVICE_DELETE_MESSAGE: SERVICE_SCHEMA_DELETE_MESSAGE, - SERVICE_LEAVE_CHAT: SERVICE_SCHEMA_LEAVE_CHAT, - SERVICE_SET_MESSAGE_REACTION: SERVICE_SCHEMA_SET_MESSAGE_REACTION, - SERVICE_DOWNLOAD_FILE: SERVICE_SCHEMA_DOWNLOAD_FILE, -} - class BotPlatformModule(Protocol): """Define the module protocol for telegram bot modules.""" @@ -483,204 +61,11 @@ async def async_setup_bot_platform( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Telegram bot component.""" - - # Register notification services - for service_notif, schema in SERVICE_MAP.items(): - supports_response = SupportsResponse.NONE - - if service_notif in [ - SERVICE_SEND_MESSAGE, - SERVICE_SEND_CHAT_ACTION, - SERVICE_SEND_PHOTO, - SERVICE_SEND_MEDIA_GROUP, - SERVICE_SEND_ANIMATION, - SERVICE_SEND_VIDEO, - SERVICE_SEND_VOICE, - SERVICE_SEND_DOCUMENT, - SERVICE_SEND_STICKER, - SERVICE_SEND_LOCATION, - SERVICE_SEND_POLL, - SERVICE_DOWNLOAD_FILE, - ]: - supports_response = SupportsResponse.OPTIONAL - - hass.services.async_register( - DOMAIN, - service_notif, - _async_send_telegram_message, - schema=schema, - supports_response=supports_response, - description_placeholders={ - "formatting_options_url": "https://core.telegram.org/bots/api#formatting-options" - }, - ) + async_setup_services(hass) return True -async def _async_send_telegram_message(service: ServiceCall) -> ServiceResponse: - """Handle sending Telegram Bot message service calls.""" - - _deprecate_timeout(service) - - # this is the list of targets to send the message to - targets = _build_targets(service) - - service_responses: JsonValueType = [] - errors: list[tuple[Exception, str]] = [] - - # invoke the service for each target - for target_config_entry, target_chat_id, target_notify_entity_id in targets: - try: - service_response = await _call_service( - service, target_config_entry.runtime_data, target_chat_id - ) - - if service.service == SERVICE_DOWNLOAD_FILE: - return service_response - - if service_response is not None: - formatted_responses: list[JsonValueType] = [] - for chat_id, message_id in service_response.items(): - formatted_response = { - ATTR_CHAT_ID: int(chat_id), - ATTR_MESSAGE_ID: message_id, - } - - if target_notify_entity_id: - formatted_response[ATTR_ENTITY_ID] = target_notify_entity_id - - formatted_responses.append(formatted_response) - - assert isinstance(service_responses, list) - service_responses.extend(formatted_responses) - except (HomeAssistantError, TelegramError) as ex: - target = target_notify_entity_id or str(target_chat_id) - errors.append((ex, target)) - - if len(errors) == 1: - if isinstance(errors[0][0], HomeAssistantError): - raise errors[0][0] - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="action_failed", - translation_placeholders={"error": str(errors[0][0])}, - ) from errors[0][0] - - if len(errors) > 1: - error_messages: list[str] = [] - for error, target in errors: - target_type = ATTR_CHAT_ID if target.isdigit() else ATTR_ENTITY_ID - error_messages.append(f"`{target_type}` {target}: {error}") - - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="multiple_errors", - translation_placeholders={"errors": "\n".join(error_messages)}, - ) - - if service.return_response: - return {"chats": service_responses} - - return None - - -async def _call_service( - service: ServiceCall, notify_service: TelegramNotificationService, chat_id: int -) -> dict[str, JsonValueType] | None: - """Calls a Telegram bot service using the specified bot and chat_id.""" - - service_name = service.service - - kwargs = dict(service.data) - kwargs[ATTR_CHAT_ID] = chat_id - - messages: dict[str, JsonValueType] | None = None - if service_name == SERVICE_SEND_MESSAGE: - messages = await notify_service.send_message(context=service.context, **kwargs) - elif service_name == SERVICE_SEND_MEDIA_GROUP: - messages = await notify_service.send_media_group( - context=service.context, **kwargs - ) - elif service_name == SERVICE_SEND_CHAT_ACTION: - messages = await notify_service.send_chat_action( - context=service.context, **kwargs - ) - elif service_name in [ - SERVICE_SEND_PHOTO, - SERVICE_SEND_ANIMATION, - SERVICE_SEND_VIDEO, - SERVICE_SEND_VOICE, - SERVICE_SEND_DOCUMENT, - ]: - messages = await notify_service.send_file( - service_name, context=service.context, **kwargs - ) - elif service_name == SERVICE_SEND_STICKER: - messages = await notify_service.send_sticker(context=service.context, **kwargs) - elif service_name == SERVICE_SEND_LOCATION: - messages = await notify_service.send_location(context=service.context, **kwargs) - elif service_name == SERVICE_SEND_POLL: - messages = await notify_service.send_poll(context=service.context, **kwargs) - elif service_name == SERVICE_ANSWER_CALLBACK_QUERY: - await notify_service.answer_callback_query(context=service.context, **kwargs) - elif service_name == SERVICE_DELETE_MESSAGE: - await notify_service.delete_message(context=service.context, **kwargs) - elif service_name == SERVICE_LEAVE_CHAT: - await notify_service.leave_chat(context=service.context, **kwargs) - elif service_name == SERVICE_SET_MESSAGE_REACTION: - await notify_service.set_message_reaction(context=service.context, **kwargs) - elif service_name == SERVICE_EDIT_MESSAGE_MEDIA: - await notify_service.edit_message_media(context=service.context, **kwargs) - elif service_name == SERVICE_SEND_MESSAGE_DRAFT: - await notify_service.send_message_draft(context=service.context, **kwargs) - elif service_name == SERVICE_DOWNLOAD_FILE: - return await notify_service.download_file(context=service.context, **kwargs) - else: - await notify_service.edit_message( - service_name, context=service.context, **kwargs - ) - - if service.return_response and messages is not None: - return messages - - return None - - -def _deprecate_timeout(service: ServiceCall) -> None: - if ATTR_TIMEOUT not in service.data: - return - - # default: service was called using frontend such as - # developer tools or automation editor - service_call_origin = "call_service" - - origin = service.context.origin_event - if origin and ATTR_ENTITY_ID in origin.data: - # automation - service_call_origin = origin.data[ATTR_ENTITY_ID] - elif origin and origin.data.get(ATTR_DOMAIN) == SCRIPT_DOMAIN: - # script - service_call_origin = f"{origin.data[ATTR_DOMAIN]}.{origin.data[ATTR_SERVICE]}" - - ir.async_create_issue( - service.hass, - DOMAIN, - "deprecated_timeout_parameter", - breaks_in_ha_version="2026.7.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_timeout_parameter", - translation_placeholders={ - "integration_title": "Telegram Bot", - "action": f"{DOMAIN}.{service.service}", - "action_origin": service_call_origin, - }, - learn_more_url="https://github.com/home-assistant/core/pull/155198", - ) - - async def async_migrate_entry( hass: HomeAssistant, config_entry: TelegramBotConfigEntry ) -> bool: @@ -751,204 +136,6 @@ async def async_migrate_entry( return True -def _build_targets( - service: ServiceCall, -) -> list[tuple[TelegramBotConfigEntry, int, str]]: - """Builds a list of targets from the service parameters. - - Each target is a tuple of (config_entry, chat_id, notify_entity_id). - The config_entry identifies the bot to use for the service call. - The chat_id or notify_entity_id identifies the recipient of the message. - """ - - migrate_chat_ids = _warn_chat_id_migration(service) - - targets: list[tuple[TelegramBotConfigEntry, int, str]] = [] - - # build target list from notify entities using service data: `entity_id` - - referenced = async_extract_referenced_entity_ids( - service.hass, TargetSelection(service.data) - ) - notify_entity_ids = referenced.referenced | referenced.indirectly_referenced - - # parse entity IDs - entity_registry = er.async_get(service.hass) - for notify_entity_id in notify_entity_ids: - # get config entry from notify entity - entity_entry = entity_registry.async_get(notify_entity_id) - if not entity_entry: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_notify_entity", - translation_placeholders={ATTR_ENTITY_ID: notify_entity_id}, - ) - assert entity_entry.config_entry_id is not None - notify_config_entry = service.hass.config_entries.async_get_known_entry( - entity_entry.config_entry_id - ) - - # get chat id from subentry - assert entity_entry.config_subentry_id is not None - notify_config_subentry = notify_config_entry.subentries[ - entity_entry.config_subentry_id - ] - notify_chat_id: int = notify_config_subentry.data[ATTR_CHAT_ID] - - targets.append((notify_config_entry, notify_chat_id, notify_entity_id)) - - # build target list using service data: `config_entry_id` and `chat_id` - - config_entry: TelegramBotConfigEntry | None = None - if CONF_CONFIG_ENTRY_ID in service.data: - # parse config entry from service data - config_entry_id: str = service.data[CONF_CONFIG_ENTRY_ID] - config_entry = service.hass.config_entries.async_get_known_entry( - config_entry_id - ) - else: - # config entry not provided so we try to determine the default - config_entries: list[TelegramBotConfigEntry] = ( - service.hass.config_entries.async_entries(DOMAIN) - ) - if len(config_entries) == 1: - config_entry = config_entries[0] - - # parse chat IDs from service data: `chat_id` - if config_entry is not None: - chat_ids: set[int] = migrate_chat_ids - if ATTR_CHAT_ID in service.data: - chat_ids = chat_ids | set( - [service.data[ATTR_CHAT_ID]] - if isinstance(service.data[ATTR_CHAT_ID], int) - else service.data[ATTR_CHAT_ID] - ) - - if not chat_ids and not targets: - # no targets from service data, so we default - # to the first allowed chat IDs of the config entry - subentries = list(config_entry.subentries.values()) - if not subentries: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="missing_allowed_chat_ids", - translation_placeholders={ - "bot_name": config_entry.title, - }, - ) - - default_chat_id: int = subentries[0].data[ATTR_CHAT_ID] - _LOGGER.debug( - "Defaulting to chat ID %s for bot %s", - default_chat_id, - config_entry.title, - ) - chat_ids = {default_chat_id} - - invalid_chat_ids: set[int] = set() - for chat_id in chat_ids: - # map chat_id to notify entity ID - - if config_entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="entry_not_loaded", - translation_placeholders={"telegram_bot": config_entry.title}, - ) - - entity_id = entity_registry.async_get_entity_id( - "notify", - DOMAIN, - f"{config_entry.runtime_data.bot.id}_{chat_id}", - ) - - if not entity_id: - invalid_chat_ids.add(chat_id) - else: - targets.append((config_entry, chat_id, entity_id)) - - if invalid_chat_ids: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_chat_ids", - translation_placeholders={ - "chat_ids": ", ".join(str(chat_id) for chat_id in invalid_chat_ids), - "bot_name": config_entry.title, - }, - ) - - # we're done building targets from service data - if targets: - return targets - - # can't determine default since multiple config entries exist - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="missing_notify_entities", - ) - - -def _warn_chat_id_migration(service: ServiceCall) -> set[int]: - if not service.data.get(ATTR_TARGET): - return set() - - chat_ids: set[int] = set( - [service.data[ATTR_TARGET]] - if isinstance(service.data[ATTR_TARGET], int) - else service.data[ATTR_TARGET] - ) - - # default: service was called using frontend such as - # developer tools or automation editor - service_call_origin = "call_service" - - origin = service.context.origin_event - if origin and ATTR_ENTITY_ID in origin.data: - # automation - service_call_origin = origin.data[ATTR_ENTITY_ID] - elif origin and origin.data.get(ATTR_DOMAIN) == SCRIPT_DOMAIN: - # script - service_call_origin = f"{origin.data[ATTR_DOMAIN]}.{origin.data[ATTR_SERVICE]}" - - ir.async_create_issue( - service.hass, - DOMAIN, - f"migrate_chat_ids_in_target_{service_call_origin}_{service.service}", - breaks_in_ha_version="2026.9.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="migrate_chat_ids_in_target", - translation_placeholders={ - "integration_title": "Telegram Bot", - "action": f"{DOMAIN}.{service.service}", - "chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids), - "action_origin": service_call_origin, - "telegram_bot_entities_url": "/config/entities?domain=telegram_bot", - "example_old": ( - f"```yaml\naction: {service.service}\ndata:\n" - " target: # to be updated\n" - " - 1234567890\n...\n```" - ), - "example_new_entity_id": ( - f"```yaml\naction: {service.service}\ndata:\n" - " entity_id:\n" - " - notify.telegram_bot_1234567890_1234567890" - " # replace with your notify entity\n...\n```" - ), - "example_new_chat_id": ( - f"```yaml\naction: {service.service}\ndata:\n" - " chat_id:\n" - " - 1234567890" - " # replace with your chat_id\n...\n```" - ), - }, - learn_more_url="https://github.com/home-assistant/core/pull/154868", - ) - - return chat_ids - - def bot_device_info(config_entry: TelegramBotConfigEntry, bot_id: int) -> dr.DeviceInfo: """Return device info for the shared bot device.""" return dr.DeviceInfo( diff --git a/homeassistant/components/telegram_bot/services.py b/homeassistant/components/telegram_bot/services.py new file mode 100644 index 0000000000000..f87275e18d803 --- /dev/null +++ b/homeassistant/components/telegram_bot/services.py @@ -0,0 +1,834 @@ +"""Support for Telegram bot services.""" + +import logging + +from telegram.constants import InputMediaType +from telegram.error import TelegramError +import voluptuous as vol + +from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_DOMAIN, + ATTR_ENTITY_ID, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_SERVICE, +) +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.target import ( + TargetSelection, + async_extract_referenced_entity_ids, +) +from homeassistant.helpers.typing import VolSchemaType +from homeassistant.util.json import JsonValueType + +from .bot import TelegramBotConfigEntry, TelegramNotificationService +from .const import ( + ATTR_ALLOWS_MULTIPLE_ANSWERS, + ATTR_AUTHENTICATION, + ATTR_CALLBACK_QUERY_ID, + ATTR_CAPTION, + ATTR_CHAT_ACTION, + ATTR_CHAT_ID, + ATTR_DIRECTORY_PATH, + ATTR_DISABLE_NOTIF, + ATTR_DISABLE_WEB_PREV, + ATTR_DRAFT_ID, + ATTR_FILE, + ATTR_FILE_ID, + ATTR_FILE_NAME, + ATTR_IS_ANONYMOUS, + ATTR_IS_BIG, + ATTR_KEYBOARD, + ATTR_KEYBOARD_INLINE, + ATTR_MEDIA, + ATTR_MEDIA_TYPE, + ATTR_MESSAGE, + ATTR_MESSAGE_ID, + ATTR_MESSAGE_TAG, + ATTR_MESSAGE_THREAD_ID, + ATTR_ONE_TIME_KEYBOARD, + ATTR_OPEN_PERIOD, + ATTR_OPTIONS, + ATTR_PARSER, + ATTR_PASSWORD, + ATTR_PROTECT_CONTENT, + ATTR_QUESTION, + ATTR_REACTION, + ATTR_REPLY_TO_MSGID, + ATTR_RESIZE_KEYBOARD, + ATTR_SHOW_ALERT, + ATTR_STICKER_ID, + ATTR_TARGET, + ATTR_TIMEOUT, + ATTR_TITLE, + ATTR_URL, + ATTR_USERNAME, + ATTR_VERIFY_SSL, + CHAT_ACTION_CHOOSE_STICKER, + CHAT_ACTION_FIND_LOCATION, + CHAT_ACTION_RECORD_VIDEO, + CHAT_ACTION_RECORD_VIDEO_NOTE, + CHAT_ACTION_RECORD_VOICE, + CHAT_ACTION_TYPING, + CHAT_ACTION_UPLOAD_DOCUMENT, + CHAT_ACTION_UPLOAD_PHOTO, + CHAT_ACTION_UPLOAD_VIDEO, + CHAT_ACTION_UPLOAD_VIDEO_NOTE, + CHAT_ACTION_UPLOAD_VOICE, + CONF_CONFIG_ENTRY_ID, + DOMAIN, + PARSER_HTML, + PARSER_MD, + PARSER_MD2, + PARSER_PLAIN_TEXT, + SERVICE_ANSWER_CALLBACK_QUERY, + SERVICE_DELETE_MESSAGE, + SERVICE_DOWNLOAD_FILE, + SERVICE_EDIT_CAPTION, + SERVICE_EDIT_MESSAGE, + SERVICE_EDIT_MESSAGE_MEDIA, + SERVICE_EDIT_REPLYMARKUP, + SERVICE_LEAVE_CHAT, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_CHAT_ACTION, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_LOCATION, + SERVICE_SEND_MEDIA_GROUP, + SERVICE_SEND_MESSAGE, + SERVICE_SEND_MESSAGE_DRAFT, + SERVICE_SEND_PHOTO, + SERVICE_SEND_POLL, + SERVICE_SEND_STICKER, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SET_MESSAGE_REACTION, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_PARSER_SCHEMA = vol.All( + cv.string, + vol.In([PARSER_HTML, PARSER_MD, PARSER_MD2, PARSER_PLAIN_TEXT]), +) + +BASE_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, + vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, + vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, + vol.Optional(ATTR_RESIZE_KEYBOARD): cv.boolean, + vol.Optional(ATTR_ONE_TIME_KEYBOARD): cv.boolean, + vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, + vol.Optional(ATTR_TIMEOUT): cv.positive_int, + vol.Optional(ATTR_MESSAGE_TAG): cv.string, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), + } +) + +SERVICE_SCHEMA_SEND_MESSAGE = vol.All( + cv.deprecated(ATTR_TIMEOUT), + BASE_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_TITLE): cv.string, + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), + } + ), +) + +SERVICE_SCHEMA_SEND_MESSAGE_DRAFT = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), + vol.Required(ATTR_DRAFT_ID): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, + } +) + +SERVICE_SCHEMA_SEND_CHAT_ACTION = vol.All( + cv.deprecated(ATTR_TIMEOUT), + vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Required(ATTR_CHAT_ACTION): vol.In( + ( + CHAT_ACTION_TYPING, + CHAT_ACTION_UPLOAD_PHOTO, + CHAT_ACTION_RECORD_VIDEO, + CHAT_ACTION_UPLOAD_VIDEO, + CHAT_ACTION_RECORD_VOICE, + CHAT_ACTION_UPLOAD_VOICE, + CHAT_ACTION_UPLOAD_DOCUMENT, + CHAT_ACTION_CHOOSE_STICKER, + CHAT_ACTION_FIND_LOCATION, + CHAT_ACTION_RECORD_VIDEO_NOTE, + CHAT_ACTION_UPLOAD_VIDEO_NOTE, + ) + ), + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), + } + ), +) + +SERVICE_SCHEMA_BASE_SEND_FILE = BASE_SERVICE_SCHEMA.extend( + { + vol.Optional(ATTR_URL): cv.string, + vol.Optional(ATTR_FILE): cv.string, + vol.Optional(ATTR_CAPTION): cv.string, + vol.Optional(ATTR_USERNAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, + vol.Optional(ATTR_AUTHENTICATION): cv.string, + vol.Optional(ATTR_VERIFY_SSL): cv.boolean, + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), + } +) + +SERVICE_SCHEMA_SEND_FILE = vol.All( + cv.deprecated(ATTR_TIMEOUT), + SERVICE_SCHEMA_BASE_SEND_FILE, +) + +SERVICE_SCHEMA_SEND_MEDIA_GROUP = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Required(ATTR_MEDIA): vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(ATTR_MEDIA_TYPE): vol.In( + ( + str(InputMediaType.AUDIO), + str(InputMediaType.VIDEO), + str(InputMediaType.DOCUMENT), + str(InputMediaType.PHOTO), + ) + ), + vol.Optional(ATTR_URL): cv.string, + vol.Optional(ATTR_FILE): cv.string, + vol.Optional(ATTR_CAPTION): cv.string, + vol.Optional(ATTR_USERNAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, + vol.Optional(ATTR_AUTHENTICATION): cv.string, + vol.Optional(ATTR_VERIFY_SSL, default=True): cv.boolean, + } + ) + ], + vol.Length(min=2, max=10), + ), + vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, + vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, + vol.Optional(ATTR_PROTECT_CONTENT): cv.boolean, + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), + } +) + +SERVICE_SCHEMA_SEND_STICKER = vol.All( + cv.deprecated(ATTR_TIMEOUT), + SERVICE_SCHEMA_BASE_SEND_FILE.extend({vol.Optional(ATTR_STICKER_ID): cv.string}), +) + +SERVICE_SCHEMA_SEND_LOCATION = vol.All( + cv.deprecated(ATTR_TIMEOUT), + BASE_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_LONGITUDE): cv.string, + vol.Required(ATTR_LATITUDE): cv.string, + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), + } + ), +) + +SERVICE_SCHEMA_SEND_POLL = vol.All( + cv.deprecated(ATTR_TIMEOUT), + vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Required(ATTR_QUESTION): cv.string, + vol.Required(ATTR_OPTIONS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_OPEN_PERIOD): cv.positive_int, + vol.Optional(ATTR_IS_ANONYMOUS, default=True): cv.boolean, + vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, + vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), + vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int), + } + ), +) + +SERVICE_SCHEMA_EDIT_MESSAGE = vol.All( + cv.deprecated(ATTR_TIMEOUT), + vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_TITLE): cv.string, + vol.Required(ATTR_MESSAGE): cv.string, + vol.Required(ATTR_MESSAGE_ID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), + vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, + vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, + } + ), +) + +SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA = vol.All( + cv.deprecated(ATTR_TIMEOUT), + vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGE_ID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), + vol.Optional(ATTR_CAPTION): cv.string, + vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, + vol.Required(ATTR_MEDIA_TYPE): vol.In( + ( + str(InputMediaType.ANIMATION), + str(InputMediaType.AUDIO), + str(InputMediaType.VIDEO), + str(InputMediaType.DOCUMENT), + str(InputMediaType.PHOTO), + ) + ), + vol.Optional(ATTR_URL): cv.string, + vol.Optional(ATTR_FILE): cv.string, + vol.Optional(ATTR_USERNAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, + vol.Optional(ATTR_AUTHENTICATION): cv.string, + vol.Optional(ATTR_VERIFY_SSL): cv.boolean, + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, + } + ), +) + +SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGE_ID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), + vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA, + vol.Required(ATTR_CAPTION): cv.string, + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, + } +) + +SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGE_ID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), + vol.Required(ATTR_KEYBOARD_INLINE): cv.ensure_list, + } +) + +SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY = vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGE): cv.string, + vol.Required(ATTR_CALLBACK_QUERY_ID): vol.Coerce(int), + vol.Optional(ATTR_SHOW_ALERT): cv.boolean, + } +) + +SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), + vol.Required(ATTR_MESSAGE_ID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + } +) + +SERVICE_SCHEMA_LEAVE_CHAT = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), + } +) + +SERVICE_SCHEMA_SET_MESSAGE_REACTION = vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGE_ID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Optional(ATTR_CHAT_ID): vol.Coerce(int), + vol.Required(ATTR_REACTION): cv.string, + vol.Optional(ATTR_IS_BIG, default=False): cv.boolean, + } +) + +SERVICE_SCHEMA_DOWNLOAD_FILE = vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_FILE_ID): cv.string, + vol.Optional(ATTR_DIRECTORY_PATH): cv.string, + vol.Optional(ATTR_FILE_NAME): cv.string, + } +) + +SERVICE_MAP: dict[str, VolSchemaType] = { + SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, + SERVICE_SEND_MESSAGE_DRAFT: SERVICE_SCHEMA_SEND_MESSAGE_DRAFT, + SERVICE_SEND_CHAT_ACTION: SERVICE_SCHEMA_SEND_CHAT_ACTION, + SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_MEDIA_GROUP: SERVICE_SCHEMA_SEND_MEDIA_GROUP, + SERVICE_SEND_STICKER: SERVICE_SCHEMA_SEND_STICKER, + SERVICE_SEND_ANIMATION: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_VIDEO: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_VOICE: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, + SERVICE_SEND_POLL: SERVICE_SCHEMA_SEND_POLL, + SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE, + SERVICE_EDIT_MESSAGE_MEDIA: SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA, + SERVICE_EDIT_CAPTION: SERVICE_SCHEMA_EDIT_CAPTION, + SERVICE_EDIT_REPLYMARKUP: SERVICE_SCHEMA_EDIT_REPLYMARKUP, + SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY, + SERVICE_DELETE_MESSAGE: SERVICE_SCHEMA_DELETE_MESSAGE, + SERVICE_LEAVE_CHAT: SERVICE_SCHEMA_LEAVE_CHAT, + SERVICE_SET_MESSAGE_REACTION: SERVICE_SCHEMA_SET_MESSAGE_REACTION, + SERVICE_DOWNLOAD_FILE: SERVICE_SCHEMA_DOWNLOAD_FILE, +} + + +async def _async_send_telegram_message(service: ServiceCall) -> ServiceResponse: + """Handle sending Telegram Bot message service calls.""" + + _deprecate_timeout(service) + + # this is the list of targets to send the message to + targets = _build_targets(service) + + service_responses: JsonValueType = [] + errors: list[tuple[Exception, str]] = [] + + # invoke the service for each target + for target_config_entry, target_chat_id, target_notify_entity_id in targets: + try: + service_response = await _call_service( + service, target_config_entry.runtime_data, target_chat_id + ) + + if service.service == SERVICE_DOWNLOAD_FILE: + return service_response + + if service_response is not None: + formatted_responses: list[JsonValueType] = [] + for chat_id, message_id in service_response.items(): + formatted_response = { + ATTR_CHAT_ID: int(chat_id), + ATTR_MESSAGE_ID: message_id, + } + + if target_notify_entity_id: + formatted_response[ATTR_ENTITY_ID] = target_notify_entity_id + + formatted_responses.append(formatted_response) + + assert isinstance(service_responses, list) + service_responses.extend(formatted_responses) + except (HomeAssistantError, TelegramError) as ex: + target = target_notify_entity_id or str(target_chat_id) + errors.append((ex, target)) + + if len(errors) == 1: + if isinstance(errors[0][0], HomeAssistantError): + raise errors[0][0] + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_failed", + translation_placeholders={"error": str(errors[0][0])}, + ) from errors[0][0] + + if len(errors) > 1: + error_messages: list[str] = [] + for error, target in errors: + target_type = ATTR_CHAT_ID if target.isdigit() else ATTR_ENTITY_ID + error_messages.append(f"`{target_type}` {target}: {error}") + + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="multiple_errors", + translation_placeholders={"errors": "\n".join(error_messages)}, + ) + + if service.return_response: + return {"chats": service_responses} + + return None + + +async def _call_service( + service: ServiceCall, notify_service: TelegramNotificationService, chat_id: int +) -> dict[str, JsonValueType] | None: + """Calls a Telegram bot service using the specified bot and chat_id.""" + + service_name = service.service + + kwargs = dict(service.data) + kwargs[ATTR_CHAT_ID] = chat_id + + messages: dict[str, JsonValueType] | None = None + if service_name == SERVICE_SEND_MESSAGE: + messages = await notify_service.send_message(context=service.context, **kwargs) + elif service_name == SERVICE_SEND_MEDIA_GROUP: + messages = await notify_service.send_media_group( + context=service.context, **kwargs + ) + elif service_name == SERVICE_SEND_CHAT_ACTION: + messages = await notify_service.send_chat_action( + context=service.context, **kwargs + ) + elif service_name in [ + SERVICE_SEND_PHOTO, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + ]: + messages = await notify_service.send_file( + service_name, context=service.context, **kwargs + ) + elif service_name == SERVICE_SEND_STICKER: + messages = await notify_service.send_sticker(context=service.context, **kwargs) + elif service_name == SERVICE_SEND_LOCATION: + messages = await notify_service.send_location(context=service.context, **kwargs) + elif service_name == SERVICE_SEND_POLL: + messages = await notify_service.send_poll(context=service.context, **kwargs) + elif service_name == SERVICE_ANSWER_CALLBACK_QUERY: + await notify_service.answer_callback_query(context=service.context, **kwargs) + elif service_name == SERVICE_DELETE_MESSAGE: + await notify_service.delete_message(context=service.context, **kwargs) + elif service_name == SERVICE_LEAVE_CHAT: + await notify_service.leave_chat(context=service.context, **kwargs) + elif service_name == SERVICE_SET_MESSAGE_REACTION: + await notify_service.set_message_reaction(context=service.context, **kwargs) + elif service_name == SERVICE_EDIT_MESSAGE_MEDIA: + await notify_service.edit_message_media(context=service.context, **kwargs) + elif service_name == SERVICE_SEND_MESSAGE_DRAFT: + await notify_service.send_message_draft(context=service.context, **kwargs) + elif service_name == SERVICE_DOWNLOAD_FILE: + return await notify_service.download_file(context=service.context, **kwargs) + else: + await notify_service.edit_message( + service_name, context=service.context, **kwargs + ) + + if service.return_response and messages is not None: + return messages + + return None + + +def _deprecate_timeout(service: ServiceCall) -> None: + if ATTR_TIMEOUT not in service.data: + return + + # default: service was called using frontend such as + # developer tools or automation editor + service_call_origin = "call_service" + + origin = service.context.origin_event + if origin and ATTR_ENTITY_ID in origin.data: + # automation + service_call_origin = origin.data[ATTR_ENTITY_ID] + elif origin and origin.data.get(ATTR_DOMAIN) == SCRIPT_DOMAIN: + # script + service_call_origin = f"{origin.data[ATTR_DOMAIN]}.{origin.data[ATTR_SERVICE]}" + + ir.async_create_issue( + service.hass, + DOMAIN, + "deprecated_timeout_parameter", + breaks_in_ha_version="2026.7.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_timeout_parameter", + translation_placeholders={ + "integration_title": "Telegram Bot", + "action": f"{DOMAIN}.{service.service}", + "action_origin": service_call_origin, + }, + learn_more_url="https://github.com/home-assistant/core/pull/155198", + ) + + +def _build_targets( + service: ServiceCall, +) -> list[tuple[TelegramBotConfigEntry, int, str]]: + """Builds a list of targets from the service parameters. + + Each target is a tuple of (config_entry, chat_id, notify_entity_id). + The config_entry identifies the bot to use for the service call. + The chat_id or notify_entity_id identifies the recipient of the message. + """ + + migrate_chat_ids = _warn_chat_id_migration(service) + + targets: list[tuple[TelegramBotConfigEntry, int, str]] = [] + + # build target list from notify entities using service data: `entity_id` + + referenced = async_extract_referenced_entity_ids( + service.hass, TargetSelection(service.data) + ) + notify_entity_ids = referenced.referenced | referenced.indirectly_referenced + + # parse entity IDs + entity_registry = er.async_get(service.hass) + for notify_entity_id in notify_entity_ids: + # get config entry from notify entity + entity_entry = entity_registry.async_get(notify_entity_id) + if not entity_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_notify_entity", + translation_placeholders={ATTR_ENTITY_ID: notify_entity_id}, + ) + assert entity_entry.config_entry_id is not None + notify_config_entry = service.hass.config_entries.async_get_known_entry( + entity_entry.config_entry_id + ) + + # get chat id from subentry + assert entity_entry.config_subentry_id is not None + notify_config_subentry = notify_config_entry.subentries[ + entity_entry.config_subentry_id + ] + notify_chat_id: int = notify_config_subentry.data[ATTR_CHAT_ID] + + targets.append((notify_config_entry, notify_chat_id, notify_entity_id)) + + # build target list using service data: `config_entry_id` and `chat_id` + + config_entry: TelegramBotConfigEntry | None = None + if CONF_CONFIG_ENTRY_ID in service.data: + # parse config entry from service data + config_entry_id: str = service.data[CONF_CONFIG_ENTRY_ID] + config_entry = service.hass.config_entries.async_get_known_entry( + config_entry_id + ) + else: + # config entry not provided so we try to determine the default + config_entries: list[TelegramBotConfigEntry] = ( + service.hass.config_entries.async_entries(DOMAIN) + ) + if len(config_entries) == 1: + config_entry = config_entries[0] + + # parse chat IDs from service data: `chat_id` + if config_entry is not None: + chat_ids: set[int] = migrate_chat_ids + if ATTR_CHAT_ID in service.data: + chat_ids = chat_ids | set( + [service.data[ATTR_CHAT_ID]] + if isinstance(service.data[ATTR_CHAT_ID], int) + else service.data[ATTR_CHAT_ID] + ) + + if not chat_ids and not targets: + # no targets from service data, so we default + # to the first allowed chat IDs of the config entry + subentries = list(config_entry.subentries.values()) + if not subentries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_allowed_chat_ids", + translation_placeholders={ + "bot_name": config_entry.title, + }, + ) + + default_chat_id: int = subentries[0].data[ATTR_CHAT_ID] + _LOGGER.debug( + "Defaulting to chat ID %s for bot %s", + default_chat_id, + config_entry.title, + ) + chat_ids = {default_chat_id} + + invalid_chat_ids: set[int] = set() + for chat_id in chat_ids: + # map chat_id to notify entity ID + + if config_entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + translation_placeholders={"telegram_bot": config_entry.title}, + ) + + entity_id = entity_registry.async_get_entity_id( + "notify", + DOMAIN, + f"{config_entry.runtime_data.bot.id}_{chat_id}", + ) + + if not entity_id: + invalid_chat_ids.add(chat_id) + else: + targets.append((config_entry, chat_id, entity_id)) + + if invalid_chat_ids: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_chat_ids", + translation_placeholders={ + "chat_ids": ", ".join(str(chat_id) for chat_id in invalid_chat_ids), + "bot_name": config_entry.title, + }, + ) + + # we're done building targets from service data + if targets: + return targets + + # can't determine default since multiple config entries exist + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_notify_entities", + ) + + +def _warn_chat_id_migration(service: ServiceCall) -> set[int]: + if not service.data.get(ATTR_TARGET): + return set() + + chat_ids: set[int] = set( + [service.data[ATTR_TARGET]] + if isinstance(service.data[ATTR_TARGET], int) + else service.data[ATTR_TARGET] + ) + + # default: service was called using frontend such as + # developer tools or automation editor + service_call_origin = "call_service" + + origin = service.context.origin_event + if origin and ATTR_ENTITY_ID in origin.data: + # automation + service_call_origin = origin.data[ATTR_ENTITY_ID] + elif origin and origin.data.get(ATTR_DOMAIN) == SCRIPT_DOMAIN: + # script + service_call_origin = f"{origin.data[ATTR_DOMAIN]}.{origin.data[ATTR_SERVICE]}" + + ir.async_create_issue( + service.hass, + DOMAIN, + f"migrate_chat_ids_in_target_{service_call_origin}_{service.service}", + breaks_in_ha_version="2026.9.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="migrate_chat_ids_in_target", + translation_placeholders={ + "integration_title": "Telegram Bot", + "action": f"{DOMAIN}.{service.service}", + "chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids), + "action_origin": service_call_origin, + "telegram_bot_entities_url": "/config/entities?domain=telegram_bot", + "example_old": ( + f"```yaml\naction: {service.service}\ndata:\n" + " target: # to be updated\n" + " - 1234567890\n...\n```" + ), + "example_new_entity_id": ( + f"```yaml\naction: {service.service}\ndata:\n" + " entity_id:\n" + " - notify.telegram_bot_1234567890_1234567890" + " # replace with your notify entity\n...\n```" + ), + "example_new_chat_id": ( + f"```yaml\naction: {service.service}\ndata:\n" + " chat_id:\n" + " - 1234567890" + " # replace with your chat_id\n...\n```" + ), + }, + learn_more_url="https://github.com/home-assistant/core/pull/154868", + ) + + return chat_ids + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the Telegram bot services.""" + for service_notif, schema in SERVICE_MAP.items(): + supports_response = SupportsResponse.NONE + + if service_notif in [ + SERVICE_SEND_MESSAGE, + SERVICE_SEND_CHAT_ACTION, + SERVICE_SEND_PHOTO, + SERVICE_SEND_MEDIA_GROUP, + SERVICE_SEND_ANIMATION, + SERVICE_SEND_VIDEO, + SERVICE_SEND_VOICE, + SERVICE_SEND_DOCUMENT, + SERVICE_SEND_STICKER, + SERVICE_SEND_LOCATION, + SERVICE_SEND_POLL, + SERVICE_DOWNLOAD_FILE, + ]: + supports_response = SupportsResponse.OPTIONAL + + hass.services.async_register( + DOMAIN, + service_notif, + _async_send_telegram_message, + schema=schema, + supports_response=supports_response, + description_placeholders={ + "formatting_options_url": "https://core.telegram.org/bots/api#formatting-options" + }, + ) diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 577cbfbb6c526..c67cc71973675 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -26,7 +26,6 @@ TimedOut, ) -from homeassistant.components.telegram_bot import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.components.telegram_bot.bot import ALLOWED_UPDATES from homeassistant.components.telegram_bot.const import ( ATTR_AUTHENTICATION, @@ -92,6 +91,8 @@ from homeassistant.const import ( ATTR_DOMAIN, ATTR_ENTITY_ID, + ATTR_LATITUDE, + ATTR_LONGITUDE, ATTR_SERVICE, CONF_API_KEY, CONF_PLATFORM, From 77cae88e6722b2768418e6c6fac36b6c9b839f13 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:36:51 +0200 Subject: [PATCH 9/9] Fix missing re-export in telegram_bot (#181920) --- .core_files.yaml | 1 + homeassistant/components/telegram_bot/__init__.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/.core_files.yaml b/.core_files.yaml index dd3fd828fcb0a..d2d2ae9780b5a 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -118,6 +118,7 @@ components: &components - homeassistant/components/sun/** - homeassistant/components/system_health/** - homeassistant/components/tag/** + - homeassistant/components/telegram_bot/** - homeassistant/components/template/** - homeassistant/components/timer/** - homeassistant/components/trace/** diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index f223bb7b2af77..54d567aa8a01c 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -24,7 +24,14 @@ TelegramNotificationService, initialize_bot, ) + +# The ATTR_* below are unused here, but re-exported for the telegram integration. from .const import ( + ATTR_CHAT_ID, # noqa: F401 + ATTR_DISABLE_NOTIF, # noqa: F401 + ATTR_DISABLE_WEB_PREV, # noqa: F401 + ATTR_MESSAGE_TAG, # noqa: F401 + ATTR_MESSAGE_THREAD_ID, # noqa: F401 ATTR_PARSER, CONF_API_ENDPOINT, CONF_CHAT_ID,