From 1bdf472ac8cd61ce8f1cc8aa3c15f4276865c2bd Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Mon, 31 Aug 2026 18:59:35 +0100 Subject: [PATCH] Add player reports for Nintendo Parental Controls (#180937) --- .../nintendo_parental_controls/icons.json | 6 + .../nintendo_parental_controls/services.py | 92 +++++++++++- .../nintendo_parental_controls/services.yaml | 23 +++ .../nintendo_parental_controls/strings.json | 32 +++- .../nintendo_parental_controls/conftest.py | 4 +- .../nintendo_parental_controls/const.py | 2 + .../snapshots/test_services.ambr | 25 ++++ .../test_services.py | 138 +++++++++++++++++- 8 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 tests/components/nintendo_parental_controls/snapshots/test_services.ambr diff --git a/homeassistant/components/nintendo_parental_controls/icons.json b/homeassistant/components/nintendo_parental_controls/icons.json index b1b4a03b0656..b8f96be3ff63 100644 --- a/homeassistant/components/nintendo_parental_controls/icons.json +++ b/homeassistant/components/nintendo_parental_controls/icons.json @@ -3,6 +3,12 @@ "add_bonus_time": { "service": "mdi:timer-plus-outline" }, + "device_usage_report": { + "service": "mdi:chart-box-outline" + }, + "player_usage_report": { + "service": "mdi:account-clock-outline" + }, "update_pin_code": { "service": "mdi:lock-open-outline" } diff --git a/homeassistant/components/nintendo_parental_controls/services.py b/homeassistant/components/nintendo_parental_controls/services.py index f1b1ab7bbe10..5a7d21bcd6c7 100644 --- a/homeassistant/components/nintendo_parental_controls/services.py +++ b/homeassistant/components/nintendo_parental_controls/services.py @@ -4,15 +4,23 @@ from enum import StrEnum import logging from pynintendoparental.device import Device +from pynintendoparental.enum import SafeLaunchSetting +from pynintendoparental.player import Player import voluptuous as vol -from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PIN +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import config_validation as cv, service +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + service, +) +from homeassistant.util.json import JsonValueType from .const import ATTR_BONUS_TIME, DOMAIN from .coordinator import NintendoParentalControlsConfigEntry +from .sensor import NintendoParentalControlsSensor _LOGGER = logging.getLogger(__name__) @@ -22,6 +30,8 @@ class NintendoParentalServices(StrEnum): ADD_BONUS_TIME = "add_bonus_time" UPDATE_PIN_CODE = "update_pin_code" + PLAYER_USAGE_REPORT = "player_usage_report" + DEVICE_USAGE_REPORT = "device_usage_report" @callback @@ -40,6 +50,29 @@ def async_setup_services( } ), ) + hass.services.async_register( + domain=DOMAIN, + service=NintendoParentalServices.PLAYER_USAGE_REPORT, + service_func=async_get_player_usage, + supports_response=SupportsResponse.ONLY, + schema=vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Required(ATTR_ENTITY_ID): cv.string, + } + ), + ) + hass.services.async_register( + domain=DOMAIN, + service=NintendoParentalServices.DEVICE_USAGE_REPORT, + service_func=async_get_device_usage_report, + supports_response=SupportsResponse.ONLY, + schema=vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + } + ), + ) service.async_register_admin_service( hass, DOMAIN, @@ -76,6 +109,39 @@ def _get_nintendo_device(hass: HomeAssistant, device_id: str) -> Device: ) +def _get_nintendo_player(hass: HomeAssistant, device: Device, entity_id: str) -> Player: + """Return a given player for a given device.""" + prefix = f"{device.device_id}_" + suffix = f"_{NintendoParentalControlsSensor.PLAYER_PLAYING_TIME}" + registry = er.async_get(hass) + entry = registry.async_get(entity_id) + if entry is None or entry.platform != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_entity" + ) + if entry.unique_id.startswith(prefix) and entry.unique_id.endswith(suffix): + player_id = entry.unique_id[len(prefix) : -len(suffix)] + if player_id in device.players: + return device.players.get_player(player_id) + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_player" + ) + + +def _build_player_app_report(player: Player) -> list[dict[str, JsonValueType]]: + """Produce a player application report.""" + return [ + { + "playing_time": app.playing_time, + "name": app.application.name, + "image": app.application.image_url, + "whitelisted": app.application.safe_launch_setting + == SafeLaunchSetting.ALLOW, + } + for app in player.apps + ] + + async def async_add_bonus_time(call: ServiceCall) -> None: """Add bonus time to a device.""" data = call.data @@ -97,3 +163,23 @@ async def async_update_pin_code(call: ServiceCall) -> None: ) device = _get_nintendo_device(call.hass, device_id) return await device.set_new_pin(new_pin) + + +async def async_get_player_usage(call: ServiceCall) -> dict: + """Get player usage.""" + data = call.data + device_id: str = data[ATTR_DEVICE_ID] + entity_id: str = data[ATTR_ENTITY_ID] + device = _get_nintendo_device(call.hass, device_id) + player = _get_nintendo_player(call.hass, device, entity_id) + return {"apps": _build_player_app_report(player)} + + +async def async_get_device_usage_report(call: ServiceCall) -> dict: + """Return the device usage report.""" + data = call.data + device_id: str = data[ATTR_DEVICE_ID] + device = _get_nintendo_device(call.hass, device_id) + return { + player.nickname: _build_player_app_report(player) for player in device.players + } diff --git a/homeassistant/components/nintendo_parental_controls/services.yaml b/homeassistant/components/nintendo_parental_controls/services.yaml index 96a090bd4f0a..8ec639497d0b 100644 --- a/homeassistant/components/nintendo_parental_controls/services.yaml +++ b/homeassistant/components/nintendo_parental_controls/services.yaml @@ -29,3 +29,26 @@ update_pin_code: selector: device: integration: nintendo_parental_controls +player_usage_report: + fields: + device_id: + required: true + example: 1234567890abcdef1234567890abcdef + selector: + device: + integration: nintendo_parental_controls + entity_id: + required: true + example: sensor.example_player_used_screen_time + selector: + entity: + integration: nintendo_parental_controls + domain: sensor +device_usage_report: + fields: + device_id: + required: true + example: 1234567890abcdef1234567890abcdef + selector: + device: + integration: nintendo_parental_controls diff --git a/homeassistant/components/nintendo_parental_controls/strings.json b/homeassistant/components/nintendo_parental_controls/strings.json index 001cf104e634..d33c6a0a2720 100644 --- a/homeassistant/components/nintendo_parental_controls/strings.json +++ b/homeassistant/components/nintendo_parental_controls/strings.json @@ -90,9 +90,15 @@ "invalid_device": { "message": "The specified device is not a Nintendo device." }, + "invalid_entity": { + "message": "The selected sensor is invalid, make sure you are selecting a Nintendo Parental Controls sensor." + }, "invalid_pin_length": { "message": "The PIN code must be a 4 to 8-digit number between 0000 and 99999999." }, + "invalid_player": { + "message": "The selected sensor is not a player sensor entity. Make sure you have selected the used screen time sensor for a specific player." + }, "no_devices_found": { "message": "No Nintendo devices found for this account." }, @@ -110,18 +116,40 @@ }, "device_id": { "description": "The ID of the device to add bonus time to.", - "example": "1234567890abcdef", "name": "Device" } }, "name": "Add Bonus Time" }, + "device_usage_report": { + "description": "Get today's application usage details for a device.", + "fields": { + "device_id": { + "description": "The ID of the device to get usage details for.", + "name": "Device" + } + }, + "name": "Device usage report" + }, + "player_usage_report": { + "description": "Get today's application usage details for a specific player.", + "fields": { + "device_id": { + "description": "The ID of the device to get player usage details for.", + "name": "Device" + }, + "entity_id": { + "description": "The player entity to get usage for.", + "name": "Player" + } + }, + "name": "Player usage report" + }, "update_pin_code": { "description": "Update the PIN code for the selected Nintendo Switch.", "fields": { "device_id": { "description": "The ID of the device to update the PIN code for.", - "example": "1234567890abcdef", "name": "Device" }, "pin": { diff --git a/tests/components/nintendo_parental_controls/conftest.py b/tests/components/nintendo_parental_controls/conftest.py index 8fa9fef04d05..a072d294ab68 100644 --- a/tests/components/nintendo_parental_controls/conftest.py +++ b/tests/components/nintendo_parental_controls/conftest.py @@ -11,7 +11,7 @@ from pynintendoparental.application import ( PlayedAppUsage, ) from pynintendoparental.device import Device -from pynintendoparental.enum import DeviceTimerMode +from pynintendoparental.enum import DeviceTimerMode, SafeLaunchSetting from pynintendoparental.player import Player, PlayerRegistry import pytest @@ -38,6 +38,8 @@ def mock_nintendo_app() -> Application: mock_app = MagicMock(spec=Application) mock_app.application_id = "testappid" mock_app.name = "Test Game Name" + mock_app.image_url = "http://example.com/image.png" + mock_app.safe_launch_setting = SafeLaunchSetting.ALLOW return mock_app diff --git a/tests/components/nintendo_parental_controls/const.py b/tests/components/nintendo_parental_controls/const.py index 39590305a7d6..d361534f5f21 100644 --- a/tests/components/nintendo_parental_controls/const.py +++ b/tests/components/nintendo_parental_controls/const.py @@ -3,3 +3,5 @@ ACCOUNT_ID = "aabbccddee112233" API_TOKEN = "valid_token" LOGIN_URL = "http://example.com" + +PLAYER_ENTITY_ID = "sensor.home_assistant_test_ha_gamer_used_screen_time" diff --git a/tests/components/nintendo_parental_controls/snapshots/test_services.ambr b/tests/components/nintendo_parental_controls/snapshots/test_services.ambr new file mode 100644 index 000000000000..95537d89e869 --- /dev/null +++ b/tests/components/nintendo_parental_controls/snapshots/test_services.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_get_device_application_report + dict({ + 'HA Gamer': list([ + dict({ + 'image': 'http://example.com/image.png', + 'name': 'Test Game Name', + 'playing_time': 110, + 'whitelisted': True, + }), + ]), + }) +# --- +# name: test_get_player_application_report + dict({ + 'apps': list([ + dict({ + 'image': 'http://example.com/image.png', + 'name': 'Test Game Name', + 'playing_time': 110, + 'whitelisted': True, + }), + ]), + }) +# --- diff --git a/tests/components/nintendo_parental_controls/test_services.py b/tests/components/nintendo_parental_controls/test_services.py index 46f622875c30..94d8ccc61147 100644 --- a/tests/components/nintendo_parental_controls/test_services.py +++ b/tests/components/nintendo_parental_controls/test_services.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import AsyncMock import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.nintendo_parental_controls.const import ( ATTR_BONUS_TIME, @@ -12,12 +13,13 @@ from homeassistant.components.nintendo_parental_controls.const import ( from homeassistant.components.nintendo_parental_controls.services import ( NintendoParentalServices, ) -from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PIN from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Context, HomeAssistant from homeassistant.exceptions import ServiceValidationError, Unauthorized -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration +from .const import PLAYER_ENTITY_ID from tests.common import MockConfigEntry, MockUser @@ -48,44 +50,64 @@ async def test_add_bonus_time( @pytest.mark.parametrize( - ("service", "payload", "exception_domain", "exception_key"), + ("service", "payload", "return_response", "exception_domain", "exception_key"), [ ( NintendoParentalServices.ADD_BONUS_TIME, {ATTR_DEVICE_ID: "invalid_device", ATTR_BONUS_TIME: 15}, + False, HOMEASSISTANT_DOMAIN, "service_device_not_found", ), ( NintendoParentalServices.UPDATE_PIN_CODE, {ATTR_DEVICE_ID: "invalid_device", CONF_PIN: "1234"}, + False, HOMEASSISTANT_DOMAIN, "service_device_not_found", ), ( NintendoParentalServices.UPDATE_PIN_CODE, {ATTR_DEVICE_ID: "invalid_device", CONF_PIN: "123"}, + False, DOMAIN, "invalid_pin_length", ), ( NintendoParentalServices.UPDATE_PIN_CODE, {ATTR_DEVICE_ID: "invalid_device", CONF_PIN: "123456789"}, + False, DOMAIN, "invalid_pin_length", ), ( NintendoParentalServices.UPDATE_PIN_CODE, {ATTR_DEVICE_ID: "invalid_device", CONF_PIN: "0000"}, + False, HOMEASSISTANT_DOMAIN, "service_device_not_found", ), ( NintendoParentalServices.UPDATE_PIN_CODE, {ATTR_DEVICE_ID: "invalid_device", CONF_PIN: "abc"}, + False, DOMAIN, "invalid_pin_length", ), + ( + NintendoParentalServices.DEVICE_USAGE_REPORT, + {ATTR_DEVICE_ID: "invalid_device"}, + True, + HOMEASSISTANT_DOMAIN, + "service_device_not_found", + ), + ( + NintendoParentalServices.PLAYER_USAGE_REPORT, + {ATTR_DEVICE_ID: "invalid_device", ATTR_ENTITY_ID: PLAYER_ENTITY_ID}, + True, + HOMEASSISTANT_DOMAIN, + "service_device_not_found", + ), ], ) async def test_service_no_device_exceptions( @@ -94,6 +116,7 @@ async def test_service_no_device_exceptions( mock_nintendo_client: AsyncMock, service: NintendoParentalServices, payload: dict[str, Any], + return_response: bool, exception_domain: str, exception_key: str, ) -> None: @@ -101,10 +124,7 @@ async def test_service_no_device_exceptions( await setup_integration(hass, mock_config_entry) with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( - DOMAIN, - service, - payload, - blocking=True, + DOMAIN, service, payload, blocking=True, return_response=return_response ) assert err.value.translation_domain == exception_domain assert err.value.translation_key == exception_key @@ -202,3 +222,107 @@ async def test_update_pin_code_requires_admin( context=Context(user_id=hass_read_only_user.id), ) mock_nintendo_device.set_new_pin.assert_not_called() + + +async def test_get_player_application_report( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + mock_nintendo_device: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test device usage report retrieval.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "testdevid"), mock_config_entry.entry_id + ) + assert device_entry + player_entity = entity_registry.async_get(PLAYER_ENTITY_ID) + assert player_entity + response = await hass.services.async_call( + DOMAIN, + NintendoParentalServices.PLAYER_USAGE_REPORT, + {ATTR_DEVICE_ID: device_entry.id, ATTR_ENTITY_ID: PLAYER_ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +async def test_get_device_application_report( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + mock_nintendo_device: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test device usage report retrieval.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "testdevid"), mock_config_entry.entry_id + ) + assert device_entry + player_entity = entity_registry.async_get(PLAYER_ENTITY_ID) + assert player_entity + response = await hass.services.async_call( + DOMAIN, + NintendoParentalServices.DEVICE_USAGE_REPORT, + { + ATTR_DEVICE_ID: device_entry.id, + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +@pytest.mark.parametrize( + ("service", "payload", "return_response", "exception_domain", "exception_key"), + [ + ( + NintendoParentalServices.PLAYER_USAGE_REPORT, + {ATTR_ENTITY_ID: "sensor.not_found"}, + True, + DOMAIN, + "invalid_entity", + ), + ( + NintendoParentalServices.PLAYER_USAGE_REPORT, + {ATTR_ENTITY_ID: "sensor.home_assistant_test_screen_time_remaining"}, + True, + DOMAIN, + "invalid_player", + ), + ], +) +async def test_player_service_failures( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_nintendo_client: AsyncMock, + service: NintendoParentalServices, + payload: dict[str, Any], + return_response: bool, + exception_domain: str, + exception_key: str, +) -> None: + """Test that player specific services raise expected exceptions.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "testdevid"), mock_config_entry.entry_id + ) + assert device_entry + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + service, + {ATTR_DEVICE_ID: device_entry.id, **payload}, + blocking=True, + return_response=return_response, + ) + assert err.value.translation_domain == exception_domain + assert err.value.translation_key == exception_key