Add player reports for Nintendo Parental Controls (#180937)

This commit is contained in:
Jordan Harvey
2026-08-31 19:59:35 +02:00
committed by GitHub
parent 73f184ccd2
commit 1bdf472ac8
8 changed files with 309 additions and 13 deletions
@@ -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"
}
@@ -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
}
@@ -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
@@ -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": {
@@ -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
@@ -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"
@@ -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,
}),
]),
})
# ---
@@ -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