diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 099123ccd9b..e9ad1e78cfc 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -16,7 +16,7 @@ from .config_flow import DEFAULT_RTSP_PORT from .const import CONF_RTSP_PORT, LOGGER from .coordinator import FoscamConfigEntry, FoscamCoordinator -PLATFORMS = [Platform.CAMERA, Platform.SWITCH] +PLATFORMS = [Platform.CAMERA, Platform.NUMBER, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool: @@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bo entry.data[CONF_PASSWORD], verbose=False, ) + coordinator = FoscamCoordinator(hass, entry, session) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index 50ddd76ddb3..80b6ec96e83 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -30,10 +30,11 @@ class FoscamDeviceInfo: is_open_white_light: bool is_siren_alarm: bool - volume: int + device_volume: int speak_volume: int is_turn_off_volume: bool is_turn_off_light: bool + supports_speak_volume_adjustment: bool is_open_wdr: bool | None = None is_open_hdr: bool | None = None @@ -118,6 +119,14 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]): mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 is_open_hdr = bool(int(mode)) + ret_sw, software_capabilities = self.session.getSWCapabilities() + + supports_speak_volume_adjustment_val = ( + bool(int(software_capabilities.get("swCapabilities1")) & 32) + if ret_sw == 0 + else False + ) + return FoscamDeviceInfo( dev_info=dev_info, product_info=product_info, @@ -127,10 +136,11 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]): is_asleep=is_asleep, is_open_white_light=is_open_white_light_val, is_siren_alarm=is_siren_alarm_val, - volume=volume_val, + device_volume=volume_val, speak_volume=speak_volume_val, is_turn_off_volume=is_turn_off_volume_val, is_turn_off_light=is_turn_off_light_val, + supports_speak_volume_adjustment=supports_speak_volume_adjustment_val, is_open_wdr=is_open_wdr, is_open_hdr=is_open_hdr, ) diff --git a/homeassistant/components/foscam/entity.py b/homeassistant/components/foscam/entity.py index 7bc983cbfaa..e9930695a75 100644 --- a/homeassistant/components/foscam/entity.py +++ b/homeassistant/components/foscam/entity.py @@ -13,6 +13,8 @@ from .coordinator import FoscamCoordinator class FoscamEntity(CoordinatorEntity[FoscamCoordinator]): """Base entity for Foscam camera.""" + _attr_has_entity_name = True + def __init__(self, coordinator: FoscamCoordinator, config_entry_id: str) -> None: """Initialize the base Foscam entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/foscam/icons.json b/homeassistant/components/foscam/icons.json index 4b0b0c17c32..7dbd874b2f6 100644 --- a/homeassistant/components/foscam/icons.json +++ b/homeassistant/components/foscam/icons.json @@ -39,6 +39,14 @@ "wdr_switch": { "default": "mdi:alpha-w-box" } + }, + "number": { + "device_volume": { + "default": "mdi:volume-source" + }, + "speak_volume": { + "default": "mdi:account-voice" + } } } } diff --git a/homeassistant/components/foscam/number.py b/homeassistant/components/foscam/number.py new file mode 100644 index 00000000000..e828955870d --- /dev/null +++ b/homeassistant/components/foscam/number.py @@ -0,0 +1,93 @@ +"""Foscam number platform for Home Assistant.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from libpyfoscamcgi import FoscamCamera + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FoscamConfigEntry, FoscamCoordinator +from .entity import FoscamEntity + + +@dataclass(frozen=True, kw_only=True) +class FoscamNumberEntityDescription(NumberEntityDescription): + """A custom entity description with adjustable features.""" + + native_value_fn: Callable[[FoscamCoordinator], int] + set_value_fn: Callable[[FoscamCamera, float], Any] + exists_fn: Callable[[FoscamCoordinator], bool] + + +NUMBER_DESCRIPTIONS: list[FoscamNumberEntityDescription] = [ + FoscamNumberEntityDescription( + key="device_volume", + translation_key="device_volume", + native_min_value=0, + native_max_value=100, + native_step=1, + native_value_fn=lambda coordinator: coordinator.data.device_volume, + set_value_fn=lambda session, value: session.setAudioVolume(value), + exists_fn=lambda _: True, + ), + FoscamNumberEntityDescription( + key="speak_volume", + translation_key="speak_volume", + native_min_value=0, + native_max_value=100, + native_step=1, + native_value_fn=lambda coordinator: coordinator.data.speak_volume, + set_value_fn=lambda session, value: session.setSpeakVolume(value), + exists_fn=lambda coordinator: coordinator.data.supports_speak_volume_adjustment, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FoscamConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up foscam number from a config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + FoscamVolumeNumberEntity(coordinator, description) + for description in NUMBER_DESCRIPTIONS + if description.exists_fn is None or description.exists_fn(coordinator) + ) + + +class FoscamVolumeNumberEntity(FoscamEntity, NumberEntity): + """Representation of a Foscam Smart AI number entity.""" + + entity_description: FoscamNumberEntityDescription + + def __init__( + self, + coordinator: FoscamCoordinator, + description: FoscamNumberEntityDescription, + ) -> None: + """Initialize the data.""" + entry_id = coordinator.config_entry.entry_id + super().__init__(coordinator, entry_id) + + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.entity_description.native_value_fn(self.coordinator) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.hass.async_add_executor_job( + self.entity_description.set_value_fn, self.coordinator.session, value + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index d73833b1cae..86a5ba59c0a 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -62,6 +62,14 @@ "wdr_switch": { "name": "WDR" } + }, + "number": { + "device_volume": { + "name": "Device volume" + }, + "speak_volume": { + "name": "Speak volume" + } } }, "services": { diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index 91118a27277..8407da8edd3 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -121,7 +121,6 @@ async def async_setup_entry( """Set up foscam switch from a config entry.""" coordinator = config_entry.runtime_data - await coordinator.async_config_entry_first_refresh() entities = [] @@ -146,7 +145,6 @@ async def async_setup_entry( class FoscamGenericSwitch(FoscamEntity, SwitchEntity): """A generic switch class for Foscam entities.""" - _attr_has_entity_name = True entity_description: FoscamSwitchEntityDescription def __init__( diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py index 43616693303..a7a5b1abe48 100644 --- a/tests/components/foscam/conftest.py +++ b/tests/components/foscam/conftest.py @@ -75,7 +75,15 @@ def setup_mock_foscam_camera(mock_foscam_camera): mock_foscam_camera.getWdrMode.return_value = (0, {"mode": "0"}) mock_foscam_camera.getHdrMode.return_value = (0, {"mode": "0"}) mock_foscam_camera.get_motion_detect_config.return_value = (0, 1) - + mock_foscam_camera.getSWCapabilities.return_value = ( + 0, + { + "swCapabilities1": "100", + "swCapbilities2": "100", + "swCapbilities3": "100", + "swCapbilities4": "100", + }, + ) return mock_foscam_camera mock_foscam_camera.side_effect = configure_mock_on_init diff --git a/tests/components/foscam/snapshots/test_number.ambr b/tests/components/foscam/snapshots/test_number.ambr new file mode 100644 index 00000000000..74294c7306a --- /dev/null +++ b/tests/components/foscam/snapshots/test_number.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_number_entities[number.mock_title_device_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_title_device_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Device volume', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_volume', + 'unique_id': '123ABC_device_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[number.mock_title_device_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Device volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_title_device_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_entities[number.mock_title_speak_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_title_speak_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Speak volume', + 'platform': 'foscam', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'speak_volume', + 'unique_id': '123ABC_speak_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[number.mock_title_speak_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Speak volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.mock_title_speak_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/foscam/test_number.py b/tests/components/foscam/test_number.py new file mode 100644 index 00000000000..94088c94895 --- /dev/null +++ b/tests/components/foscam/test_number.py @@ -0,0 +1,62 @@ +"""Test the Foscam number platform.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.foscam.const import DOMAIN +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_mock_foscam_camera +from .const import ENTRY_ID, VALID_CONFIG + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_number_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test creation of number entities.""" + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) + entry.add_to_hass(hass) + + with ( + # Mock a valid camera instance + patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera, + patch("homeassistant.components.foscam.PLATFORMS", [Platform.NUMBER]), + ): + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_setting_number(hass: HomeAssistant) -> None: + """Test setting a number entity calls the correct method on the camera.""" + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) + entry.add_to_hass(hass) + + with patch("homeassistant.components.foscam.FoscamCamera") as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.mock_title_device_volume", + ATTR_VALUE: 42, + }, + blocking=True, + ) + mock_foscam_camera.setAudioVolume.assert_called_once_with(42)