mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Add player reports for Nintendo Parental Controls (#180937)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user