1
0
mirror of https://github.com/home-assistant/core.git synced 2026-03-02 07:29:28 +00:00

Add volume control to Foscam Upgrade dependencies (#150618)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Foscam-wangzhengyu
2025-09-11 17:59:05 +08:00
committed by GitHub
parent 2fc2bb97fc
commit e65b4292b2
10 changed files with 311 additions and 6 deletions

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -39,6 +39,14 @@
"wdr_switch": {
"default": "mdi:alpha-w-box"
}
},
"number": {
"device_volume": {
"default": "mdi:volume-source"
},
"speak_volume": {
"default": "mdi:account-voice"
}
}
}
}

View File

@@ -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()

View File

@@ -62,6 +62,14 @@
"wdr_switch": {
"name": "WDR"
}
},
"number": {
"device_volume": {
"name": "Device volume"
},
"speak_volume": {
"name": "Speak volume"
}
}
},
"services": {

View File

@@ -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__(

View File

@@ -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

View File

@@ -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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.mock_title_device_volume',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.mock_title_speak_volume',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '100',
})
# ---

View File

@@ -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)