diff --git a/homeassistant/components/fressnapf_tracker/__init__.py b/homeassistant/components/fressnapf_tracker/__init__.py index 6794aa92fe3..be6f256466c 100644 --- a/homeassistant/components/fressnapf_tracker/__init__.py +++ b/homeassistant/components/fressnapf_tracker/__init__.py @@ -15,6 +15,7 @@ from .coordinator import ( PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, + Platform.LIGHT, Platform.SENSOR, ] diff --git a/homeassistant/components/fressnapf_tracker/light.py b/homeassistant/components/fressnapf_tracker/light.py new file mode 100644 index 00000000000..a6321852046 --- /dev/null +++ b/homeassistant/components/fressnapf_tracker/light.py @@ -0,0 +1,93 @@ +"""Light platform for fressnapf_tracker.""" + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + LightEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FressnapfTrackerConfigEntry +from .const import DOMAIN +from .entity import FressnapfTrackerEntity + +LIGHT_ENTITY_DESCRIPTION = LightEntityDescription( + translation_key="led", + entity_category=EntityCategory.CONFIG, + key="led_brightness_value", +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FressnapfTrackerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Fressnapf Tracker lights.""" + + async_add_entities( + FressnapfTrackerLight(coordinator, LIGHT_ENTITY_DESCRIPTION) + for coordinator in entry.runtime_data + if coordinator.data.led_activatable is not None + and coordinator.data.led_activatable.has_led + and coordinator.data.tracker_settings.features.flash_light + ) + + +class FressnapfTrackerLight(FressnapfTrackerEntity, LightEntity): + """Fressnapf Tracker light.""" + + _attr_color_mode: ColorMode = ColorMode.BRIGHTNESS + _attr_supported_color_modes: set[ColorMode] = {ColorMode.BRIGHTNESS} + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + if TYPE_CHECKING: + # The entity is not created if led_brightness_value is None + assert self.coordinator.data.led_brightness_value is not None + return int(round((self.coordinator.data.led_brightness_value / 100) * 255)) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + self.raise_if_not_activatable() + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + brightness = int((brightness / 255) * 100) + await self.coordinator.client.set_led_brightness(brightness) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + await self.coordinator.client.set_led_brightness(0) + await self.coordinator.async_request_refresh() + + def raise_if_not_activatable(self) -> None: + """Raise error with reasoning if light is not activatable.""" + if TYPE_CHECKING: + # The entity is not created if led_activatable is None + assert self.coordinator.data.led_activatable is not None + error_type: str | None = None + if not self.coordinator.data.led_activatable.seen_recently: + error_type = "not_seen_recently" + elif not self.coordinator.data.led_activatable.not_charging: + error_type = "charging" + elif not self.coordinator.data.led_activatable.nonempty_battery: + error_type = "low_battery" + if error_type is not None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=error_type, + ) + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + if self.coordinator.data.led_brightness_value is not None: + return self.coordinator.data.led_brightness_value > 0 + return False diff --git a/homeassistant/components/fressnapf_tracker/strings.json b/homeassistant/components/fressnapf_tracker/strings.json index 666656af114..0c06b436b3c 100644 --- a/homeassistant/components/fressnapf_tracker/strings.json +++ b/homeassistant/components/fressnapf_tracker/strings.json @@ -45,5 +45,23 @@ } } } + }, + "entity": { + "light": { + "led": { + "name": "Flashlight" + } + } + }, + "exceptions": { + "charging": { + "message": "The flashlight cannot be activated while charging." + }, + "low_battery": { + "message": "The flashlight cannot be activated due to low battery." + }, + "not_seen_recently": { + "message": "The flashlight cannot be activated when the tracker has not moved recently." + } } } diff --git a/tests/components/fressnapf_tracker/conftest.py b/tests/components/fressnapf_tracker/conftest.py index f7b58bfb05a..97e7ecfecf7 100644 --- a/tests/components/fressnapf_tracker/conftest.py +++ b/tests/components/fressnapf_tracker/conftest.py @@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch from fressnapftracker import ( Device, + LedActivatable, + LedBrightness, PhoneVerificationResponse, Position, SmsCodeResponse, @@ -30,6 +32,30 @@ MOCK_USER_ID = 12345 MOCK_ACCESS_TOKEN = "mock_access_token" MOCK_SERIAL_NUMBER = "ABC123456" MOCK_DEVICE_TOKEN = "mock_device_token" +MOCK_TRACKER = Tracker( + name="Fluffy", + battery=85, + charging=False, + position=Position( + lat=52.520008, + lng=13.404954, + accuracy=10, + timestamp="2024-01-15T12:00:00Z", + ), + tracker_settings=TrackerSettings( + generation="GPS Tracker 2.0", + features=TrackerFeatures(flash_light=True, live_tracking=True), + ), + led_brightness=LedBrightness(value=50), + deep_sleep=None, + led_activatable=LedActivatable( + has_led=True, + seen_recently=True, + nonempty_battery=True, + not_charging=True, + overall=True, + ), +) @pytest.fixture @@ -42,29 +68,6 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -@pytest.fixture -def mock_tracker() -> Tracker: - """Create a mock Tracker object.""" - return Tracker( - name="Fluffy", - battery=85, - charging=False, - position=Position( - lat=52.520008, - lng=13.404954, - accuracy=10, - timestamp="2024-01-15T12:00:00Z", - ), - tracker_settings=TrackerSettings( - generation="GPS Tracker 2.0", - features=TrackerFeatures(live_tracking=True), - ), - led_brightness=None, - deep_sleep=None, - led_activatable=None, - ) - - @pytest.fixture def mock_tracker_no_position() -> Tracker: """Create a mock Tracker object without position.""" @@ -122,13 +125,14 @@ def mock_auth_client(mock_device: Device) -> Generator[MagicMock]: @pytest.fixture -def mock_api_client(mock_tracker: Tracker) -> Generator[MagicMock]: +def mock_api_client() -> Generator[MagicMock]: """Mock the ApiClient.""" with patch( "homeassistant.components.fressnapf_tracker.coordinator.ApiClient" ) as mock_api_client: client = mock_api_client.return_value - client.get_tracker = AsyncMock(return_value=mock_tracker) + client.get_tracker = AsyncMock(return_value=MOCK_TRACKER) + client.set_led_brightness = AsyncMock(return_value=None) yield client diff --git a/tests/components/fressnapf_tracker/snapshots/test_light.ambr b/tests/components/fressnapf_tracker/snapshots/test_light.ambr new file mode 100644 index 00000000000..81f76a0fec9 --- /dev/null +++ b/tests/components/fressnapf_tracker/snapshots/test_light.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_state_entity_device_snapshots[light.fluffy_flashlight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.fluffy_flashlight', + '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': 'Flashlight', + 'platform': 'fressnapf_tracker', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led', + 'unique_id': 'ABC123456_led_brightness_value', + 'unit_of_measurement': None, + }) +# --- +# name: test_state_entity_device_snapshots[light.fluffy_flashlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 128, + 'color_mode': , + 'friendly_name': 'Fluffy Flashlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fluffy_flashlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fressnapf_tracker/test_light.py b/tests/components/fressnapf_tracker/test_light.py new file mode 100644 index 00000000000..e124429e367 --- /dev/null +++ b/tests/components/fressnapf_tracker/test_light.py @@ -0,0 +1,174 @@ +"""Test the Fressnapf Tracker light platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock, patch + +from fressnapftracker import Tracker, TrackerFeatures, TrackerSettings +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +TRACKER_NO_LED = Tracker( + name="Fluffy", + battery=0, + charging=False, + position=None, + tracker_settings=TrackerSettings( + generation="GPS Tracker 2.0", + features=TrackerFeatures(flash_light=False, live_tracking=True), + ), +) + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch( + "homeassistant.components.fressnapf_tracker.PLATFORMS", [Platform.LIGHT] + ): + yield + + +@pytest.mark.usefixtures("init_integration") +async def test_state_entity_device_snapshots( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test light entity is created correctly.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("mock_auth_client") +async def test_not_added_when_no_led( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_api_client: MagicMock, +) -> None: + """Test light entity is created correctly.""" + mock_api_client.get_tracker.return_value = TRACKER_NO_LED + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entity_entries) == 0 + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_on( + hass: HomeAssistant, + mock_api_client: MagicMock, +) -> None: + """Test turning the light on.""" + entity_id = "light.fluffy_flashlight" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_api_client.set_led_brightness.assert_called_once_with(100) + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_on_with_brightness( + hass: HomeAssistant, + mock_api_client: MagicMock, +) -> None: + """Test turning the light on with brightness.""" + entity_id = "light.fluffy_flashlight" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + # 128/255 * 100 = 50 + mock_api_client.set_led_brightness.assert_called_once_with(50) + + +@pytest.mark.usefixtures("init_integration") +async def test_turn_off( + hass: HomeAssistant, + mock_api_client: MagicMock, +) -> None: + """Test turning the light off.""" + entity_id = "light.fluffy_flashlight" + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_api_client.set_led_brightness.assert_called_once_with(0) + + +@pytest.mark.parametrize( + "activatable_parameter", + [ + "seen_recently", + "nonempty_battery", + "not_charging", + ], +) +@pytest.mark.usefixtures("mock_auth_client") +async def test_turn_on_led_not_activatable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: MagicMock, + activatable_parameter: str, +) -> None: + """Test turning on the light when LED is not activatable raises.""" + setattr( + mock_api_client.get_tracker.return_value.led_activatable, + activatable_parameter, + False, + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.fluffy_flashlight" + + with pytest.raises(HomeAssistantError, match="The flashlight cannot be activated"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_api_client.set_led_brightness.assert_not_called()