From beb122bb1adb56ad910c5129faebd076da42e3a6 Mon Sep 17 00:00:00 2001 From: Josh Gustafson Date: Sun, 15 Mar 2026 13:08:05 -0600 Subject: [PATCH] Add binary sensor platform to Arcam FMJ (#165272) Co-authored-by: Claude Opus 4.6 --- .../components/arcam_fmj/__init__.py | 2 +- .../components/arcam_fmj/binary_sensor.py | 68 +++++++++++++ .../components/arcam_fmj/strings.json | 5 + .../snapshots/test_binary_sensor.ambr | 99 +++++++++++++++++++ .../arcam_fmj/test_binary_sensor.py | 88 +++++++++++++++++ 5 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/arcam_fmj/binary_sensor.py create mode 100644 tests/components/arcam_fmj/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/arcam_fmj/test_binary_sensor.py diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index d80e6814425..df088738a64 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -17,7 +17,7 @@ from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRunti _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool: diff --git a/homeassistant/components/arcam_fmj/binary_sensor.py b/homeassistant/components/arcam_fmj/binary_sensor.py new file mode 100644 index 00000000000..0addfdb4aa2 --- /dev/null +++ b/homeassistant/components/arcam_fmj/binary_sensor.py @@ -0,0 +1,68 @@ +"""Arcam binary sensors for incoming stream info.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from arcam.fmj.state import State + +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 ArcamFmjConfigEntry +from .entity import ArcamFmjEntity + + +@dataclass(frozen=True, kw_only=True) +class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an Arcam FMJ binary sensor entity.""" + + value_fn: Callable[[State], bool | None] + + +BINARY_SENSORS: tuple[ArcamFmjBinarySensorEntityDescription, ...] = ( + ArcamFmjBinarySensorEntityDescription( + key="incoming_video_interlaced", + translation_key="incoming_video_interlaced", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: ( + vp.interlaced + if (vp := state.get_incoming_video_parameters()) is not None + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ArcamFmjConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Arcam FMJ binary sensors from a config entry.""" + coordinators = config_entry.runtime_data.coordinators + + entities: list[ArcamFmjBinarySensorEntity] = [] + for coordinator in coordinators.values(): + entities.extend( + ArcamFmjBinarySensorEntity(coordinator, description) + for description in BINARY_SENSORS + ) + async_add_entities(entities) + + +class ArcamFmjBinarySensorEntity(ArcamFmjEntity, BinarySensorEntity): + """Representation of an Arcam FMJ binary sensor.""" + + entity_description: ArcamFmjBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return the binary sensor value.""" + return self.entity_description.value_fn(self.coordinator.state) diff --git a/homeassistant/components/arcam_fmj/strings.json b/homeassistant/components/arcam_fmj/strings.json index 96dd86efb3f..cad3708efa7 100644 --- a/homeassistant/components/arcam_fmj/strings.json +++ b/homeassistant/components/arcam_fmj/strings.json @@ -25,6 +25,11 @@ } }, "entity": { + "binary_sensor": { + "incoming_video_interlaced": { + "name": "Incoming video interlaced" + } + }, "sensor": { "incoming_audio_config": { "name": "Incoming audio configuration", diff --git a/tests/components/arcam_fmj/snapshots/test_binary_sensor.ambr b/tests/components/arcam_fmj/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2c0588fb90f --- /dev/null +++ b/tests/components/arcam_fmj/snapshots/test_binary_sensor.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_setup[binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced-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.arcam_fmj_127_0_0_1_incoming_video_interlaced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Incoming video interlaced', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Incoming video interlaced', + 'platform': 'arcam_fmj', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'incoming_video_interlaced', + 'unique_id': '456789abcdef-1-incoming_video_interlaced', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arcam FMJ (127.0.0.1) Incoming video interlaced', + }), + 'context': , + 'entity_id': 'binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[binary_sensor.arcam_fmj_127_0_0_1_zone_2_incoming_video_interlaced-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.arcam_fmj_127_0_0_1_zone_2_incoming_video_interlaced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Incoming video interlaced', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Incoming video interlaced', + 'platform': 'arcam_fmj', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'incoming_video_interlaced', + 'unique_id': '456789abcdef-2-incoming_video_interlaced', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.arcam_fmj_127_0_0_1_zone_2_incoming_video_interlaced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arcam FMJ (127.0.0.1) Zone 2 Incoming video interlaced', + }), + 'context': , + 'entity_id': 'binary_sensor.arcam_fmj_127_0_0_1_zone_2_incoming_video_interlaced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/arcam_fmj/test_binary_sensor.py b/tests/components/arcam_fmj/test_binary_sensor.py new file mode 100644 index 00000000000..67b506c585a --- /dev/null +++ b/tests/components/arcam_fmj/test_binary_sensor.py @@ -0,0 +1,88 @@ +"""Tests for Arcam FMJ binary sensor entities.""" + +from collections.abc import Generator +from unittest.mock import Mock, patch + +from arcam.fmj.state import State +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def binary_sensor_only() -> Generator[None]: + """Limit platform setup to binary_sensor only.""" + with patch( + "homeassistant.components.arcam_fmj.PLATFORMS", [Platform.BINARY_SENSOR] + ): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "player_setup") +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test snapshot of the binary sensor platform.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("player_setup") +async def test_binary_sensor_none( + hass: HomeAssistant, +) -> None: + """Test binary sensor when video parameters are None.""" + state = hass.states.get( + "binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced" + ) + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("player_setup") +async def test_binary_sensor_interlaced( + hass: HomeAssistant, + state_1: State, + client: Mock, +) -> None: + """Test binary sensor reports on when video is interlaced.""" + video_params = Mock() + video_params.interlaced = True + state_1.get_incoming_video_parameters.return_value = video_params + + client.notify_data_updated() + await hass.async_block_till_done() + + state = hass.states.get( + "binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced" + ) + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.usefixtures("player_setup") +async def test_binary_sensor_not_interlaced( + hass: HomeAssistant, + state_1: State, + client: Mock, +) -> None: + """Test binary sensor reports off when video is not interlaced.""" + video_params = Mock() + video_params.interlaced = False + state_1.get_incoming_video_parameters.return_value = video_params + + client.notify_data_updated() + await hass.async_block_till_done() + + state = hass.states.get( + "binary_sensor.arcam_fmj_127_0_0_1_incoming_video_interlaced" + ) + assert state is not None + assert state.state == STATE_OFF