Compare commits

..

1 Commits

Author SHA1 Message Date
jbouwh
96c111c96c Add MQTT select subentry support 2025-10-06 15:39:21 +00:00
32 changed files with 190 additions and 893 deletions

2
CODEOWNERS generated
View File

@@ -1065,8 +1065,6 @@ 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

@@ -458,6 +458,7 @@ SUBENTRY_PLATFORMS = [
Platform.LOCK,
Platform.NOTIFY,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
@@ -1141,6 +1142,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.LOCK.value: None,
Platform.NOTIFY.value: None,
Platform.NUMBER.value: validate_number_platform_config,
Platform.SELECT: None,
Platform.SENSOR.value: validate_sensor_platform_config,
Platform.SWITCH.value: None,
}
@@ -1367,6 +1369,7 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
custom_filtering=True,
),
},
Platform.SELECT.value: {},
Platform.SENSOR.value: {
CONF_DEVICE_CLASS: PlatformField(
selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False
@@ -3103,6 +3106,34 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.SELECT.value: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_OPTIONS: PlatformField(selector=OPTIONS_SELECTOR, required=True),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.SENSOR.value: {
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,

View File

@@ -346,6 +346,7 @@
"mode_state_template": "Operation mode value template",
"on_command_type": "ON command type",
"optimistic": "Optimistic",
"options": "Set options",
"payload_off": "Payload \"off\"",
"payload_on": "Payload \"on\"",
"payload_press": "Payload \"press\"",
@@ -393,6 +394,7 @@
"mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the operation mode state. [Learn more.]({url}#mode_state_template)",
"on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.",
"optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)",
"options": "List of options that can be selected.",
"payload_off": "The payload that represents the \"off\" state.",
"payload_on": "The payload that represents the \"on\" state.",
"payload_press": "The payload to send when the button is triggered.",
@@ -1334,6 +1336,7 @@
"lock": "[%key:component::lock::title%]",
"notify": "[%key:component::notify::title%]",
"number": "[%key:component::number::title%]",
"select": "[%key:component::select::title%]",
"sensor": "[%key:component::sensor::title%]",
"switch": "[%key:component::switch::title%]"
}

View File

@@ -1,51 +0,0 @@
"""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

@@ -1,61 +0,0 @@
"""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

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

View File

@@ -1,52 +0,0 @@
"""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

@@ -1,41 +0,0 @@
"""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

@@ -1,11 +0,0 @@
{
"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

@@ -1,81 +0,0 @@
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

@@ -1,91 +0,0 @@
"""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

@@ -1,38 +0,0 @@
{
"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

@@ -22,7 +22,7 @@
"name": "Mode",
"state": {
"auto": "Automatic",
"box": "Input field",
"box": "Box",
"slider": "Slider"
}
},

View File

@@ -20,9 +20,8 @@ set_temperature:
selector:
number:
min: 0
max: 250
max: 100
step: 0.5
mode: box
unit_of_measurement: "°"
operation_mode:
example: eco

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from typing import Any
import voluptuous as vol
@@ -21,7 +20,7 @@ from homeassistant.const import (
CONF_PLATFORM,
CONF_TYPE,
)
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
@@ -455,28 +454,8 @@ async def async_attach_trigger(
zwave_js_config = await validate_value_updated_trigger_config(
hass, zwave_js_config
)
@callback
def run_action(
extra_trigger_payload: dict[str, Any],
description: str,
context: Context | None = None,
) -> asyncio.Task[Any]:
"""Run action with trigger variables."""
payload = {
"trigger": {
**trigger_info["trigger_data"],
CONF_PLATFORM: VALUE_UPDATED_PLATFORM_TYPE,
"description": description,
**extra_trigger_payload,
}
}
return hass.async_create_task(action(payload, context))
return await attach_value_updated_trigger(
hass, zwave_js_config[CONF_OPTIONS], run_action
hass, zwave_js_config[CONF_OPTIONS], action, trigger_info
)
raise HomeAssistantError(f"Unhandled trigger type {trigger_type}")

View File

@@ -17,12 +17,19 @@ from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_PLATFORM,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.trigger import (
Trigger,
TriggerActionType,
TriggerConfig,
TriggerData,
TriggerInfo,
)
from homeassistant.helpers.typing import ConfigType
from ..const import (
@@ -120,13 +127,17 @@ _CONFIG_SCHEMA = vol.Schema(
class EventTrigger(Trigger):
"""Z-Wave JS event trigger."""
_hass: HomeAssistant
_options: dict[str, Any]
_event_source: str
_event_name: str
_event_data_filter: dict
_job: HassJob
_trigger_data: TriggerData
_unsubs: list[Callable]
_action_runner: TriggerActionRunner
_platform_type = PLATFORM_TYPE
@classmethod
async def async_validate_complete_config(
@@ -165,12 +176,14 @@ class EventTrigger(Trigger):
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
self._hass = hass
assert config.options is not None
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
dev_reg = dr.async_get(self._hass)
@@ -185,7 +198,8 @@ class EventTrigger(Trigger):
self._event_source = options[ATTR_EVENT_SOURCE]
self._event_name = options[ATTR_EVENT]
self._event_data_filter = options.get(ATTR_EVENT_DATA, {})
self._action_runner = run_action
self._job = HassJob(action)
self._trigger_data = trigger_info["trigger_data"]
self._unsubs: list[Callable] = []
self._create_zwave_listeners()
@@ -211,7 +225,9 @@ class EventTrigger(Trigger):
if event_data[key] != val:
return
payload: dict[str, Any] = {
payload = {
**self._trigger_data,
CONF_PLATFORM: self._platform_type,
ATTR_EVENT_SOURCE: self._event_source,
ATTR_EVENT: self._event_name,
ATTR_EVENT_DATA: event_data,
@@ -221,17 +237,21 @@ class EventTrigger(Trigger):
f"Z-Wave JS '{self._event_source}' event '{self._event_name}' was emitted"
)
description = primary_desc
if device:
device_name = device.name_by_user or device.name
payload[ATTR_DEVICE_ID] = device.id
home_and_node_id = get_home_and_node_id_from_device_entry(device)
assert home_and_node_id
payload[ATTR_NODE_ID] = home_and_node_id[1]
description = f"{primary_desc} on {device_name}"
payload["description"] = f"{primary_desc} on {device_name}"
else:
payload["description"] = primary_desc
description = f"{description} with event data: {event_data}"
self._action_runner(payload, description)
payload["description"] = (
f"{payload['description']} with event data: {event_data}"
)
self._hass.async_run_hass_job(self._job, {"trigger": payload})
@callback
def _async_remove(self) -> None:

View File

@@ -11,12 +11,23 @@ from zwave_js_server.const import CommandClass
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.value import Value, get_value_id_str
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_OPTIONS, MATCH_ALL
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_ENTITY_ID,
CONF_OPTIONS,
CONF_PLATFORM,
MATCH_ALL,
)
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.trigger import (
Trigger,
TriggerActionType,
TriggerConfig,
TriggerInfo,
)
from homeassistant.helpers.typing import ConfigType
from ..config_validation import VALUE_SCHEMA
@@ -89,7 +100,12 @@ async def async_validate_trigger_config(
async def async_attach_trigger(
hass: HomeAssistant, options: ConfigType, run_action: TriggerActionRunner
hass: HomeAssistant,
options: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
*,
platform_type: str = PLATFORM_TYPE,
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
dev_reg = dr.async_get(hass)
@@ -105,6 +121,9 @@ async def async_attach_trigger(
endpoint = options.get(ATTR_ENDPOINT)
property_key = options.get(ATTR_PROPERTY_KEY)
unsubs: list[Callable] = []
job = HassJob(action)
trigger_data = trigger_info["trigger_data"]
@callback
def async_on_value_updated(
@@ -133,8 +152,10 @@ async def async_attach_trigger(
return
device_name = device.name_by_user or device.name
description = f"Z-Wave value {value.value_id} updated on {device_name}"
payload = {
**trigger_data,
CONF_PLATFORM: platform_type,
ATTR_DEVICE_ID: device.id,
ATTR_NODE_ID: value.node.node_id,
ATTR_COMMAND_CLASS: value.command_class,
@@ -148,9 +169,10 @@ async def async_attach_trigger(
ATTR_PREVIOUS_VALUE_RAW: prev_value_raw,
ATTR_CURRENT_VALUE: curr_value,
ATTR_CURRENT_VALUE_RAW: curr_value_raw,
"description": f"Z-Wave value {value.value_id} updated on {device_name}",
}
run_action(payload, description)
hass.async_run_hass_job(job, {"trigger": payload})
@callback
def async_remove() -> None:
@@ -201,6 +223,7 @@ async def async_attach_trigger(
class ValueUpdatedTrigger(Trigger):
"""Z-Wave JS value updated trigger."""
_hass: HomeAssistant
_options: dict[str, Any]
@classmethod
@@ -222,12 +245,16 @@ class ValueUpdatedTrigger(Trigger):
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
self._hass = hass
assert config.options is not None
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
return await async_attach_trigger(self._hass, self._options, run_action)
return await async_attach_trigger(
self._hass, self._options, action, trigger_info
)

View File

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

View File

@@ -4459,12 +4459,6 @@
"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",

View File

@@ -28,10 +28,8 @@ from homeassistant.core import (
CALLBACK_TYPE,
Context,
HassJob,
HassJobType,
HomeAssistant,
callback,
get_hassjob_callable_job_type,
is_callback,
)
from homeassistant.exceptions import HomeAssistantError, TemplateError
@@ -180,8 +178,6 @@ _TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
class Trigger(abc.ABC):
"""Trigger class."""
_hass: HomeAssistant
@classmethod
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
@@ -216,33 +212,14 @@ class Trigger(abc.ABC):
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
self._hass = hass
async def async_attach_action(
self,
action: TriggerAction,
action_payload_builder: TriggerActionPayloadBuilder,
) -> CALLBACK_TYPE:
"""Attach the trigger to an action."""
@callback
def run_action(
extra_trigger_payload: dict[str, Any],
description: str,
context: Context | None = None,
) -> asyncio.Task[Any]:
"""Run action with trigger variables."""
payload = action_payload_builder(extra_trigger_payload, description)
return self._hass.async_create_task(action(payload, context))
return await self.async_attach_runner(run_action)
@abc.abstractmethod
async def async_attach_runner(
self, run_action: TriggerActionRunner
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
"""Attach the trigger."""
class TriggerProtocol(Protocol):
@@ -280,43 +257,6 @@ class TriggerConfig:
options: dict[str, Any] | None = None
class TriggerActionRunner(Protocol):
"""Protocol type for the trigger action runner helper callback."""
@callback
def __call__(
self,
extra_trigger_payload: dict[str, Any],
description: str,
context: Context | None = None,
) -> asyncio.Task[Any]:
"""Define trigger action runner type.
Returns:
A Task that allows awaiting for the action to finish.
"""
class TriggerActionPayloadBuilder(Protocol):
"""Protocol type for the trigger action payload builder."""
def __call__(
self, extra_trigger_payload: dict[str, Any], description: str
) -> dict[str, Any]:
"""Define trigger action payload builder type."""
class TriggerAction(Protocol):
"""Protocol type for trigger action callback."""
async def __call__(
self,
run_variables: dict[str, Any],
context: Context | None = None,
) -> Any:
"""Define action callback type."""
class TriggerActionType(Protocol):
"""Protocol type for trigger action callback."""
@@ -553,71 +493,6 @@ def _trigger_action_wrapper(
return wrapper_func
async def _async_attach_trigger_cls(
hass: HomeAssistant,
trigger_cls: type[Trigger],
trigger_key: str,
conf: ConfigType,
action: Callable,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Initialize a new Trigger class and attach it."""
def action_payload_builder(
extra_trigger_payload: dict[str, Any], description: str
) -> dict[str, Any]:
"""Build action variables."""
payload = {
"trigger": {
**trigger_info["trigger_data"],
CONF_PLATFORM: trigger_key,
"description": description,
**extra_trigger_payload,
}
}
if CONF_VARIABLES in conf:
trigger_variables = conf[CONF_VARIABLES]
payload.update(trigger_variables.async_render(hass, payload))
return payload
# Wrap sync action so that it is always async.
# This should be removed when sync actions are no longer supported.
match get_hassjob_callable_job_type(action):
case HassJobType.Executor:
original_action = action
async def wrapped_executor_action(
run_variables: dict[str, Any], context: Context | None = None
) -> Any:
"""Wrap sync action to be called in executor."""
return await hass.async_add_executor_job(
original_action, run_variables, context
)
action = wrapped_executor_action
case HassJobType.Callback:
original_action = action
async def wrapped_callback_action(
run_variables: dict[str, Any], context: Context | None = None
) -> Any:
"""Wrap callback action to be awaitable."""
return original_action(run_variables, context)
action = wrapped_callback_action
trigger = trigger_cls(
hass,
TriggerConfig(
key=trigger_key,
target=conf.get(CONF_TARGET),
options=conf.get(CONF_OPTIONS),
),
)
return await trigger.async_attach_action(action, action_payload_builder)
async def async_initialize_triggers(
hass: HomeAssistant,
trigger_config: list[ConfigType],
@@ -657,17 +532,23 @@ async def async_initialize_triggers(
trigger_data=trigger_data,
)
action_wrapper = _trigger_action_wrapper(hass, action, conf)
if hasattr(platform, "async_get_triggers"):
trigger_descriptors = await platform.async_get_triggers(hass)
relative_trigger_key = get_relative_description_key(
platform_domain, trigger_key
)
trigger_cls = trigger_descriptors[relative_trigger_key]
coro = _async_attach_trigger_cls(
hass, trigger_cls, trigger_key, conf, action, info
trigger = trigger_cls(
hass,
TriggerConfig(
key=trigger_key,
target=conf.get(CONF_TARGET),
options=conf.get(CONF_OPTIONS),
),
)
coro = trigger.async_attach(action_wrapper, info)
else:
action_wrapper = _trigger_action_wrapper(hass, action, conf)
coro = platform.async_attach_trigger(hass, conf, action_wrapper, info)
triggers.append(create_eager_task(coro))

View File

@@ -15,7 +15,7 @@ astral==2.2
async-interrupt==1.2.2
async-upnp-client==0.45.0
atomicwrites-homeassistant==1.4.1
attrs==25.4.0
attrs==25.3.0
audioop-lts==0.2.1
av==13.1.0
awesomeversion==25.5.0

View File

@@ -36,7 +36,7 @@ dependencies = [
"annotatedyaml==1.0.2",
"astral==2.2",
"async-interrupt==1.2.2",
"attrs==25.4.0",
"attrs==25.3.0",
"atomicwrites-homeassistant==1.4.1",
"audioop-lts==0.2.1",
"awesomeversion==25.5.0",

2
requirements.txt generated
View File

@@ -13,7 +13,7 @@ aiozoneinfo==0.2.3
annotatedyaml==1.0.2
astral==2.2
async-interrupt==1.2.2
attrs==25.4.0
attrs==25.3.0
atomicwrites-homeassistant==1.4.1
audioop-lts==0.2.1
awesomeversion==25.5.0

3
requirements_all.txt generated
View File

@@ -2209,9 +2209,6 @@ 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,9 +1845,6 @@ 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

@@ -517,6 +517,20 @@ MOCK_SUBENTRY_NUMBER_COMPONENT_NO_UNIT = {
"entity_picture": "https://example.com/f9261f6feed443e7b7d5f3fbe2a47414",
},
}
MOCK_SUBENTRY_SELECT_COMPONENT = {
"fa261f6feed443e7b7d5f3fbe2a47414": {
"platform": "select",
"name": "Mode",
"entity_category": None,
"command_topic": "test-topic",
"command_template": "{{ value }}",
"state_topic": "test-topic",
"options": ["beer", "milk"],
"value_template": "{{ value_json.value }}",
"retain": False,
"entity_picture": "https://example.com/fa261f6feed443e7b7d5f3fbe2a47414",
},
}
MOCK_SUBENTRY_SENSOR_COMPONENT = {
"e9261f6feed443e7b7d5f3fbe2a47412": {
"platform": "sensor",
@@ -668,6 +682,10 @@ MOCK_NUMBER_SUBENTRY_DATA_NO_UNIT = {
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}},
"components": MOCK_SUBENTRY_NUMBER_COMPONENT_NO_UNIT,
}
MOCK_SELECT_SUBENTRY_DATA = {
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}},
"components": MOCK_SUBENTRY_SELECT_COMPONENT,
}
MOCK_SENSOR_SUBENTRY_DATA_SINGLE = {
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}},
"components": MOCK_SUBENTRY_SENSOR_COMPONENT,

View File

@@ -53,6 +53,7 @@ from .common import (
MOCK_NUMBER_SUBENTRY_DATA_CUSTOM_UNIT,
MOCK_NUMBER_SUBENTRY_DATA_DEVICE_CLASS_UNIT,
MOCK_NUMBER_SUBENTRY_DATA_NO_UNIT,
MOCK_SELECT_SUBENTRY_DATA,
MOCK_SENSOR_SUBENTRY_DATA_SINGLE,
MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE,
MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS,
@@ -3553,6 +3554,24 @@ async def test_migrate_of_incompatible_config_entry(
"Milk notifier Speed",
id="number_no_unit",
),
pytest.param(
MOCK_SELECT_SUBENTRY_DATA,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Mode"},
{},
(),
{
"command_topic": "test-topic",
"command_template": "{{ value }}",
"state_topic": "test-topic",
"options": ["beer", "milk"],
"value_template": "{{ value_json.value }}",
"retain": False,
},
(),
"Milk notifier Mode",
id="select",
),
pytest.param(
MOCK_SENSOR_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}},

View File

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

View File

@@ -1,93 +0,0 @@
"""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

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

View File

@@ -1,101 +0,0 @@
"""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

View File

@@ -24,7 +24,9 @@ from homeassistant.helpers.trigger import (
DATA_PLUGGABLE_ACTIONS,
PluggableAction,
Trigger,
TriggerActionRunner,
TriggerActionType,
TriggerConfig,
TriggerInfo,
_async_get_trigger_platform,
async_initialize_triggers,
async_validate_trigger_config,
@@ -447,31 +449,7 @@ async def test_pluggable_action(
assert not plug_2
class TriggerActionFunctionTypeHelper:
"""Helper for testing different trigger action function types."""
def __init__(self) -> None:
"""Init helper."""
self.action_calls = []
@callback
def cb_action(self, *args):
"""Callback action."""
self.action_calls.append([*args])
def sync_action(self, *args):
"""Sync action."""
self.action_calls.append([*args])
async def async_action(self, *args):
"""Async action."""
self.action_calls.append([*args])
@pytest.mark.parametrize("action_method", ["cb_action", "sync_action", "async_action"])
async def test_platform_multiple_triggers(
hass: HomeAssistant, action_method: str
) -> None:
async def test_platform_multiple_triggers(hass: HomeAssistant) -> None:
"""Test a trigger platform with multiple trigger."""
class MockTrigger(Trigger):
@@ -484,23 +462,30 @@ async def test_platform_multiple_triggers(
"""Validate config."""
return config
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
class MockTrigger1(MockTrigger):
"""Mock trigger 1."""
async def async_attach_runner(
self, run_action: TriggerActionRunner
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
run_action({"extra": "test_trigger_1"}, "trigger 1 desc")
action({"trigger": "test_trigger_1"})
class MockTrigger2(MockTrigger):
"""Mock trigger 2."""
async def async_attach_runner(
self, run_action: TriggerActionRunner
async def async_attach(
self,
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
run_action({"extra": "test_trigger_2"}, "trigger 2 desc")
action({"trigger": "test_trigger_2"})
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
@@ -523,41 +508,22 @@ async def test_platform_multiple_triggers(
log_cb = MagicMock()
action_helper = TriggerActionFunctionTypeHelper()
action_method = getattr(action_helper, action_method)
action_calls = []
await async_initialize_triggers(hass, config_1, action_method, "test", "", log_cb)
assert len(action_helper.action_calls) == 1
assert action_helper.action_calls[0][0] == {
"trigger": {
"alias": None,
"description": "trigger 1 desc",
"extra": "test_trigger_1",
"id": "0",
"idx": "0",
"platform": "test",
}
}
action_helper.action_calls.clear()
@callback
def cb_action(*args):
action_calls.append([*args])
await async_initialize_triggers(hass, config_2, action_method, "test", "", log_cb)
assert len(action_helper.action_calls) == 1
assert action_helper.action_calls[0][0] == {
"trigger": {
"alias": None,
"description": "trigger 2 desc",
"extra": "test_trigger_2",
"id": "0",
"idx": "0",
"platform": "test.trig_2",
}
}
action_helper.action_calls.clear()
await async_initialize_triggers(hass, config_1, cb_action, "test", "", log_cb)
assert action_calls == [[{"trigger": "test_trigger_1"}]]
action_calls.clear()
await async_initialize_triggers(hass, config_2, cb_action, "test", "", log_cb)
assert action_calls == [[{"trigger": "test_trigger_2"}]]
action_calls.clear()
with pytest.raises(KeyError):
await async_initialize_triggers(
hass, config_3, action_method, "test", "", log_cb
)
await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb)
async def test_platform_migrate_trigger(hass: HomeAssistant) -> None: