diff --git a/homeassistant/components/nintendo_parental_controls/sensor.py b/homeassistant/components/nintendo_parental_controls/sensor.py index ca9bd803da3ccb..3b18c2a4f91c74 100644 --- a/homeassistant/components/nintendo_parental_controls/sensor.py +++ b/homeassistant/components/nintendo_parental_controls/sensor.py @@ -5,6 +5,8 @@ from datetime import datetime from enum import StrEnum +from pynintendoparental.player import Player + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -26,20 +28,30 @@ class NintendoParentalControlsSensor(StrEnum): """Store keys for Nintendo parental controls sensors.""" PLAYING_TIME = "playing_time" + PLAYER_PLAYING_TIME = "player_playing_time" TIME_REMAINING = "time_remaining" TIME_EXTENDED = "time_extended" @dataclass(kw_only=True, frozen=True) -class NintendoParentalControlsSensorEntityDescription(SensorEntityDescription): - """Description for Nintendo parental controls sensor entities.""" +class NintendoParentalControlsDeviceSensorEntityDescription(SensorEntityDescription): + """Description for Nintendo parental controls device sensor entities.""" value_fn: Callable[[Device], datetime | int | float | None] available_fn: Callable[[Device], bool] = lambda device: True -SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...] = ( - NintendoParentalControlsSensorEntityDescription( +@dataclass(kw_only=True, frozen=True) +class NintendoParentalControlsPlayerSensorEntityDescription(SensorEntityDescription): + """Description for Nintendo parental controls player sensor entities.""" + + value_fn: Callable[[Player], int | float | None] + + +DEVICE_SENSOR_DESCRIPTIONS: tuple[ + NintendoParentalControlsDeviceSensorEntityDescription, ... +] = ( + NintendoParentalControlsDeviceSensorEntityDescription( key=NintendoParentalControlsSensor.PLAYING_TIME, translation_key=NintendoParentalControlsSensor.PLAYING_TIME, native_unit_of_measurement=UnitOfTime.MINUTES, @@ -47,7 +59,7 @@ class NintendoParentalControlsSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda device: device.today_playing_time, ), - NintendoParentalControlsSensorEntityDescription( + NintendoParentalControlsDeviceSensorEntityDescription( key=NintendoParentalControlsSensor.TIME_REMAINING, translation_key=NintendoParentalControlsSensor.TIME_REMAINING, native_unit_of_measurement=UnitOfTime.MINUTES, @@ -55,7 +67,7 @@ class NintendoParentalControlsSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, value_fn=lambda device: device.today_time_remaining, ), - NintendoParentalControlsSensorEntityDescription( + NintendoParentalControlsDeviceSensorEntityDescription( key=NintendoParentalControlsSensor.TIME_EXTENDED, translation_key=NintendoParentalControlsSensor.TIME_EXTENDED, native_unit_of_measurement=UnitOfTime.MINUTES, @@ -66,30 +78,53 @@ class NintendoParentalControlsSensorEntityDescription(SensorEntityDescription): ), ) +PLAYER_SENSOR_DESCRIPTIONS: tuple[ + NintendoParentalControlsPlayerSensorEntityDescription, ... +] = ( + NintendoParentalControlsPlayerSensorEntityDescription( + key=NintendoParentalControlsSensor.PLAYER_PLAYING_TIME, + translation_key=NintendoParentalControlsSensor.PLAYER_PLAYING_TIME, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda player: player.playing_time, + ), +) + async def async_setup_entry( hass: HomeAssistant, entry: NintendoParentalControlsConfigEntry, - async_add_devices: AddConfigEntryEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - async_add_devices( - NintendoParentalControlsSensorEntity(entry.runtime_data, device, sensor) + entities: list[NintendoDevice] = [] + entities.extend( + NintendoParentalControlsDeviceSensorEntity(entry.runtime_data, device, sensor) for device in entry.runtime_data.api.devices.values() - for sensor in SENSOR_DESCRIPTIONS + for sensor in DEVICE_SENSOR_DESCRIPTIONS ) - - -class NintendoParentalControlsSensorEntity(NintendoDevice, SensorEntity): + for device in entry.runtime_data.api.devices.values(): + entities.extend( + NintendoParentalControlsPlayerSensorEntity( + entry.runtime_data, device, player_id, sensor + ) + for player_id in device.players + for sensor in PLAYER_SENSOR_DESCRIPTIONS + ) + async_add_entities(entities) + + +class NintendoParentalControlsDeviceSensorEntity(NintendoDevice, SensorEntity): """Represent a single sensor.""" - entity_description: NintendoParentalControlsSensorEntityDescription + entity_description: NintendoParentalControlsDeviceSensorEntityDescription def __init__( self, coordinator: NintendoUpdateCoordinator, device: Device, - description: NintendoParentalControlsSensorEntityDescription, + description: NintendoParentalControlsDeviceSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator, device=device, key=description.key) @@ -104,3 +139,39 @@ def native_value(self) -> datetime | int | float | None: def available(self) -> bool: """Return if the sensor is available.""" return super().available and self.entity_description.available_fn(self._device) + + +class NintendoParentalControlsPlayerSensorEntity(NintendoDevice, SensorEntity): + """Represent a single player sensor.""" + + entity_description: NintendoParentalControlsPlayerSensorEntityDescription + + def __init__( + self, + coordinator: NintendoUpdateCoordinator, + device: Device, + player_id: str, + description: NintendoParentalControlsPlayerSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator, device=device, key=description.key) + self.entity_description = description + self.player_id = player_id + player_obj = device.get_player(player_id) + nickname = player_obj.nickname or "" + self._attr_translation_placeholders = {"nickname": nickname} + self._attr_unique_id = f"{device.device_id}_{player_id}_{description.key}" + + @property + def entity_picture(self) -> str | None: + """Return the entity picture.""" + if self.player_id not in self._device.players: + return None + return self._device.get_player(self.player_id).player_image + + @property + def native_value(self) -> int | float | None: + """Return the native value.""" + if self.player_id not in self._device.players: + return None + return self.entity_description.value_fn(self._device.get_player(self.player_id)) diff --git a/homeassistant/components/nintendo_parental_controls/strings.json b/homeassistant/components/nintendo_parental_controls/strings.json index cde905574a27d3..86e3fb1a9a6a98 100644 --- a/homeassistant/components/nintendo_parental_controls/strings.json +++ b/homeassistant/components/nintendo_parental_controls/strings.json @@ -47,6 +47,9 @@ } }, "sensor": { + "player_playing_time": { + "name": "{nickname} used screen time" + }, "playing_time": { "name": "Used screen time" }, diff --git a/tests/components/nintendo_parental_controls/conftest.py b/tests/components/nintendo_parental_controls/conftest.py index 97ee7ccb2c1d2c..e124e72374356b 100644 --- a/tests/components/nintendo_parental_controls/conftest.py +++ b/tests/components/nintendo_parental_controls/conftest.py @@ -7,6 +7,7 @@ from pynintendoparental import NintendoParental from pynintendoparental.device import Device from pynintendoparental.enum import DeviceTimerMode +from pynintendoparental.player import Player import pytest from homeassistant.components.nintendo_parental_controls.const import DOMAIN @@ -27,7 +28,29 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_nintendo_device() -> Device: +def mock_nintendo_player() -> Player: + """Return a mocked player.""" + # This class has no async methods + mock = MagicMock(spec=Player) + mock.player_id = "testplayerid" + mock.nickname = "HA Gamer" + mock.apps = [ + { + "playingTime": 15, + "meta": { + "title": "Test Game Name", + "imageUri": {"medium": "http://localhost/medium.png"}, + "shopUri": "http://localhost/shop-test-game-name", + }, + } + ] + mock.playing_time = 110 + mock.player_image = "http://localhost/image.png" + return mock + + +@pytest.fixture +def mock_nintendo_device(mock_nintendo_player: Player) -> Device: """Return a mocked device.""" mock = AsyncMock(spec=Device) mock.device_id = "testdevid" @@ -51,6 +74,9 @@ def mock_nintendo_device() -> Device: mock.forced_termination_mode = True mock.model = "Test Model" mock.generation = "P00" + mock.players = {mock_nintendo_player.player_id: mock_nintendo_player} + mock.get_player = MagicMock() + mock.get_player.return_value = mock_nintendo_player return mock diff --git a/tests/components/nintendo_parental_controls/snapshots/test_sensor.ambr b/tests/components/nintendo_parental_controls/snapshots/test_sensor.ambr index 49f4c262abfc36..4a3a69dbfb8692 100644 --- a/tests/components/nintendo_parental_controls/snapshots/test_sensor.ambr +++ b/tests/components/nintendo_parental_controls/snapshots/test_sensor.ambr @@ -57,6 +57,65 @@ 'state': '30', }) # --- +# name: test_sensor[sensor.home_assistant_test_ha_gamer_used_screen_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_assistant_test_ha_gamer_used_screen_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'HA Gamer used screen time', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HA Gamer used screen time', + 'platform': 'nintendo_parental_controls', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'testdevid_testplayerid_player_playing_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_assistant_test_ha_gamer_used_screen_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'entity_picture': 'http://localhost/image.png', + 'friendly_name': 'Home Assistant Test HA Gamer used screen time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_assistant_test_ha_gamer_used_screen_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '110', + }) +# --- # name: test_sensor[sensor.home_assistant_test_screen_time_remaining-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/nintendo_parental_controls/test_sensor.py b/tests/components/nintendo_parental_controls/test_sensor.py index 0d9584888289ce..7e4303261c69ac 100644 --- a/tests/components/nintendo_parental_controls/test_sensor.py +++ b/tests/components/nintendo_parental_controls/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -10,7 +11,7 @@ from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_sensor( @@ -28,3 +29,33 @@ async def test_sensor( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_player_sensor_none_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test player sensor returns None when player is not in device players.""" + with patch( + "homeassistant.components.nintendo_parental_controls._PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + entity_id = "sensor.home_assistant_test_ha_gamer_used_screen_time" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "110" + assert state.attributes["entity_picture"] == "http://localhost/image.png" + + mock_nintendo_client.devices["testdevid"].players = {} + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "unknown" + assert "entity_picture" not in state.attributes