Add new Nintendo Parental Controls integration (#145343)

Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Jordan Harvey
2025-10-06 17:36:46 +01:00
committed by GitHub
parent ade424c074
commit f72047eb02
18 changed files with 646 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -1065,6 +1065,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/nilu/ @hfurubotten
/homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum
/homeassistant/components/nintendo_parental/ @pantherale0
/tests/components/nintendo_parental/ @pantherale0
/homeassistant/components/nissan_leaf/ @filcole
/homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe

View File

@@ -0,0 +1,51 @@
"""The Nintendo Switch Parental Controls integration."""
from __future__ import annotations
from pynintendoparental import Authenticator
from pynintendoparental.exceptions import (
InvalidOAuthConfigurationException,
InvalidSessionTokenException,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_SESSION_TOKEN, DOMAIN
from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(
hass: HomeAssistant, entry: NintendoParentalConfigEntry
) -> bool:
"""Set up Nintendo Switch Parental Controls from a config entry."""
try:
nintendo_auth = await Authenticator.complete_login(
auth=None,
response_token=entry.data[CONF_SESSION_TOKEN],
is_session_token=True,
client_session=async_get_clientsession(hass),
)
except (InvalidSessionTokenException, InvalidOAuthConfigurationException) as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="auth_expired",
) from err
entry.runtime_data = coordinator = NintendoUpdateCoordinator(
hass, nintendo_auth, entry
)
await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: NintendoParentalConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -0,0 +1,61 @@
"""Config flow for the Nintendo Switch Parental Controls integration."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from pynintendoparental import Authenticator
from pynintendoparental.exceptions import HttpException, InvalidSessionTokenException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_SESSION_TOKEN, DOMAIN
_LOGGER = logging.getLogger(__name__)
class NintendoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Nintendo Switch Parental Controls."""
def __init__(self) -> None:
"""Initialize a new config flow instance."""
self.auth: Authenticator | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
if self.auth is None:
self.auth = Authenticator.generate_login(
client_session=async_get_clientsession(self.hass)
)
if user_input is not None:
try:
await self.auth.complete_login(
self.auth, user_input[CONF_API_TOKEN], False
)
except (ValueError, InvalidSessionTokenException, HttpException):
errors["base"] = "invalid_auth"
else:
if TYPE_CHECKING:
assert self.auth.account_id
await self.async_set_unique_id(self.auth.account_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=self.auth.account_id,
data={
CONF_SESSION_TOKEN: self.auth.get_session_token,
},
)
return self.async_show_form(
step_id="user",
description_placeholders={"link": self.auth.login_url},
data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
errors=errors,
)

View File

@@ -0,0 +1,5 @@
"""Constants for the Nintendo Switch Parental Controls integration."""
DOMAIN = "nintendo_parental"
CONF_UPDATE_INTERVAL = "update_interval"
CONF_SESSION_TOKEN = "session_token"

View File

@@ -0,0 +1,52 @@
"""Nintendo Parental Controls data coordinator."""
from __future__ import annotations
from datetime import timedelta
import logging
from pynintendoparental import Authenticator, NintendoParental
from pynintendoparental.exceptions import InvalidOAuthConfigurationException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
type NintendoParentalConfigEntry = ConfigEntry[NintendoUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
class NintendoUpdateCoordinator(DataUpdateCoordinator[None]):
"""Nintendo data update coordinator."""
def __init__(
self,
hass: HomeAssistant,
authenticator: Authenticator,
config_entry: NintendoParentalConfigEntry,
) -> None:
"""Initialize update coordinator."""
super().__init__(
hass=hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=config_entry,
)
self.api = NintendoParental(
authenticator, hass.config.time_zone, hass.config.language
)
async def _async_update_data(self) -> None:
"""Update data from Nintendo's API."""
try:
return await self.api.update()
except InvalidOAuthConfigurationException as err:
raise ConfigEntryError(
err, translation_domain=DOMAIN, translation_key="invalid_auth"
) from err

View File

@@ -0,0 +1,41 @@
"""Base entity definition for Nintendo Parental."""
from __future__ import annotations
from pynintendoparental.device import Device
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import NintendoUpdateCoordinator
class NintendoDevice(CoordinatorEntity[NintendoUpdateCoordinator]):
"""Represent a Nintendo Switch."""
_attr_has_entity_name = True
def __init__(
self, coordinator: NintendoUpdateCoordinator, device: Device, key: str
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._device = device
self._attr_unique_id = f"{device.device_id}_{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.device_id)},
manufacturer="Nintendo",
name=device.name,
sw_version=device.extra["firmwareVersion"]["displayedVersion"],
)
async def async_added_to_hass(self) -> None:
"""When entity is loaded."""
await super().async_added_to_hass()
self._device.add_device_callback(self.async_write_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""When will be removed from HASS."""
self._device.remove_device_callback(self.async_write_ha_state)
await super().async_will_remove_from_hass()

View File

@@ -0,0 +1,11 @@
{
"domain": "nintendo_parental",
"name": "Nintendo Switch Parental Controls",
"codeowners": ["@pantherale0"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nintendo_parental",
"iot_class": "cloud_polling",
"loggers": ["pynintendoparental"],
"quality_scale": "bronze",
"requirements": ["pynintendoparental==1.0.1"]
}

View File

@@ -0,0 +1,81 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No custom actions are defined.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
No IP discovery.
discovery:
status: exempt
comment: |
No discovery.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: |
No specific icons defined.
reconfiguration-flow: todo
repair-issues:
comment: |
No issues in integration
status: exempt
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,91 @@
"""Sensor platform for Nintendo Parental."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator
from .entity import Device, NintendoDevice
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class NintendoParentalSensor(StrEnum):
"""Store keys for Nintendo Parental sensors."""
PLAYING_TIME = "playing_time"
TIME_REMAINING = "time_remaining"
@dataclass(kw_only=True, frozen=True)
class NintendoParentalSensorEntityDescription(SensorEntityDescription):
"""Description for Nintendo Parental sensor entities."""
value_fn: Callable[[Device], int | float | None]
SENSOR_DESCRIPTIONS: tuple[NintendoParentalSensorEntityDescription, ...] = (
NintendoParentalSensorEntityDescription(
key=NintendoParentalSensor.PLAYING_TIME,
translation_key=NintendoParentalSensor.PLAYING_TIME,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda device: device.today_playing_time,
),
NintendoParentalSensorEntityDescription(
key=NintendoParentalSensor.TIME_REMAINING,
translation_key=NintendoParentalSensor.TIME_REMAINING,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda device: device.today_time_remaining,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: NintendoParentalConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
async_add_devices(
NintendoParentalSensorEntity(entry.runtime_data, device, sensor)
for device in entry.runtime_data.api.devices.values()
for sensor in SENSOR_DESCRIPTIONS
)
class NintendoParentalSensorEntity(NintendoDevice, SensorEntity):
"""Represent a single sensor."""
entity_description: NintendoParentalSensorEntityDescription
def __init__(
self,
coordinator: NintendoUpdateCoordinator,
device: Device,
description: NintendoParentalSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator=coordinator, device=device, key=description.key)
self.entity_description = description
@property
def native_value(self) -> int | float | None:
"""Return the native value."""
return self.entity_description.value_fn(self._device)

View File

@@ -0,0 +1,38 @@
{
"config": {
"step": {
"user": {
"description": "To obtain your access token, click [Nintendo Login]({link}) to sign in to your Nintendo account. Then, for the account you want to link, right-click on the red **Select this person** button and choose **Copy Link Address**.",
"data": {
"api_token": "Access token"
},
"data_description": {
"api_token": "The link copied from the Nintendo website"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
},
"entity": {
"sensor": {
"playing_time": {
"name": "Used screen time"
},
"time_remaining": {
"name": "Screen time remaining"
}
}
},
"exceptions": {
"auth_expired": {
"message": "Authentication expired. Please remove and re-add the integration to reconnect."
}
}
}

View File

@@ -440,6 +440,7 @@ FLOWS = {
"nightscout",
"niko_home_control",
"nina",
"nintendo_parental",
"nmap_tracker",
"nmbs",
"nobo_hub",

View File

@@ -4459,6 +4459,12 @@
"iot_class": "cloud_polling",
"single_config_entry": true
},
"nintendo_parental": {
"name": "Nintendo Switch Parental Controls",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"nissan_leaf": {
"name": "Nissan Leaf",
"integration_type": "hub",

3
requirements_all.txt generated
View File

@@ -2209,6 +2209,9 @@ pynetio==0.1.9.1
# homeassistant.components.nina
pynina==0.3.6
# homeassistant.components.nintendo_parental
pynintendoparental==1.0.1
# homeassistant.components.nobo_hub
pynobo==1.8.1

View File

@@ -1845,6 +1845,9 @@ pynetgear==0.10.10
# homeassistant.components.nina
pynina==0.3.6
# homeassistant.components.nintendo_parental
pynintendoparental==1.0.1
# homeassistant.components.nobo_hub
pynobo==1.8.1

View File

@@ -0,0 +1 @@
"""Tests for the Nintendo Switch Parental Controls integration."""

View File

@@ -0,0 +1,93 @@
"""Common fixtures for the Nintendo Switch Parental Controls tests."""
from collections.abc import Generator
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from pynintendoparental.device import Device
import pytest
from homeassistant.components.nintendo_parental.const import DOMAIN
from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={"session_token": API_TOKEN},
unique_id=ACCOUNT_ID,
)
@pytest.fixture
def mock_nintendo_device() -> Device:
"""Return a mocked device."""
mock = AsyncMock(spec=Device)
mock.device_id = "testdevid"
mock.name = "Home Assistant Test"
mock.extra = {"device": {"firmwareVersion": {"displayedVersion": "99.99.99"}}}
mock.limit_time = 120
mock.today_playing_time = 110
return mock
@pytest.fixture
def mock_nintendo_authenticator() -> Generator[MagicMock]:
"""Mock Nintendo Authenticator."""
with (
patch(
"homeassistant.components.nintendo_parental.Authenticator",
autospec=True,
) as mock_auth_class,
patch(
"homeassistant.components.nintendo_parental.config_flow.Authenticator",
new=mock_auth_class,
),
):
mock_auth = MagicMock()
mock_auth._id_token = API_TOKEN
mock_auth._at_expiry = datetime(2099, 12, 31, 23, 59, 59)
mock_auth.account_id = ACCOUNT_ID
mock_auth.login_url = LOGIN_URL
mock_auth.get_session_token = API_TOKEN
# Patch complete_login as an AsyncMock on both instance and class as this is a class method
mock_auth.complete_login = AsyncMock()
type(mock_auth).complete_login = mock_auth.complete_login
mock_auth_class.generate_login.return_value = mock_auth
yield mock_auth
@pytest.fixture
def mock_nintendo_client(
mock_nintendo_device: Device,
) -> Generator[AsyncMock]:
"""Mock a Nintendo client."""
with (
patch(
"homeassistant.components.nintendo_parental.NintendoParental",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.nintendo_parental.config_flow.NintendoParental",
new=mock_client,
),
):
client = mock_client.return_value
client.update.return_value = True
client.devices.return_value = {"testdevid": mock_nintendo_device}
yield client
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.nintendo_parental.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry

View File

@@ -0,0 +1,5 @@
"""Constants for the Nintendo Parental Controls test suite."""
ACCOUNT_ID = "aabbccddee112233"
API_TOKEN = "valid_token"
LOGIN_URL = "http://example.com"

View File

@@ -0,0 +1,101 @@
"""Test the Nintendo Switch Parental Controls config flow."""
from unittest.mock import AsyncMock
from pynintendoparental.exceptions import InvalidSessionTokenException
from homeassistant import config_entries
from homeassistant.components.nintendo_parental.const import CONF_SESSION_TOKEN, DOMAIN
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL
from tests.common import MockConfigEntry
async def test_full_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_nintendo_authenticator: AsyncMock,
) -> None:
"""Test a full and successful config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result is not None
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert "link" in result["description_placeholders"]
assert result["description_placeholders"]["link"] == LOGIN_URL
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ACCOUNT_ID
assert result["data"][CONF_SESSION_TOKEN] == API_TOKEN
assert result["result"].unique_id == ACCOUNT_ID
async def test_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nintendo_authenticator: AsyncMock,
) -> None:
"""Test that the flow aborts if the account is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_invalid_auth(
hass: HomeAssistant,
mock_nintendo_authenticator: AsyncMock,
) -> None:
"""Test handling of invalid authentication."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result is not None
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert "link" in result["description_placeholders"]
# Simulate invalid authentication by raising an exception
mock_nintendo_authenticator.complete_login.side_effect = (
InvalidSessionTokenException
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_TOKEN: "invalid_token"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "invalid_auth"}
# Now ensure that the flow can be recovered
mock_nintendo_authenticator.complete_login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == ACCOUNT_ID
assert result["data"][CONF_SESSION_TOKEN] == API_TOKEN
assert result["result"].unique_id == ACCOUNT_ID