From 411491dc45aa935261d3b3650f08e4615a42719d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 16 Jan 2026 11:19:51 +0100 Subject: [PATCH 1/6] Type OpenAI config entry consistently (#161052) --- homeassistant/components/openai_conversation/__init__.py | 4 ++-- homeassistant/components/openai_conversation/ai_task.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index b4c9a16693a35..a6f24e5787cb7 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -259,7 +259,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool: """Unload OpenAI.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -280,7 +280,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} + api_keys_entries: dict[str, tuple[OpenAIConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py index 91933a36bb97e..29badc0bc8296 100644 --- a/homeassistant/components/openai_conversation/ai_task.py +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -10,7 +10,6 @@ from openai.types.responses.response_output_item import ImageGenerationCall from homeassistant.components import ai_task, conversation -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -35,7 +34,7 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenAIConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AI Task entities.""" From 49c42b9ad07cb2e1e8a5a453cc6c29930e5d15b0 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Fri, 16 Jan 2026 12:51:42 +0100 Subject: [PATCH 2/6] Clean up unnecessary Z-Wave "device config changed" repairs (#161000) --- homeassistant/components/zwave_js/__init__.py | 33 +++++---- tests/components/zwave_js/test_repairs.py | 70 +++++++++++++++---- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 2076c37856e92..e14fd0757f6ec 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -840,19 +840,26 @@ async def async_on_node_ready(self, node: ZwaveNode) -> None: # After ensuring the node is set up in HA, we should check if the node's # device config has changed, and if so, issue a repair registry entry for a # possible reinterview - if not node.is_controller_node and await node.async_has_device_config_changed(): - device_name = device.name_by_user or device.name or "Unnamed device" - async_create_issue( - self.hass, - DOMAIN, - f"device_config_file_changed.{device.id}", - data={"device_id": device.id, "device_name": device_name}, - is_fixable=True, - is_persistent=False, - translation_key="device_config_file_changed", - translation_placeholders={"device_name": device_name}, - severity=IssueSeverity.WARNING, - ) + if not node.is_controller_node: + issue_id = f"device_config_file_changed.{device.id}" + if await node.async_has_device_config_changed(): + device_name = device.name_by_user or device.name or "Unnamed device" + async_create_issue( + self.hass, + DOMAIN, + issue_id, + data={"device_id": device.id, "device_name": device_name}, + is_fixable=True, + is_persistent=False, + translation_key="device_config_file_changed", + translation_placeholders={"device_name": device_name}, + severity=IssueSeverity.WARNING, + ) + else: + # Clear any existing repair issue if the device config is not considered + # changed. This can happen when the original issue was created by + # an upstream bug, or the change has been reverted. + async_delete_issue(self.hass, DOMAIN, issue_id) async def async_handle_discovery_info( self, diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d47fd771127ae..cb2c5a846c724 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -4,8 +4,9 @@ from unittest.mock import MagicMock, patch import pytest +from zwave_js_server.client import Client from zwave_js_server.event import Event -from zwave_js_server.model.node import Node +from zwave_js_server.model.node import Node, NodeDataType from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.const import CONF_KEEP_OLD_DEVICES @@ -23,9 +24,12 @@ async def _trigger_repair_issue( - hass: HomeAssistant, client, multisensor_6_state + hass: HomeAssistant, + client: Client, + multisensor_6_state: NodeDataType, + device_config_changed: bool = True, ) -> Node: - """Trigger repair issue.""" + """Trigger repair issue with configurable device config changed status.""" # Create a node node_state = deepcopy(multisensor_6_state) node = Node(client, node_state) @@ -40,7 +44,7 @@ async def _trigger_repair_issue( ) with patch( "zwave_js_server.model.node.Node.async_has_device_config_changed", - return_value=True, + return_value=device_config_changed, ): client.driver.controller.receive_event(event) await hass.async_block_till_done() @@ -55,9 +59,9 @@ async def test_device_config_file_changed_confirm_step( hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - client, - multisensor_6_state, - integration, + client: Client, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test the device_config_file_changed issue confirm step.""" node = await _trigger_repair_issue(hass, client, multisensor_6_state) @@ -116,14 +120,54 @@ async def test_device_config_file_changed_confirm_step( assert len(msg["result"]["issues"]) == 0 +async def test_device_config_file_changed_cleared( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + client: Client, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, +) -> None: + """Test the device_config_file_changed issue is cleared when no longer true.""" + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, node)} + ) + assert device + issue_id = f"device_config_file_changed.{device.id}" + + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + + # Assert the issue is present + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["issue_id"] == issue_id + + # Simulate the node becoming ready again with device config no longer changed + await _trigger_repair_issue( + hass, client, multisensor_6_state, device_config_changed=False + ) + + # Assert the issue is now cleared + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + async def test_device_config_file_changed_ignore_step( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - client, - multisensor_6_state, - integration, + client: Client, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test the device_config_file_changed issue ignore step.""" node = await _trigger_repair_issue(hass, client, multisensor_6_state) @@ -237,9 +281,9 @@ async def test_abort_confirm( hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, - client, - multisensor_6_state, - integration, + client: Client, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test aborting device_config_file_changed issue in confirm step.""" node = await _trigger_repair_issue(hass, client, multisensor_6_state) From 49bd26da86a83d4b953f949eda11a3439d351a2a Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 16 Jan 2026 12:37:22 +0000 Subject: [PATCH 3/6] Bump aiomealie to 1.2.0 (#161058) --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../mealie/snapshots/test_services.ambr | 110 ++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 5e090a6af738b..21fe1b11197c6 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["aiomealie==1.1.1"] + "requirements": ["aiomealie==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 85b2adb1e506a..2b99ebe239993 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -319,7 +319,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.1.1 +aiomealie==1.2.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12e48e799d33d..25b94eb7f0059 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -304,7 +304,7 @@ aiolookin==1.0.0 aiolyric==2.0.2 # homeassistant.components.mealie -aiomealie==1.1.1 +aiomealie==1.2.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 30f70bc9273ba..2591741c8d209 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1647,80 +1647,135 @@ 'image': 'SuPW', 'ingredients': list([ dict({ + 'display': '1 130g dark couverture chocolate (min. 55% cocoa content)', + 'food': None, 'is_food': None, 'note': '130g dark couverture chocolate (min. 55% cocoa content)', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a3adfe78-d157-44d8-98be-9c133e45bb4e', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 1 Vanilla Pod', + 'food': None, 'is_food': True, 'note': '1 Vanilla Pod', + 'original_text': None, 'quantity': 1.0, 'reference_id': '41d234d7-c040-48f9-91e6-f4636aebb77b', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 150g softened butter', + 'food': None, 'is_food': None, 'note': '150g softened butter', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'f6ce06bf-8b02-43e6-8316-0dc3fb0da0fc', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 100g Icing sugar', + 'food': None, 'is_food': True, 'note': '100g Icing sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'f7fcd86e-b04b-4e07-b69c-513925811491', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 6 Eggs', + 'food': None, 'is_food': True, 'note': '6 Eggs', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a831fbc3-e2f5-452e-a745-450be8b4a130', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 100g Castor sugar', + 'food': None, 'is_food': True, 'note': '100g Castor sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'b5ee4bdc-0047-4de7-968b-f3360bbcb31e', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 140g Plain wheat flour', + 'food': None, 'is_food': True, 'note': '140g Plain wheat flour', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a67db09d-429c-4e77-919d-cfed3da675ad', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 200g apricot jam', + 'food': None, 'is_food': True, 'note': '200g apricot jam', + 'original_text': None, 'quantity': 1.0, 'reference_id': '55479752-c062-4b25-aae3-2b210999d7b9', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 200g castor sugar', + 'food': None, 'is_food': True, 'note': '200g castor sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'ff9cd404-24ec-4d38-b0aa-0120ce1df679', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 150g dark couverture chocolate (min. 55% cocoa content)', + 'food': None, 'is_food': True, 'note': '150g dark couverture chocolate (min. 55% cocoa content)', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'c7fca92e-971e-4728-a227-8b04783583ed', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 Unsweetend whipped cream to garnish', + 'food': None, 'is_food': True, 'note': 'Unsweetend whipped cream to garnish', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'ef023f23-7816-4871-87f6-4d29f9a283f7', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), ]), @@ -2223,80 +2278,135 @@ 'image': 'SuPW', 'ingredients': list([ dict({ + 'display': '1 130g dark couverture chocolate (min. 55% cocoa content)', + 'food': None, 'is_food': None, 'note': '130g dark couverture chocolate (min. 55% cocoa content)', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a3adfe78-d157-44d8-98be-9c133e45bb4e', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 1 Vanilla Pod', + 'food': None, 'is_food': True, 'note': '1 Vanilla Pod', + 'original_text': None, 'quantity': 1.0, 'reference_id': '41d234d7-c040-48f9-91e6-f4636aebb77b', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 150g softened butter', + 'food': None, 'is_food': None, 'note': '150g softened butter', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'f6ce06bf-8b02-43e6-8316-0dc3fb0da0fc', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 100g Icing sugar', + 'food': None, 'is_food': True, 'note': '100g Icing sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'f7fcd86e-b04b-4e07-b69c-513925811491', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 6 Eggs', + 'food': None, 'is_food': True, 'note': '6 Eggs', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a831fbc3-e2f5-452e-a745-450be8b4a130', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 100g Castor sugar', + 'food': None, 'is_food': True, 'note': '100g Castor sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'b5ee4bdc-0047-4de7-968b-f3360bbcb31e', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 140g Plain wheat flour', + 'food': None, 'is_food': True, 'note': '140g Plain wheat flour', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'a67db09d-429c-4e77-919d-cfed3da675ad', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 200g apricot jam', + 'food': None, 'is_food': True, 'note': '200g apricot jam', + 'original_text': None, 'quantity': 1.0, 'reference_id': '55479752-c062-4b25-aae3-2b210999d7b9', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 200g castor sugar', + 'food': None, 'is_food': True, 'note': '200g castor sugar', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'ff9cd404-24ec-4d38-b0aa-0120ce1df679', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 150g dark couverture chocolate (min. 55% cocoa content)', + 'food': None, 'is_food': True, 'note': '150g dark couverture chocolate (min. 55% cocoa content)', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'c7fca92e-971e-4728-a227-8b04783583ed', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), dict({ + 'display': '1 Unsweetend whipped cream to garnish', + 'food': None, 'is_food': True, 'note': 'Unsweetend whipped cream to garnish', + 'original_text': None, 'quantity': 1.0, 'reference_id': 'ef023f23-7816-4871-87f6-4d29f9a283f7', + 'referenced_recipe': None, + 'title': None, 'unit': None, }), ]), From bd24c27bc9613d28861e18b0f22674a0526c9e8e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Fri, 16 Jan 2026 13:56:15 +0100 Subject: [PATCH 4/6] SMA add selector strings/translation (#161060) --- homeassistant/components/sma/strings.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index 2e06550754560..55f2d2512b74d 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -35,7 +35,6 @@ "data": { "group": "[%key:component::sma::config::step::user::data::group%]", "host": "[%key:common::config_flow::data::host%]", - "password": "[%key:common::config_flow::data::password%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, @@ -57,5 +56,13 @@ "title": "Set up SMA Solar" } } + }, + "selector": { + "group": { + "options": { + "installer": "Installer", + "user": "User" + } + } } } From cee007b0b02fcb7d850c6274e84bfa3a11d9faee Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:31:42 +0100 Subject: [PATCH 5/6] Add binary sensor platform to Mastodon (#161056) --- homeassistant/components/mastodon/__init__.py | 2 +- .../components/mastodon/binary_sensor.py | 128 ++++++ homeassistant/components/mastodon/icons.json | 13 + .../components/mastodon/strings.json | 10 + .../snapshots/test_binary_sensor.ambr | 393 ++++++++++++++++++ .../components/mastodon/test_binary_sensor.py | 28 ++ 6 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/mastodon/binary_sensor.py create mode 100644 tests/components/mastodon/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/mastodon/test_binary_sensor.py diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 531b88ac38a65..8e4910d937aa1 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -28,7 +28,7 @@ from .services import async_setup_services from .utils import construct_mastodon_username, create_mastodon_client -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/mastodon/binary_sensor.py b/homeassistant/components/mastodon/binary_sensor.py new file mode 100644 index 0000000000000..42400c8b23863 --- /dev/null +++ b/homeassistant/components/mastodon/binary_sensor.py @@ -0,0 +1,128 @@ +"""Binary sensor platform for the Mastodon integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from mastodon.Mastodon import Account + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import MastodonConfigEntry +from .entity import MastodonEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class MastodonBinarySensor(StrEnum): + """Mastodon binary sensors.""" + + BOT = "bot" + SUSPENDED = "suspended" + DISCOVERABLE = "discoverable" + LOCKED = "locked" + INDEXABLE = "indexable" + LIMITED = "limited" + MEMORIAL = "memorial" + MOVED = "moved" + + +@dataclass(frozen=True, kw_only=True) +class MastodonBinarySensorEntityDescription(BinarySensorEntityDescription): + """Mastodon binary sensor description.""" + + is_on_fn: Callable[[Account], bool | None] + + +ENTITY_DESCRIPTIONS: tuple[MastodonBinarySensorEntityDescription, ...] = ( + MastodonBinarySensorEntityDescription( + key=MastodonBinarySensor.BOT, + translation_key=MastodonBinarySensor.BOT, + is_on_fn=lambda account: account.bot, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MastodonBinarySensorEntityDescription( + key=MastodonBinarySensor.DISCOVERABLE, + translation_key=MastodonBinarySensor.DISCOVERABLE, + is_on_fn=lambda account: account.discoverable, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MastodonBinarySensorEntityDescription( + key=MastodonBinarySensor.LOCKED, + translation_key=MastodonBinarySensor.LOCKED, + is_on_fn=lambda account: account.locked, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MastodonBinarySensorEntityDescription( + key=MastodonBinarySensor.MOVED, + translation_key=MastodonBinarySensor.MOVED, + is_on_fn=lambda account: account.moved is not None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MastodonBinarySensorEntityDescription( + key=MastodonBinarySensor.INDEXABLE, + translation_key=MastodonBinarySensor.INDEXABLE, + is_on_fn=lambda account: account.indexable, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MastodonBinarySensorEntityDescription( + key=MastodonBinarySensor.LIMITED, + translation_key=MastodonBinarySensor.LIMITED, + is_on_fn=lambda account: account.limited is True, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MastodonBinarySensorEntityDescription( + key=MastodonBinarySensor.MEMORIAL, + translation_key=MastodonBinarySensor.MEMORIAL, + is_on_fn=lambda account: account.memorial is True, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MastodonBinarySensorEntityDescription( + key=MastodonBinarySensor.SUSPENDED, + translation_key=MastodonBinarySensor.SUSPENDED, + is_on_fn=lambda account: account.suspended is True, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MastodonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the binary sensor platform.""" + coordinator = entry.runtime_data.coordinator + + async_add_entities( + MastodonBinarySensorEntity( + coordinator=coordinator, + entity_description=entity_description, + data=entry, + ) + for entity_description in ENTITY_DESCRIPTIONS + ) + + +class MastodonBinarySensorEntity(MastodonEntity, BinarySensorEntity): + """Mastodon binary sensor entity.""" + + entity_description: MastodonBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json index e7272c2b6f8cd..50bfcb1040d89 100644 --- a/homeassistant/components/mastodon/icons.json +++ b/homeassistant/components/mastodon/icons.json @@ -1,5 +1,18 @@ { "entity": { + "binary_sensor": { + "bot": { "default": "mdi:robot" }, + "discoverable": { "default": "mdi:magnify-scan" }, + "indexable": { "default": "mdi:search-web" }, + "limited": { "default": "mdi:volume-mute" }, + "locked": { + "default": "mdi:account-lock", + "state": { "off": "mdi:account-lock-open" } + }, + "memorial": { "default": "mdi:candle" }, + "moved": { "default": "mdi:truck-delivery" }, + "suspended": { "default": "mdi:account-off" } + }, "sensor": { "followers": { "default": "mdi:account-multiple" diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 93161a8129d25..acd1c93eb28f9 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -26,6 +26,16 @@ } }, "entity": { + "binary_sensor": { + "bot": { "name": "Bot" }, + "discoverable": { "name": "Discoverable" }, + "indexable": { "name": "Indexable" }, + "limited": { "name": "Muted" }, + "locked": { "name": "Locked" }, + "memorial": { "name": "Memorial" }, + "moved": { "name": "Moved" }, + "suspended": { "name": "Suspended" } + }, "sensor": { "followers": { "name": "Followers", diff --git a/tests/components/mastodon/snapshots/test_binary_sensor.ambr b/tests/components/mastodon/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..c4176e92a543a --- /dev/null +++ b/tests/components/mastodon/snapshots/test_binary_sensor.ambr @@ -0,0 +1,393 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_bot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_bot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bot', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bot', + 'platform': 'mastodon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'trwnh_mastodon_social_bot', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_bot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mastodon @trwnh@mastodon.social Bot', + }), + 'context': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_bot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_discoverable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_discoverable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discoverable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Discoverable', + 'platform': 'mastodon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'trwnh_mastodon_social_discoverable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_discoverable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mastodon @trwnh@mastodon.social Discoverable', + }), + 'context': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_discoverable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_indexable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_indexable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Indexable', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Indexable', + 'platform': 'mastodon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'trwnh_mastodon_social_indexable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_indexable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mastodon @trwnh@mastodon.social Indexable', + }), + 'context': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_indexable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_locked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_locked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Locked', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Locked', + 'platform': 'mastodon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'trwnh_mastodon_social_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_locked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mastodon @trwnh@mastodon.social Locked', + }), + 'context': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_locked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_memorial-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_memorial', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Memorial', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Memorial', + 'platform': 'mastodon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'trwnh_mastodon_social_memorial', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_memorial-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mastodon @trwnh@mastodon.social Memorial', + }), + 'context': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_memorial', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_moved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_moved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Moved', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Moved', + 'platform': 'mastodon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'trwnh_mastodon_social_moved', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_moved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mastodon @trwnh@mastodon.social Moved', + }), + 'context': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_moved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_muted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_muted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Muted', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Muted', + 'platform': 'mastodon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'trwnh_mastodon_social_limited', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_muted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mastodon @trwnh@mastodon.social Muted', + }), + 'context': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_muted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_suspended-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_suspended', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Suspended', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Suspended', + 'platform': 'mastodon', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'trwnh_mastodon_social_suspended', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.mastodon_trwnh_mastodon_social_suspended-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mastodon @trwnh@mastodon.social Suspended', + }), + 'context': , + 'entity_id': 'binary_sensor.mastodon_trwnh_mastodon_social_suspended', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/mastodon/test_binary_sensor.py b/tests/components/mastodon/test_binary_sensor.py new file mode 100644 index 0000000000000..212daebbd3895 --- /dev/null +++ b/tests/components/mastodon/test_binary_sensor.py @@ -0,0 +1,28 @@ +"""Tests for the Mastodon binary sensors.""" + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_mastodon_client", "entity_registry_enabled_by_default") +async def test_binary_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the binary sensor entities.""" + with patch("homeassistant.components.mastodon.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 6e7b206788763d52996cb355abe61ea9baa09a30 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:18:06 +0100 Subject: [PATCH 6/6] Add update preview feature to labs (#160989) --- homeassistant/components/labs/__init__.py | 7 ++- homeassistant/components/labs/helpers.py | 29 ++++++++++ .../components/labs/websocket_api.py | 22 ++----- tests/components/labs/test_init.py | 57 +++++++++++++++++++ 4 files changed, 98 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/labs/__init__.py b/homeassistant/components/labs/__init__.py index 73bee604450ff..485d7f8c878eb 100644 --- a/homeassistant/components/labs/__init__.py +++ b/homeassistant/components/labs/__init__.py @@ -18,7 +18,11 @@ from homeassistant.loader import async_get_custom_components from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION -from .helpers import async_is_preview_feature_enabled, async_listen +from .helpers import ( + async_is_preview_feature_enabled, + async_listen, + async_update_preview_feature, +) from .models import ( EventLabsUpdatedData, LabPreviewFeature, @@ -37,6 +41,7 @@ "EventLabsUpdatedData", "async_is_preview_feature_enabled", "async_listen", + "async_update_preview_feature", ] diff --git a/homeassistant/components/labs/helpers.py b/homeassistant/components/labs/helpers.py index 3b9ce94b4ad98..85430724bef2b 100644 --- a/homeassistant/components/labs/helpers.py +++ b/homeassistant/components/labs/helpers.py @@ -61,3 +61,32 @@ def _async_feature_updated(event: Event[EventLabsUpdatedData]) -> None: listener() return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated) + + +async def async_update_preview_feature( + hass: HomeAssistant, + domain: str, + preview_feature: str, + enabled: bool, +) -> None: + """Update a lab preview feature state.""" + labs_data = hass.data[LABS_DATA] + + preview_feature_id = f"{domain}.{preview_feature}" + + if preview_feature_id not in labs_data.preview_features: + raise ValueError(f"Preview feature {preview_feature_id} not found") + + if enabled: + labs_data.data.preview_feature_status.add((domain, preview_feature)) + else: + labs_data.data.preview_feature_status.discard((domain, preview_feature)) + + await labs_data.store.async_save(labs_data.data.to_store_format()) + + event_data: EventLabsUpdatedData = { + "domain": domain, + "preview_feature": preview_feature, + "enabled": enabled, + } + hass.bus.async_fire(EVENT_LABS_UPDATED, event_data) diff --git a/homeassistant/components/labs/websocket_api.py b/homeassistant/components/labs/websocket_api.py index bccfe0c53de33..f1f744f8a47c7 100644 --- a/homeassistant/components/labs/websocket_api.py +++ b/homeassistant/components/labs/websocket_api.py @@ -8,12 +8,14 @@ from homeassistant.components import websocket_api from homeassistant.components.backup import async_get_manager -from homeassistant.const import EVENT_LABS_UPDATED from homeassistant.core import HomeAssistant, callback from .const import LABS_DATA -from .helpers import async_is_preview_feature_enabled, async_listen -from .models import EventLabsUpdatedData +from .helpers import ( + async_is_preview_feature_enabled, + async_listen, + async_update_preview_feature, +) @callback @@ -95,19 +97,7 @@ async def websocket_update_preview_feature( ) return - if enabled: - labs_data.data.preview_feature_status.add((domain, preview_feature)) - else: - labs_data.data.preview_feature_status.discard((domain, preview_feature)) - - await labs_data.store.async_save(labs_data.data.to_store_format()) - - event_data: EventLabsUpdatedData = { - "domain": domain, - "preview_feature": preview_feature, - "enabled": enabled, - } - hass.bus.async_fire(EVENT_LABS_UPDATED, event_data) + await async_update_preview_feature(hass, domain, preview_feature, enabled) connection.send_result(msg["id"]) diff --git a/tests/components/labs/test_init.py b/tests/components/labs/test_init.py index 467201f92404d..cc040d113212a 100644 --- a/tests/components/labs/test_init.py +++ b/tests/components/labs/test_init.py @@ -11,6 +11,7 @@ EVENT_LABS_UPDATED, async_is_preview_feature_enabled, async_listen, + async_update_preview_feature, ) from homeassistant.components.labs.const import DOMAIN, LABS_DATA from homeassistant.components.labs.models import LabPreviewFeature @@ -20,6 +21,8 @@ from . import assert_stored_labs_data +from tests.common import async_capture_events + async def test_async_setup(hass: HomeAssistant) -> None: """Test the Labs integration setup.""" @@ -436,3 +439,57 @@ def test_listener() -> None: # Verify listener was not called after unsubscribe assert len(listener_calls) == 1 + + +async def test_async_update_preview_feature( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test enabling and disabling a preview feature using the helper function.""" + hass.config.components.add("kitchen_sink") + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + events = async_capture_events(hass, EVENT_LABS_UPDATED) + + await async_update_preview_feature( + hass, "kitchen_sink", "special_repair", enabled=True + ) + await hass.async_block_till_done() + + assert async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + + assert len(events) == 1 + assert events[0].data["domain"] == "kitchen_sink" + assert events[0].data["preview_feature"] == "special_repair" + assert events[0].data["enabled"] is True + + assert_stored_labs_data( + hass_storage, + [{"domain": "kitchen_sink", "preview_feature": "special_repair"}], + ) + + await async_update_preview_feature( + hass, "kitchen_sink", "special_repair", enabled=False + ) + await hass.async_block_till_done() + + assert not async_is_preview_feature_enabled(hass, "kitchen_sink", "special_repair") + + assert len(events) == 2 + assert events[1].data["domain"] == "kitchen_sink" + assert events[1].data["preview_feature"] == "special_repair" + assert events[1].data["enabled"] is False + + assert_stored_labs_data(hass_storage, []) + + +async def test_async_update_preview_feature_not_found(hass: HomeAssistant) -> None: + """Test updating a preview feature that doesn't exist raises.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + with pytest.raises( + ValueError, match="Preview feature nonexistent.feature not found" + ): + await async_update_preview_feature(hass, "nonexistent", "feature", enabled=True)