Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4445932
Add a player specific sensor
pantherale0 Nov 4, 2025
246299d
Update homeassistant/components/nintendo_parental_controls/sensor.py
pantherale0 Nov 6, 2025
e9d7ef3
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
pantherale0 Nov 17, 2025
66a13ce
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
pantherale0 Dec 8, 2025
f02aa7a
Rename async_add_devices to async_add_entities in Nintendo parental c…
pantherale0 Dec 8, 2025
bef5e98
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
pantherale0 Dec 9, 2025
9a1e04b
Update player sensors for 2.1.0
pantherale0 Dec 9, 2025
9845be1
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
pantherale0 Dec 9, 2025
5ae6f36
mypy fixes
pantherale0 Dec 9, 2025
709fe14
fix tests
pantherale0 Dec 9, 2025
4944d9c
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
pantherale0 Dec 16, 2025
42b88c7
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
pantherale0 Dec 17, 2025
359d378
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
pantherale0 Jan 14, 2026
4017d0f
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
pantherale0 Mar 4, 2026
0cf577f
collect sensor entities into single list
pantherale0 Mar 4, 2026
c929069
handle missing players and update tests
pantherale0 Mar 4, 2026
77958c0
remove unused imports from sensor.py
pantherale0 Mar 4, 2026
cfb24da
remove unused player_playing_time attributes in strings.json
pantherale0 Mar 4, 2026
fc93dc3
specify type for entities list in async_setup_entry
pantherale0 Mar 4, 2026
cc44f41
fix snapshot
pantherale0 Mar 4, 2026
a499e62
Update homeassistant/components/nintendo_parental_controls/sensor.py
pantherale0 Mar 4, 2026
c3d081d
Update homeassistant/components/nintendo_parental_controls/sensor.py
pantherale0 Mar 4, 2026
30528c1
update formatting and tests
pantherale0 Mar 4, 2026
aebaaa9
update tests based on review comment
pantherale0 May 8, 2026
f9ed25a
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
pantherale0 May 8, 2026
5f8276d
update snapshots
pantherale0 May 8, 2026
9b2f980
rename `player` to `player_id`
pantherale0 May 8, 2026
0e61a48
Potential fix for pull request finding
pantherale0 May 8, 2026
f61fdda
fix copilot suggestion
pantherale0 May 8, 2026
9fc9288
Merge branch 'dev' into nintendo_parental_controls/add_player_sensor
emontnemery May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 86 additions & 15 deletions homeassistant/components/nintendo_parental_controls/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from datetime import datetime
from enum import StrEnum

from pynintendoparental.player import Player

from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
Expand All @@ -26,36 +28,46 @@ 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):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the naming scheme have to follow this or could it be shortened?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've named it like this to follow the rest of the naming scheme for other entities. Long, but I need to be able to pass the translation placeholder for the account name.

"""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,
device_class=SensorDeviceClass.DURATION,
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,
device_class=SensorDeviceClass.DURATION,
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,
Expand All @@ -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
)
Comment thread
pantherale0 marked this conversation as resolved.
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)
Expand All @@ -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."""
Comment thread
pantherale0 marked this conversation as resolved.
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
Comment thread
pantherale0 marked this conversation as resolved.

@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))
Comment thread
pantherale0 marked this conversation as resolved.
Comment thread
pantherale0 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
}
},
"sensor": {
"player_playing_time": {
"name": "{nickname} used screen time"
},
"playing_time": {
"name": "Used screen time"
},
Expand Down
28 changes: 27 additions & 1 deletion tests/components/nintendo_parental_controls/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'HA Gamer used screen time',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'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': <NintendoParentalControlsSensor.PLAYER_PLAYING_TIME: 'player_playing_time'>,
'unique_id': 'testdevid_testplayerid_player_playing_time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# 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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.home_assistant_test_ha_gamer_used_screen_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '110',
})
# ---
# name: test_sensor[sensor.home_assistant_test_screen_time_remaining-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
Expand Down
33 changes: 32 additions & 1 deletion tests/components/nintendo_parental_controls/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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